From 5377b7348b74093bd12b3f82c6394763c63c3b0e Mon Sep 17 00:00:00 2001 From: SimplifiedPrivacy Date: Wed, 29 Jul 2026 15:04:33 -0400 Subject: [PATCH] Introduced Singbox Proxy Feature Start Utility & Script. But it's not yet integrated to be used --- core/assets/sudo_scripts/singbox_runner | 26 ++++ core/errors/exceptions.py | 14 ++ core/models/Result.py | 2 + core/services/networking/systemwide/dns.py | 4 +- .../systemwide/encrypted_proxy/singbox.py | 34 +++++ .../encrypted_proxy/singbox_runner.py | 136 ++++++++++++++++++ .../general_firewall_dns_tools.py | 49 +++++++ .../networking/systemwide/killswitch.py | 6 +- .../systemwide/systemwide_wireguard.py | 7 +- core/utils/basic_operations/process_tools.py | 9 ++ 10 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 core/assets/sudo_scripts/singbox_runner create mode 100644 core/services/networking/systemwide/encrypted_proxy/singbox.py create mode 100644 core/services/networking/systemwide/encrypted_proxy/singbox_runner.py create mode 100644 core/services/networking/systemwide/general_tools/general_firewall_dns_tools.py create mode 100644 core/utils/basic_operations/process_tools.py diff --git a/core/assets/sudo_scripts/singbox_runner b/core/assets/sudo_scripts/singbox_runner new file mode 100644 index 0000000..22d4f68 --- /dev/null +++ b/core/assets/sudo_scripts/singbox_runner @@ -0,0 +1,26 @@ +#!/bin/bash +set -eo pipefail + +SINGBOX_BIN="/usr/bin/singbox" +LOG_FILE="~/.local/share/hydra-veil/singbox.txt" + +run_binary() { + "$SINGBOX_BIN" run -c "$1" >> "$LOG_FILE" 2>&1 & + _new_pid=$! + echo "$_new_pid" +} +# get result in python: pid = int(result.stdout.strip()) + +gracefully_close() { + sudo kill -TERM "$1" 2>/dev/null || true +} + +forcefully_kill() { + sudo kill -TERM "$1" 2>/dev/null || true +} + +hello_world_test() { + echo "hello world $1" +} + + diff --git a/core/errors/exceptions.py b/core/errors/exceptions.py index d9e5da8..f0b50e5 100644 --- a/core/errors/exceptions.py +++ b/core/errors/exceptions.py @@ -1,6 +1,20 @@ from core.models.Result import Result, ResultError from typing import Optional +class DNSError(Exception): + """There are issues with DNS""" + def __init__(self, result: Optional[Result]): + if result: + self.result = result + if result.message and result.message is not None: + error_msg = result.message + else: + error_msg = result.error_type + else: + error_msg = "Failed to start DNS" + super().__init__(error_msg) + + class FirewallError(Exception): """There are issues with the Firewall""" def __init__(self, result: Optional[Result]): diff --git a/core/models/Result.py b/core/models/Result.py index bfccef2..1ea265f 100644 --- a/core/models/Result.py +++ b/core/models/Result.py @@ -15,6 +15,8 @@ class ResultError(Enum): DATABASE = "database" PERMISSION = "permission" SUBSCRIPTION = "subscription" + PROCESS_GOT_KILLED = "process_got_killed" + PROCESS_WONT_START = "process_wont_start" NMCLI = "nmcli_issues" FIREWALL = "firewall" CLIENT_DNS = "client_dns" diff --git a/core/services/networking/systemwide/dns.py b/core/services/networking/systemwide/dns.py index f1315b1..0180e3b 100644 --- a/core/services/networking/systemwide/dns.py +++ b/core/services/networking/systemwide/dns.py @@ -13,7 +13,7 @@ DNS_SCRIPT = f"/opt/hydra-veil/dns" def set_on(network_interface: str, dns_should_be: str) -> Result: command = ["sudo", DNS_SCRIPT, "set", network_interface, dns_should_be] human_readable_goal = "Set DNS" - return run_generic_command(command, human_readable_goal) + return run_generic_command(command, human_readable_goal, timeout=5) # requires script to have sudo @@ -21,7 +21,7 @@ def set_on(network_interface: str, dns_should_be: str) -> Result: def revert() -> Result: command = ["sudo", DNS_SCRIPT, "revert"] human_readable_goal = "DNS revert" - return run_generic_command(command, human_readable_goal) + return run_generic_command(command, human_readable_goal, timeout=7) # does NOT need sudo diff --git a/core/services/networking/systemwide/encrypted_proxy/singbox.py b/core/services/networking/systemwide/encrypted_proxy/singbox.py new file mode 100644 index 0000000..88af9e9 --- /dev/null +++ b/core/services/networking/systemwide/encrypted_proxy/singbox.py @@ -0,0 +1,34 @@ +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 + +SINGBOX_WRAPPER = "/opt/hydra-veil/singbox_runner" # needs chmod 755 + +@wrap_with(systemwide_hell_raiser) +def start(config_path: str) -> Result: + command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && run_binary "{config_path}"'] + human_readable_goal = "Start running Singbox" + return run_generic_command(command, human_readable_goal, timeout=5) + + +@wrap_with(systemwide_hell_raiser) +def turn_off(process_id: str) -> Result: + command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && gracefully_close "{process_id}"'] + human_readable_goal = "Gracefully Stop Singbox" + return run_generic_command(command, human_readable_goal, timeout=5) + + +@wrap_with(systemwide_hell_raiser) +def force_kill(process_id: str) -> Result: + command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && forcefully_kill "{process_id}"'] + human_readable_goal = "Force Kill Singbox" + return run_generic_command(command, human_readable_goal, timeout=5) + + +@wrap_with(systemwide_hell_raiser) +def hello_world_test(name: str) -> str: + command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && hello_world_test "{name}"'] + human_readable_goal = "Hello world test" + return run_generic_command(command, human_readable_goal, timeout=5) diff --git a/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py b/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py new file mode 100644 index 0000000..6a3cbca --- /dev/null +++ b/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py @@ -0,0 +1,136 @@ +from core.services.networking.systemwide.encrypted_proxy import singbox +from core.utils.basic_operations import process_tools +from core.models.Result import Result, ResultError +from core.errors.logger import logger +from core.Constants import Constants +from core.models.system.SystemState import SystemState +from core.controllers.SystemStateController import SystemStateController +from core.services.networking.systemwide.systemwide_utils import get_firewall_setting, get_dns_setting +from core.services.networking.systemwide import dns +from core.services.networking.systemwide.general_connection_tools.general_firewall_dns_tools import generic_enable_firewall_w_retry +from core.errors.exceptions import FirewallError, DNSError + +# generic +import time + +QUANTITY_OF_ATTEMPTS = 2 +SINGBOX_TUN_IF = 'tun0' +SINGBOX_INTERNAL_SUBNET = '172.19.0.0/30' +SINGBOX_INTERNAL_ADDR = '172.19.0.1/30' +SINGBOX_DEFAULT_DNS = '9.9.9.9' + + +def set_dns_for_singbox(current_state: SystemState): + do_they_even_use_systemd = dns.is_systemd_enabled() + + if not do_they_even_use_systemd.valid: + error_msg = "You don't use SystemD, so we are skipping setting your DNS." + logger.info(error_msg) + return True + + dns_result = dns.set_on(network_interface=SINGBOX_TUN_IF, dns_should_be=SINGBOX_DEFAULT_DNS) + + if dns_result.valid: + return True + else: + # Update the state for the DNS failure, but not firewall + current_state.firewalled = True + current_state.dns_set = False + SystemStateController.update_or_create(current_state) + raise DNSError(dns_result) + + +def _attempt_start_with_retry(config_path: str, quantity_of_attempts: int = 2) -> Result: + current_attempt = 0 + + while current_attempt < quantity_of_attempts: + activation_result = singbox.start(config_path) + + if activation_result.valid: + return activation_result + else: + current_attempt = current_attempt + 1 + logger.error(f"[SINGBOX] Attempt {current_attempt} for Singbox Failed. Because: {activation_result.message}. Trying again..") + + + +def start_singbox( + profile_id: int, + config_path: str, + server_ip: str, + connection_observer: Optional[ConnectionObserver] = None +) -> Result: + """ + Purpose: + Orchestrates the full flow of the Singbox connection. + + Steps: + 1) Validate inputs + 2) Start it with a retry + 3) Evaluate if the process id is still up. + 4) If it's up, setup the State JSON + 5) Turn on Firewall + 6) Turn on DNS + 7) Update the State + """ + + function_name = "START_SINGBOX" + + # ============= INPUT VALIDATION ============= + requirements = [profile_id, config_path, server_ip] + for each_requirement in requirements: + if each_requirement is None: + return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"Invalid inputs into {function_name}") + + # ============= START PROCESS ============= + activation_result = _attempt_start_with_retry(config_path=config_path, quantity_of_attempts=QUANTITY_OF_ATTEMPTS) + + if not activation_result.valid: + error_msg = f"Singbox failed to start after {QUANTITY_OF_ATTEMPTS} attempts" + logger.error(f"[{function_name}] {error_msg}") + return Result(valid=False, error_type=ResultError=PROCESS_WONT_START, message=error_msg) + + process_id = activation_result.data + time.sleep(2) + active = process_tools.is_running(process_id) + + if not active: + error_msg = f"While Singbox might have literally allowed the binary to begin, it's killing the process on id {process_id}" + logger.error(f"[{function_name}] {error_msg}") + return Result(valid=False, error_type=ResultError.PROCESS_GOT_KILLED, message=error_msg, data=process_id) + + # ============= SETUP STATE ============= + # Even if firewall is off, we want to save the fact we turned Singbox on, before we raise errors. + + logger.info(f"Setting State JSON with INTENDED firewall & Dns settings") + current_state = SystemStateController.create( + profile_id=profile.id, + firewalled=True, # intended setting, not result yet + dns_set=True # intended setting, not result yet + ) + + # ============= FIREWALL ============= + logger.info(f"[{function_name}] Attempting to enable the Firewall for {SINGBOX_TUN_IF} and {SINGBOX_INTERNAL_SUBNET}...") + # this is labeled "generic" for being protocol neutral + firewall_result = generic_enable_firewall_w_retry( + interface_name=SINGBOX_TUN_IF, + server_ip=server_ip, + internal_subnet = SINGBOX_INTERNAL_SUBNET, + max_retries = 2, + connection_observer=connection_observer + ) + + if not firewall_result.valid: + # Update the state of the failure, + current_state.firewalled = False + current_state.dns_set = False + SystemStateController.update_or_create(current_state) + raise FirewallError(firewall_result) # Now end the party. + + # ============= DNS ============= + dns_result = set_dns_for_singbox(current_state) + # raises error if not okay. + + # ============= CONCLUSION ============= + return Result(valid=True, data=current_state) + diff --git a/core/services/networking/systemwide/general_tools/general_firewall_dns_tools.py b/core/services/networking/systemwide/general_tools/general_firewall_dns_tools.py new file mode 100644 index 0000000..fdd99ea --- /dev/null +++ b/core/services/networking/systemwide/general_tools/general_firewall_dns_tools.py @@ -0,0 +1,49 @@ +from core.services.networking.systemwide import killswitch +from core.errors.logger import logger +from core.errors.exceptions import FirewallError +from core.models.Result import Result, ResultError + + +def generic_enable_firewall_w_retry( + interface_name: str, + server_ip: str, + internal_subnet: str = None, + max_retries: int = 2 +) -> None: + """ + Purpose: + Protocol-neutral, Enable the firewall with automatic retry on failure. + + Called by: + start_singbox + + Raises Errors: + Yes, FirewallError + """ + + last_result = None + + for attempt in range(1, max_retries + 1): + logger.info(f"Enabling firewall (attempt {attempt}/{max_retries})") + + turn_on_result = killswitch.arm( + server_ip=server_ip, + tunnel_if=interface_name, + internal_subnet=internal_subnet + ) + + if turn_on_result is None: + logger.error("turn_on_firewall() returned None") + continue + + last_result = turn_on_result + firewall_status = killswitch.status() + + if turn_on_result.valid and firewall_status: + logger.info("Firewall successfully enabled") + return last_result + + logger.warning(f"Firewall enable failed on attempt {attempt}, because {turn_on_result.message}") + + return last_result + diff --git a/core/services/networking/systemwide/killswitch.py b/core/services/networking/systemwide/killswitch.py index 53b1ddd..f1ca7c7 100644 --- a/core/services/networking/systemwide/killswitch.py +++ b/core/services/networking/systemwide/killswitch.py @@ -3,7 +3,7 @@ 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 +# import subprocess KILLSWITCH_WRAPPER = "/opt/hydra-veil/firewall" # needs chmod 755 @@ -13,13 +13,13 @@ def arm(server_ip: str, tunnel_if: str, internal_subnet: str = None) -> Result: if internal_subnet: command.append(internal_subnet) human_readable_goal = "Arming the Firewall" - return run_generic_command(command, human_readable_goal) + return run_generic_command(command, human_readable_goal, timeout=5) @wrap_with(systemwide_hell_raiser) def disarm() -> bool: command = ["sudo", KILLSWITCH_WRAPPER, "disarm"] human_readable_goal = "Disarming the Firewall" - return run_generic_command(command, human_readable_goal) + return run_generic_command(command, human_readable_goal, timeout=5) def status() -> bool: command = ["sudo", KILLSWITCH_WRAPPER, "status"] diff --git a/core/services/networking/systemwide/systemwide_wireguard.py b/core/services/networking/systemwide/systemwide_wireguard.py index 6e13b7d..7699733 100644 --- a/core/services/networking/systemwide/systemwide_wireguard.py +++ b/core/services/networking/systemwide/systemwide_wireguard.py @@ -169,9 +169,10 @@ def __establish_system_connection( 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) + if dns_setting and not did_dns_work: + current_state.firewalled = firewall_tracker + current_state.dns_set = did_dns_work + SystemStateController.update_or_create(current_state) return Result(valid=True, data=current_state) diff --git a/core/utils/basic_operations/process_tools.py b/core/utils/basic_operations/process_tools.py new file mode 100644 index 0000000..f515657 --- /dev/null +++ b/core/utils/basic_operations/process_tools.py @@ -0,0 +1,9 @@ +from core.errors.logger import logger +import os + +def is_running(pid: int) -> bool | None: + if not isinstance(pid, int): + logger.error("[SUBPROCESS CHECKER] Invalid int passed in for process id!") + return None + + return os.path.exists(f"/proc/{pid}") \ No newline at end of file