Separation of performing subprocess commands, parsing them for error strings, and handling those errors as enum types. Systemwide error handling was introduced via a wrapper to all systemwide functions requiring the sudo scripts.

This commit is contained in:
SimplifiedPrivacy 2026-07-23 18:41:52 -04:00
parent 4d6f810100
commit 1baaa4042c
11 changed files with 287 additions and 104 deletions

View file

@ -3,6 +3,11 @@
# 2.4.3
### Systemwide Killswitch Introduced for Enable
### July 23, 2026
Separation of performing subprocess commands, parsing them for error strings, and handling those errors as enum types. Systemwide error handling was introduced via a wrapper to all systemwide functions requiring the sudo scripts.
<br/>
### July 20, 2026
Introduced IP-interface-based killswitch support for systemwide wireguard, on enable only. This version is stable and working for enable only, with a "test" script in the fileslot. And fixed GUI's object save creation. Additionally rolled back Connection data models to previous non-enum version.
<br/>

View file

@ -47,7 +47,11 @@ if [[ "$ACTION" == "set" ]]; then
_set_dns
fi
if [[ "$ACTION" == "revert" ]]; then
resolvectl revert "$IFACE" 2>/dev/null || true
# if [[ "$ACTION" == "revert" ]]; then
# resolvectl revert "$IFACE" 2>/dev/null || true
# fi
if [[ "$ACTION" == "revert" ]]; then
resolvectl revert "$IFACE" || true
fi

View file

@ -13,7 +13,9 @@ class SystemStateController:
@staticmethod
def create(profile_id: int, firewalled: bool, dns_set: bool) -> SystemState:
return SystemState(profile_id, firewalled, dns_set).save()
current_state = SystemState(profile_id, firewalled, dns_set)
current_state.save()
return current_state
@staticmethod
def update_or_create(system_state):

View file

@ -1,3 +1,25 @@
from core.models.Result import Result, ResultError
class SudoScript(Exception):
"""There are issues with the sudo scripts"""
def __init__(self, result: Result):
self.result = result
if result.message and result.message is not None:
error_msg = result.message
else:
error_msg = result.error_type
super().__init__(error_msg)
class MissingPreReqs(Exception):
"""User can't take an action because they lack the prereqs"""
def __init__(self, result: Result):
self.result = result
if result.message and result.message is not None:
error_msg = result.message
else:
error_msg = result.error_type
super().__init__(error_msg)
class TorServiceInitializationError(Exception):
pass

View file

@ -9,6 +9,8 @@ class ResultError(Enum):
SUCCESS = "success"
NOT_SUPPORTED = "NOT_SUPPORTED"
INVALID_INPUT = "invalid_input"
MISSING_FILE = "missing_file"
MISSING_DEPENDENCY = "missing_dependency"
CONNECTION = "connection"
DATABASE = "database"
PERMISSION = "permission"
@ -17,6 +19,8 @@ class ResultError(Enum):
FIREWALL = "firewall"
CLIENT_DNS = "client_dns"
EXTERNAL_DNS = "external_dns"
INTERFACE = "interface"
TIMEOUT = "timeout"
UNKNOWN = "unknown"
@dataclass
@ -25,6 +29,7 @@ class Result():
error_type: ResultError = ResultError.SUCCESS
data: Optional[Any] = None
message: Optional[str] = None
goal: Optional[str] = None
def user_message(self) -> str:
"""Human-readable error for the UI."""

View file

