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 # 2.4.3
### Systemwide Killswitch Introduced for Enable ### 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 ### 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. 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/> <br/>

View file

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

View file

@ -13,7 +13,9 @@ class SystemStateController:
@staticmethod @staticmethod
def create(profile_id: int, firewalled: bool, dns_set: bool) -> SystemState: 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 @staticmethod
def update_or_create(system_state): 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): class TorServiceInitializationError(Exception):
pass pass

View file

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

View file

@ -1,69 +1,49 @@
# custom # 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.Constants import Constants
from core.models.Result import Result, ResultError from core.models.Result import Result, ResultError
from core.errors.logger import logger from core.errors.logger import logger
import subprocess
from subprocess import CalledProcessError
DNS_SCRIPT = f"/opt/hydra-veil/dns" DNS_SCRIPT = f"/opt/hydra-veil/dns"
# requires script to have sudo # requires script to have sudo
@wrap_with(systemwide_hell_raiser)
def set_on(network_interface: str, dns_should_be: str) -> Result: def set_on(network_interface: str, dns_should_be: str) -> Result:
result = subprocess.run( command = ["sudo", DNS_SCRIPT, "set", network_interface, dns_should_be]
["sudo", DNS_SCRIPT, "set", network_interface, dns_should_be], human_readable_goal = "Set DNS"
capture_output=True, return run_generic_command(command, human_readable_goal)
text=True,
)
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 # requires script to have sudo
@wrap_with(systemwide_hell_raiser)
def revert() -> Result: def revert() -> Result:
try: command = ["sudo", DNS_SCRIPT, "revert"]
result = subprocess.run( human_readable_goal = "DNS revert"
["sudo", DNS_SCRIPT, "revert"], return run_generic_command(command, human_readable_goal)
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)
# does NOT need sudo # does NOT need sudo
def is_systemd_enabled() -> Result: def is_systemd_enabled() -> Result:
try: command = ["systemctl", "is-enabled", "systemd-resolved"]
process = subprocess.Popen(('systemctl', 'is-enabled', 'systemd-resolved'), stdout=subprocess.PIPE) human_readable_goal = "Find out if SystemD is enabled"
process_output = str(process.stdout.read()) result = run_generic_command(command, human_readable_goal, timeout=5)
if 'enabled' in process_output: if result.valid:
if "enabled" in result.data:
return Result(valid=True) return Result(valid=True)
else: else:
return Result(valid=False, message=process_output) logger.error("Systemctl is not even enabled.")
except CalledProcessError as e: return Result(valid=False) # this would be true in the original object just because the command ran.
return Result(valid=False, message=e) else:
return result # send the error itself.
# does NOT need sudo # does NOT need sudo
def get_resolvctl_data(which_command: str) -> Result: def get_resolvctl_data(which_command: str) -> Result:
try: command = ['resolvectl', which_command]
process_output = subprocess.check_output(('resolvectl', which_command), text=True) human_readable_goal = "Get Resolvctl Data"
return Result(valid=True, data=process_output) return run_generic_command(command, human_readable_goal, timeout=5)
except CalledProcessError as e:
return Result(valid=False, message=e)
# These are the raw Linux systemctl commands: # 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.models.Result import Result, ResultError
from core.errors.logger import logger from core.errors.logger import logger
import subprocess
KILLSWITCH_WRAPPER = "/opt/hydra-veil/firewall" # needs chmod 755 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: 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: if internal_subnet:
cmd.append(internal_subnet) command.append(internal_subnet)
result = subprocess.run(cmd, capture_output=True, text=True) human_readable_goal = "Arming the Firewall"
if result.returncode != 0: return run_generic_command(command, human_readable_goal)
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)
@wrap_with(systemwide_hell_raiser)
def disarm() -> bool: def disarm() -> bool:
result = subprocess.run( command = ["sudo", KILLSWITCH_WRAPPER, "disarm"]
["sudo", KILLSWITCH_WRAPPER, "disarm"], human_readable_goal = "Disarming the Firewall"
capture_output=True, return run_generic_command(command, human_readable_goal)
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)
def status() -> bool: def status() -> bool:
result = subprocess.run( command = ["sudo", KILLSWITCH_WRAPPER, "status"]
["sudo", KILLSWITCH_WRAPPER, "status"], human_readable_goal = "Get the Firewall's status"
capture_output=True, result = run_generic_command(command, human_readable_goal, timeout=5)
text=True, if result.valid:
) if "armed" in result.data.strip():
return result.returncode == 0 and result.stdout.strip() == "armed" 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() turn_off = dns.revert()
logger.info(f"The result of the turn off is {turn_off.valid}") logger.info(f"The result of the turn off is {turn_off.valid}")
if not 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 return turn_off.valid
@ -100,6 +100,8 @@ def check_and_kill_firewall():
else: else:
error_msg = f"Firewall can't be disabled: {disable_result_object.message}" error_msg = f"Firewall can't be disabled: {disable_result_object.message}"
logger.error(error_msg) logger.error(error_msg)
# if the systemwide_raiser didn't catch it with the reason, then..
raise ConnectionTerminationError(error_msg) raise ConnectionTerminationError(error_msg)
else: else:
logger.info("We are skipping disabling the firewall, because it's already off.") 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: if not sinkhole_result:
raise ConnectionError('IPv6 Could not be protected.') 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 ============= # ============= PREP SETTINGS =============
if firewall_setting or dns_setting: if firewall_setting or dns_setting:
wg_interface_result = extract_wg_interface_name(process_output) wg_interface_result = extract_wg_interface_name(process_output)
@ -258,6 +270,22 @@ def __establish_system_connection(
if turn_on_result.valid: if turn_on_result.valid:
firewall_tracker = True 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 ============= # ============= DNS =============
if dns_setting: if dns_setting:
set_dns_result = set_dns(str(profile.id), wg_interface_name) set_dns_result = set_dns(str(profile.id), wg_interface_name)
@ -274,39 +302,19 @@ def __establish_system_connection(
did_dns_work = False did_dns_work = False
logger.info("Skipping DNS setup, because it's off in settings") 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 ============= # ============= TESTING =============
logger.info("If we made it this far, we have nmcli on, ipv6 sinkhole on, firewall on.")
try: try:
await_connection(connection_observer=connection_observer) await_connection(connection_observer=connection_observer)
except ConnectionError: except ConnectionError:
raise ConnectionError('The connection could not be established.') 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) return Result(valid=True)
@ -339,7 +347,8 @@ def establish_system_connection(
# ================= SETTINGS ================= # ================= SETTINGS =================
firewall_setting = get_firewall_setting() firewall_setting = get_firewall_setting()
dns_setting = get_dns_setting() dns_setting = True # for testing
# dns_setting = get_dns_setting()
# ================= CONNECT WG ================ # ================= CONNECT WG ================
try: 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