@ -1,69 +1,49 @@
# custom
from core.services.networking.systemwide.systemwide_errors import systemwide_hell_raiser
from core.utils.basic_operations.wrap_with import wrap_with
from core.utils.basic_operations.run_generic_command import run_generic_command
from core.Constants import Constants
from core.models.Result import Result, ResultError
from core.errors.logger import logger
import subprocess
from subprocess import CalledProcessError
DNS_SCRIPT = f"/opt/hydra-veil/dns"
# requires script to have sudo
@wrap_with(systemwide_hell_raiser)
def set_on(network_interface: str, dns_should_be: str) -> Result:
result = subprocess.run(
["sudo", DNS_SCRIPT, "set", network_interface, dns_should_be],
capture_output=True,
text=True,
)
command = ["sudo", DNS_SCRIPT, "set", network_interface, dns_should_be]
human_readable_goal = "Set DNS"
return run_generic_command(command, human_readable_goal)
if result.returncode == 0:
logger.info("DNS setup successful")
return Result(valid=True)
else:
logger.error(f"DNS setup failed: {result.stderr.strip()}")
return Result(valid=False, message=result.stderr.strip())
# requires script to have sudo
@wrap_with(systemwide_hell_raiser)
def revert() -> Result:
try:
result = subprocess.run(
["sudo", DNS_SCRIPT, "revert"],
capture_output=True,
text=True,
)
if result.returncode == 0:
logger.info("DNS revert successful")
return Result(valid=True)
else:
logger.error(f"DNS revert failed: {result.stderr.strip()}")
return Result(valid=False, message=result.stderr.strip())
except CalledProcessError as e:
return Result(valid=False, message=e)
command = ["sudo", DNS_SCRIPT, "revert"]
human_readable_goal = "DNS revert"
return run_generic_command(command, human_readable_goal)
# does NOT need sudo
def is_systemd_enabled() -> Result:
try:
process = subprocess.Popen(('systemctl', 'is-enabled', 'systemd-resolved'), stdout=subprocess.PIPE)
process_output = str(process.stdout.read())
if 'enabled' in process_output:
command = ["systemctl", "is-enabled", "systemd-resolved"]
human_readable_goal = "Find out if SystemD is enabled"
result = run_generic_command(command, human_readable_goal, timeout=5)
if result.valid:
if "enabled" in result.data:
return Result(valid=True)
else:
return Result(valid=False, message=process_output)
except CalledProcessError as e:
return Result(valid=False, message=e)
logger.error("Systemctl is not even enabled.")
return Result(valid=False) # this would be true in the original object just because the command ran.
else:
return result # send the error itself.
# does NOT need sudo
def get_resolvctl_data(which_command: str) -> Result:
try:
process_output = subprocess.check_output(('resolvectl', which_command), text=True)
return Result(valid=True, data=process_output)
except CalledProcessError as e:
return Result(valid=False, message=e)
command = ['resolvectl', which_command]
human_readable_goal = "Get Resolvctl Data"
return run_generic_command(command, human_readable_goal, timeout=5)
# These are the raw Linux systemctl commands:

View file

@ -1,41 +1,37 @@
import subprocess
from core.services.networking.systemwide.systemwide_errors import systemwide_hell_raiser
from core.utils.basic_operations.wrap_with import wrap_with
from core.utils.basic_operations.run_generic_command import run_generic_command
from core.models.Result import Result, ResultError
from core.errors.logger import logger
import subprocess
KILLSWITCH_WRAPPER = "/opt/hydra-veil/firewall" # needs chmod 755
@wrap_with(systemwide_hell_raiser)
def arm(server_ip: str, tunnel_if: str, internal_subnet: str = None) -> Result:
cmd = ["sudo", KILLSWITCH_WRAPPER, "arm", server_ip, tunnel_if]
command = ["sudo", KILLSWITCH_WRAPPER, "arm", server_ip, tunnel_if]
if internal_subnet:
cmd.append(internal_subnet)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
error_log = f"Failed to arm firewall: {result.stderr.strip()}"
logger.error(f"[KILLSWITCH] {error_log}")
return Result(valid=False, error_type=ResultError.FIREWALL, message=error_log)
else:
return Result(valid=True)
command.append(internal_subnet)
human_readable_goal = "Arming the Firewall"
return run_generic_command(command, human_readable_goal)
@wrap_with(systemwide_hell_raiser)
def disarm() -> bool:
result = subprocess.run(
["sudo", KILLSWITCH_WRAPPER, "disarm"],
capture_output=True,
text=True,
)
if result.returncode != 0:
error_msg = f"Failed to disarm: {result.stderr.strip()}"
logger.error(f"[KILLSWITCH] {error_log}")
return Result(valid=False, error_type=ResultError.FIREWALL, message=error_log)
else:
return Result(valid=True)
command = ["sudo", KILLSWITCH_WRAPPER, "disarm"]
human_readable_goal = "Disarming the Firewall"
return run_generic_command(command, human_readable_goal)
def status() -> bool:
result = subprocess.run(
["sudo", KILLSWITCH_WRAPPER, "status"],
capture_output=True,
text=True,
)
return result.returncode == 0 and result.stdout.strip() == "armed"
command = ["sudo", KILLSWITCH_WRAPPER, "status"]
human_readable_goal = "Get the Firewall's status"
result = run_generic_command(command, human_readable_goal, timeout=5)
if result.valid:
if "armed" in result.data.strip():
return True
else:
logger.error("[FIREWALL STATUS] It is currently NOT armed.")
return False
else:
# process error with running the command itself,
# send the error object with details.
return result

View file

@ -0,0 +1,54 @@
from core.errors.exceptions import SudoScript, MissingPreReqs
from core.models.Result import Result, ResultError
from core.errors.logger import logger
"""
Purpose:
Raises Errors for known problems with the systemwide connections
Kinds of Raises:
SudoScript
MissingPreReqs
"""
def systemwide_hell_raiser(result: Result) -> Result:
if result.valid:
return result
logger.error(f"[HELL RAISER] We were trying to {result.goal} but got an error of {result.error_type} with a human message of {result.message}")
if result.error_type == ResultError.MISSING_FILE:
logger.error(f"[HELL RAISER] We are missing the setup scripts for {result.goal}")
result.message = f"You're missing the setup scripts for {result.goal}. Re-do the install, or check the setup you did."
raise SudoScript(result)
elif result.error_type == ResultError.PERMISSION:
error_msg = f"There's improper permissions on the scripts for {result.goal}. Re-do the install, or check the setup you did."
result.message = error_msg
logger.error(f"[HELL RAISER] {error_msg}")
raise SudoScript(result)
elif result.error_type == ResultError.MISSING_DEPENDENCY:
error_output = result.message
list_of_dependencies = ["nmcli", "resolvconf", "nftables", "nft"]
what_is_missing = []
for each_dependency in list_of_dependencies:
if each_dependency in error_output:
what_is_missing.append(each_dependency)
result.message = f"You're missing the prereq packages of {what_is_missing}. Re-do the install, or check the setup you did."
raise MissingPreReqs(result)
elif result.error_type == ResultError.INTERFACE:
logger.info("The Interface is not found.")
# might not be an error, if it's already down,
return result
elif result.error_type == ResultError.UNKNOWN:
logger.info(f"Unknown error type for {result.goal} with {result.message} and potentially output of {result.data}.")
return result
else:
logger.info("Unknown Result object")
return result

View file

@ -53,7 +53,7 @@ def revert_dns() -> Result:
turn_off = dns.revert()
logger.info(f"The result of the turn off is {turn_off.valid}")
if not turn_off.valid:
logger.error(f"Critical DNS Error! Could NOT turn off DNS. {turn_off.message}")
logger.error(f"Critical DNS Error! Could NOT turn off DNS. Error type: {turn_off.error_type} with {turn_off.message}")
return turn_off.valid
@ -100,6 +100,8 @@ def check_and_kill_firewall():
else:
error_msg = f"Firewall can't be disabled: {disable_result_object.message}"
logger.error(error_msg)
# if the systemwide_raiser didn't catch it with the reason, then..
raise ConnectionTerminationError(error_msg)
else:
logger.info("We are skipping disabling the firewall, because it's already off.")
@ -235,6 +237,16 @@ def __establish_system_connection(
if not sinkhole_result:
raise ConnectionError('IPv6 Could not be protected.')
# ============= SETUP STATE =============
# Even if firewall is off, we want to save the fact we turned on the VPN tunnel, before we raise errors.
logger.info(f"Setting State JSON..")
current_state = SystemStateController.create(
profile_id=profile.id,
firewalled=firewall_setting, # intended
dns_set=dns_setting # intended
)
# ============= PREP SETTINGS =============
if firewall_setting or dns_setting:
wg_interface_result = extract_wg_interface_name(process_output)
@ -258,6 +270,22 @@ def __establish_system_connection(
if turn_on_result.valid:
firewall_tracker = True
# ============= CONFIRM ON FIREWALL =============
# if firewall_setting:
logger.info(f"Evaluating Firewall Situation after we tried to turn on it on.")
firewall_status = killswitch.status()
logger.info(f"""
The firewall setting is on.
The result of arming it, {turn_on_result.valid}
The result of the status {firewall_status}
""")
if firewall_status:
logger.info("Firewall is on. All good.")
else:
logger.error("Firewall FAILED TO ARM") # DON'T TRY TO RENEGOTIATE WITH DEAD INTERNET !!
# raise ConnectionError(f'Firewall failed to arm!! {turn_on_result.message}.') # if this triggers, it tries to renegotiate with dead internet.
# ============= DNS =============
if dns_setting:
set_dns_result = set_dns(str(profile.id), wg_interface_name)
@ -274,39 +302,19 @@ def __establish_system_connection(
did_dns_work = False
logger.info("Skipping DNS setup, because it's off in settings")
# ============= SETUP STATE =============
# Even if firewall is off, we want to save the fact we turned on the VPN tunnel, before we raise errors.
logger.info(f"Setting State JSON..")
SystemStateController.create(
profile_id=profile.id,
firewalled=firewall_tracker,
dns_set=did_dns_work
)
# ============= CONFIRM ON FIREWALL =============
if firewall_setting:
logger.info(f"Evaluating Firewall Situation after we tried to turn on it on.")
firewall_status = killswitch.status()
logger.info(f"""
The firewall setting is on.
The result of arming it, {turn_on_result.valid}
The result of the status {firewall_status}
""")
if firewall_status:
logger.info("Firewall is on. All good.")
else:
logger.error("Firewall FAILED TO ARM") # TRIES TO RENEGOTIATE WITH DEAD INTERNET !!
# raise ConnectionError(f'Firewall failed to arm!! {turn_on_result.message}.') # if this triggers, it tries to renegotiate with dead internet.
logger.info("If we made it this far, we have nmcli on, ipv6 sinkhole on, firewall on.")
# ============= TESTING =============
logger.info("If we made it this far, we have nmcli on, ipv6 sinkhole on, firewall on.")
try:
await_connection(connection_observer=connection_observer)
except ConnectionError:
raise ConnectionError('The connection could not be established.')
# ============= UPDATE STATE =============
current_state.firewalled = firewall_tracker
current_state.dns_set = did_dns_work
SystemStateController.update_or_create(current_state)
return Result(valid=True)
@ -339,7 +347,8 @@ def establish_system_connection(
# ================= SETTINGS =================
firewall_setting = get_firewall_setting()
dns_setting = get_dns_setting()
dns_setting = True # for testing
# dns_setting = get_dns_setting()
# ================= CONNECT WG ================
try:

View file

@ -0,0 +1,66 @@
from core.models.Result import Result, ResultError
from core.errors.logger import logger
import subprocess
import os
_SUDO_KW = {
"stdout": subprocess.PIPE,
"stderr": subprocess.PIPE,
"stdin": subprocess.DEVNULL,
"env": {**os.environ, "SUDO_ASKPASS": "/bin/false"},
"text": True,
}
def parse_errors(error_output: str) -> ResultError:
# Interface missing
if "No such device" in error_output:
error_enum = ResultError.INTERFACE
# Permission error
elif "No permissions" in error_output:
error_enum = ResultError.PERMISSION
elif "sudo: a password is required" or "password is required" in error_output:
error_enum = ResultError.PERMISSION
elif "command not found" in error_output:
error_enum = ResultError.MISSING_DEPENDENCY
else:
error_enum = ResultError.UNKNOWN
return error_enum
def run_generic_command(
command: list,
human_readable_goal: str,
timeout: float | None = None
) -> Result:
output_data = None # incase command has nothing.
# Add -n flag to sudo for non-interactive mode (no prompts)
if command and command[0] == "sudo":
if "-n" not in command:
command = [command[0], "-n"] + command[1:]
try:
result = subprocess.run(
command,
**_SUDO_KW,
timeout=timeout
)
output_data = result.stdout
if result.returncode == 0:
logger.info(f"{human_readable_goal} was successful")
return Result(valid=True, data=output_data)
else:
error_output = result.stderr.strip()
error_enum = parse_errors(error_output)
logger.error(f"{human_readable_goal} Failed, error type is {error_enum}.")
return Result(valid=False, error_type=error_enum, goal=human_readable_goal, message=error_output)
except subprocess.TimeoutExpired:
return Result(valid=False, error_type=ResultError.TIMEOUT, goal=human_readable_goal, data=output_data, message="Command timed out")
except FileNotFoundError:
return Result(valid=False, error_type=ResultError.MISSING_FILE, goal=human_readable_goal, data=output_data, message="Script is not found")
except Exception as e:
return Result(valid=False, error_type=ResultError.UNKNOWN, goal=human_readable_goal, data=output_data, message=e)

View file

@ -0,0 +1,40 @@
from dataclasses import dataclass
from typing import TypeVar, Callable
import functools
"""
Purpose:
Wraps one function with another
Steps:
1) Takes the original function it's wrapping around, and does it
2) Passes that into the second function `wrapper_fn`
3) Returns the resulting object type
Assumes:
Same object/type for both functions.
"""
T = TypeVar('T') # Input type (what the original function returns)
U = TypeVar('U') # Output type (what wrapper_fn returns)
def wrap_with(wrapper_fn: Callable[[T], U]) -> Callable:
def decorator(func: Callable[..., T]) -> Callable[..., U]:
@functools.wraps(func)
def wrapper(*args, **kwargs) -> U:
result = func(*args, **kwargs)
return wrapper_fn(result)
return wrapper
return decorator
# def wrap_with(wrapper_fn: Callable[[Result], Result]) -> Callable:
# def decorator(func: Callable[..., Result]) -> Callable[..., Result]:
# @functools.wraps(func)
# def wrapper(*args, **kwargs) -> Result:
# result = func(*args, **kwargs)
# processed_result = wrapper_fn(result) # ← Any function plugged in
# return processed_result
# return wrapper
# return decorator