From 6107a355f909807506a07ad1cf5696c7bc51449e Mon Sep 17 00:00:00 2001 From: SimplifiedPrivacy Date: Thu, 15 Oct 2026 19:02:47 -0400 Subject: [PATCH] Improved singbox cleanup process. And added support for killing wireguard profiles inside end_singbox. --- core/controllers/SystemStateController.py | 8 +- core/models/system/SystemState.py | 10 +- .../encrypted_proxy/close_singbox.py | 190 +++++++----------- .../encrypted_proxy/singbox_runner.py | 124 +++++------- .../general_tools/interface_tools.py | 2 +- core/utils/basic_operations/pid_tools.py | 13 +- core/utils/run_commands.py | 6 +- 7 files changed, 155 insertions(+), 198 deletions(-) diff --git a/core/controllers/SystemStateController.py b/core/controllers/SystemStateController.py index 9da0c19..d459570 100644 --- a/core/controllers/SystemStateController.py +++ b/core/controllers/SystemStateController.py @@ -1,5 +1,5 @@ from core.models.system.SystemState import SystemState - +from core.models.enum_models import ProtocolChoice class SystemStateController: @@ -12,11 +12,11 @@ class SystemStateController: return SystemState.exists() @staticmethod - def create(profile_id: int, firewalled: bool, dns_set: bool, process_id: int = None) -> SystemState: + def create(profile_id: int, firewalled: bool, dns_set: bool, protocol: ProtocolChoice, process_id: int = None) -> SystemState: if process_id: - current_state = SystemState(profile_id, firewalled, dns_set, process_id) + current_state = SystemState(profile_id, firewalled, dns_set, protocol, process_id) else: - current_state = SystemState(profile_id, firewalled, dns_set) + current_state = SystemState(profile_id, firewalled, dns_set, protocol) current_state.save() return current_state diff --git a/core/models/system/SystemState.py b/core/models/system/SystemState.py index 33da171..532904f 100644 --- a/core/models/system/SystemState.py +++ b/core/models/system/SystemState.py @@ -1,5 +1,6 @@ from core.models.enum_models import ProtocolChoice from core.Constants import Constants +from core.utils.basic_operations.enum_tools import serialize_enums # generic from dataclasses import dataclass @@ -22,7 +23,12 @@ class SystemState: def save(self: Self): - system_state_file_contents = f'{self.to_json(indent=4)}\n' + data_as_dict = self.to_json(indent=4) + print(f"data_as_dict is {data_as_dict}") + data_without_enums = serialize_enums(data_as_dict) + print(f"data_without_enums is {data_without_enums}") + system_state_file_contents = f'{data_without_enums}\n' + os.makedirs(SystemState.__get_state_path(), exist_ok=True, mode=0o700) system_state_file_path = f'{SystemState.__get_state_path()}/system.json' @@ -31,6 +37,8 @@ class SystemState: with open(system_state_file_path, 'w') as system_state_file: system_state_file.write(system_state_file_contents) + print("saved system state") + @staticmethod def get(): diff --git a/core/services/networking/systemwide/encrypted_proxy/close_singbox.py b/core/services/networking/systemwide/encrypted_proxy/close_singbox.py index 617a6b8..a039218 100644 --- a/core/services/networking/systemwide/encrypted_proxy/close_singbox.py +++ b/core/services/networking/systemwide/encrypted_proxy/close_singbox.py @@ -7,6 +7,7 @@ from core.errors.logger import logger from core.services.networking.systemwide.encrypted_proxy import singbox from core.utils.basic_operations import process_tools from core.utils.run_commands import run_generic_command +from core.utils.type_checker import type_checker from typing import Callable, cast, Optional import time @@ -14,37 +15,8 @@ import time KILL_WAIT_TIME = 1.3 APP_NAME = 'sing-box' -def _try_with_permission_fallback(operation: Callable, interface: str) -> Result: - """Execute operation; if denied, check if tunnel is still active.""" - try: - return operation() - except SudoScript as e: - logger.error(f"The Sudo Scripts giving power to kill this are being denied permission, before we flag this, let's see if the proxy is active, which does NOT need permission to check, {e}") - existance = interface_tools.exists(interface) - if not existance.valid and existance.error_type == ResultError.INTERFACE: - not_existing = "Permission denied, but the proxy interface is down, so this is acceptable" - logger.info(not_existing) - return Result(valid=True, message=not_existing) - error_msg = "Critical Issue, the tunnel is up, but we lack permission to take it down." - logger.error(error_msg) - return Result(valid=False, error_type=ResultError.PERMISSION, message=error_msg) - - -def _escalate_kill(pid: int) -> Result: - """Graceful → wait → force kill escalation.""" - if not process_tools.is_running(pid): - return Result(valid=True, message=f"PID {pid} already down") - - graceful = singbox.turn_off(pid) - time.sleep(KILL_WAIT_TIME) - - if not process_tools.is_running(pid): - return Result(valid=True, message=f"PID {pid} gracefully closed") - - force = singbox.force_kill(pid) - return Result(valid=force.valid, message=f"Force kill {'succeeded' if force.valid else 'failed'}") - +@type_checker def get_process_id_from_state(current_state: Optional[SystemState]) -> Result: if current_state is None: return Result(valid=False, error_type=ResultError.MISSING_DATA, message="Missing the State itself.") @@ -64,110 +36,102 @@ def get_process_id_from_state(current_state: Optional[SystemState]) -> Result: return Result(valid=False, error_type=ResultError.INVALID_INPUT, data=e, message="The process id is not a valid integer.") -# Function is private because it leverages singbox only functionality. -def _shut_down_by_known_process_id(process_id: int) -> Result: - graceful_close = singbox.stop(process_id) - if not graceful_close.valid: - logger.error(f"Could not gracefully close it. So we'll {KILL_WAIT_TIME} seconds and kill it.") - - still_active = pid_tools.is_running(pid=process_id, process_name="sing-box") - - if not still_active: - return Result(valid=True, message=f"The process ID {process_id} gracefully closed or never was running to begin with.") +@type_checker +def terminate_pid(pid: int) -> Result: + """ + The single source of truth for killing a process. + Logic: Graceful Stop -> Wait -> Force Kill. + """ + if not process_tools.is_running(pid): + return Result(valid=True, message=f"PID {pid} already down") + # 1. Attempt Graceful Stop + logger.info(f"Attempting graceful stop of PID {pid}...") + singbox.stop(pid) # We don't strictly need the result here, we check is_running + time.sleep(KILL_WAIT_TIME) - logger.info(f"Graceful closed failed, as {process_id} is still running. Escalating to forced killing..") + + if not process_tools.is_running(pid): + return Result(valid=True, message=f"PID {pid} closed gracefully") - force_kill = singbox.force_kill(process_id) + # 2. Attempt Force Kill + logger.warning(f"PID {pid} resisted graceful stop. Escalating to force kill...") + force_result = singbox.force_kill(pid) + + if force_result.valid and not process_tools.is_running(pid): + return Result(valid=True, message=f"PID {pid} force killed successfully") + + return Result(valid=False, message=f"PID {pid} failed to terminate even after force kill") - if force_kill.valid: - return Result(valid=True, message=f"The process ID {process_id} was forcefully closed.") - else: - return Result(valid=False, message=f"Even a force kill couldn't stop the process ID {process_id}.") + +@type_checker +def _try_with_permission_fallback(operation: Callable, interface: str) -> Result: + """Wraps sudo operations; if permission is denied, check if the tunnel is already gone.""" + try: + return operation() + except SudoScript as e: + logger.error(f"Sudo permission denied: {e}. Checking if interface {interface} is already down...") + interface_output = interface_tools.get_output(interface) + + # If the interface is gone, the permission error is irrelevant because the goal is achieved + if not interface_output.valid and interface_output.error_type == ResultError.INTERFACE: + return Result(valid=True, message="Permission denied, but interface is already down. Success.") + + return Result(valid=False, error_type=ResultError.PERMISSION, message="Tunnel is UP, but sudo permissions are missing.") def _hunt_and_kill(interface_name: str) -> Result: - """ - Purpose: - Kill all pids with the phrase - - Flow: - Gets a list of all pids with the phrase in it, - Goes through each and tries to kill it with escalating force. - Finally evaluates for each pid if it killed the tunnel. - """ - function_name = "_hunt_and_kill" - + """Finds all potential sing-box PIDs and kills them until the interface disappears.""" id_results = pid_tools.get_pids_by_phrase(phase=APP_NAME) - if id_results.valid: - pid_data_list = id_results.data - else: + if not id_results.valid: return id_results - for idx, pid_info in enumerate(pid_data_list): + pids = id_results.data + for pid_info in pids: pid = pid_info['pid'] - command = pid_info['command'] + logger.info(f"Hunting: Attempting to kill PID {pid}") - logger.info(f"[{function_name}] Attempting to kill PID {pid}: {command}") - graceful_or_force = _escalate_kill(pid) - logger.info(f"Escalation of killing of pid {pid} resulted in {graceful_or_force.valid} and {graceful_or_force.message}") - if not graceful_or_force.valid: - logger.info("Skipping to next pid..") - continue + terminate_pid(int(pid)) + + # Check if the interface finally dropped + if not interface_tools.exists(interface=interface_name): + return Result(valid=True, message=f"Tunnel stopped after killing PID {pid}") - if not process_tools.is_running(pid): - logger.info(f"[{function_name}] PID {pid} force killed successfully") - - # EVALUATION: Did killing this PID stop the app? is the interface still running? - existance = interface_tools.exists(interface=interface_name) - if not existance.valid: - logger.info(f"[{function_name}] App/Tunnel is fully stopped after killing PID {pid}") - return Result(valid=True, message=f"App stopped after killing PID {pid}") - else: - logger.warning(f"[{function_name}] App still running, continuing to next PID") - continue - - else: - logger.error(f"[{function_name}] Could not kill PID {pid}, moving to next") - continue + return Result(valid=False, message="Hunted all matching PIDs, but interface remains active") + + +def orchestrate_closing(current_state: Optional[SystemState], interface: str) -> Result: + """ + Tries increasingly aggressive strategies to bring down the proxy. + """ - return Result(valid=False, error_type=ResultError.UNKNOWN, message="Could not kill any PIDs") + # Strategy 1: PID from JSON state + def try_state_pid(): + pid_res = get_process_id_from_state(current_state) + if not pid_res.valid: + return pid_res + return terminate_pid(pid_res.data) + # Strategy 2: Exact name match (pgrep -x) + def try_exact_match(): + match = pid_tools.get_pid_by_app_name(exact_app_name=APP_NAME) + if not match.valid: + return match + return terminate_pid(int(match.data)) -def _try_shutdown_by_exact_match() -> Result: - exact_match = pid_tools.get_pid_by_app_name(exact_app_name=APP_NAME) - if exact_match.valid: - logger.info(f"Attempting exact match PID {exact_match.data}") - return _shut_down_by_known_process_id(cast(int, exact_match.data)) - else: - return exact_match + # Strategy 3: Hunt all related processes + def try_hunt(): + return _hunt_and_kill(interface) - -def _try_known_pid_from_state(current_state: Optional[SystemState]) -> Result: - pid_query = get_process_id_from_state(current_state) - if not pid_query.valid: - return pid_query - return _shut_down_by_known_process_id(cast(int, pid_query.data)) - - -def orchestrate_closing(current_state: SystemState, interface: str) -> Result: - strategies = [ - lambda: _try_known_pid_from_state(current_state), - lambda: _try_shutdown_by_exact_match(), - lambda: _hunt_and_kill(interface), - ] - quantity_of_strategies = len(strategies) + strategies = [try_state_pid, try_exact_match, try_hunt] for strategy in strategies: result = _try_with_permission_fallback(strategy, interface) if result.valid: - # remove state json: - SystemState.dissolve() return result + + # Final sanity check: Did the strategy fail, but the interface is gone anyway? if not interface_tools.exists(interface): - # remove state json: - SystemState.dissolve() - return Result(valid=True, message="Tunnel inactive despite strategy failure") - - return Result(valid=False, message=f"All {quantity_of_strategies} strategies tried, we simply can not bring down the proxy, which is still up.") + return Result(valid=True, message="Interface disappeared despite strategy reporting failure") + return Result(valid=False, message="All shutdown strategies exhausted. Proxy is still active.") diff --git a/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py b/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py index 6b85a96..2ee4a3a 100644 --- a/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py +++ b/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py @@ -1,8 +1,8 @@ from core.controllers.profile_state.get_profile_data import get_protocol_enum from core.models.enum_models import ProtocolChoice -from core.services.networking.general_connection_tools.cross_protocol import kill_any_protocol +# from core.services.networking.general_connection_tools.cross_protocol import kill_any_protocol from core.services.networking.systemwide.encrypted_proxy import singbox -from core.services.networking.systemwide.encrypted_proxy.close_singbox import orchestrate_closing +from core.services.networking.systemwide.encrypted_proxy.close_singbox import orchestrate_closing, terminate_pid from core.services.networking.systemwide.general_tools import interface_tools from core.services.networking.systemwide import killswitch from core.services.networking.systemwide.wireguard.wg_firewall_dns import revert_dns @@ -135,79 +135,58 @@ def _attempt_start_with_retry(profile_id: int, quantity_of_attempts: int = 2) -> return Result(valid=False, error_type=ResultError.PROCESS_WONT_START, message=error_msg) + def end_singbox() -> Result: + """Orchestrate closing singbox's process, firewall, dns, and json state.""" - # get by systemstate: - current_state = SystemState.get() - - # ------------------ - # KILL ZOMBIE SINGBOX NOT TIED TO PROFILES - # ------------------ - if not current_state: - # Make sure there's no singbox id: - singbox_pid = pid_tools.get_pid_by_app_name(exact_app_name="sing-box") - - if singbox_pid is None: - return Result(valid=True, message="Already was disabled.") - - if not singbox_pid.valid: - return Result(valid=True, message="Already was disabled.") - - singbox_pid = singbox_pid.data - - if not singbox_pid or singbox_pid is None: - return Result(valid=True, message="Already was disabled or singbox showing no pid") - - logger.error(f"Critical Issue! State JSON is blank, but Singbox is still running. {singbox_pid}") - - - # ------------------ - # KILL NON-SINGBOX SUCH AS WIREGUARD - # ------------------ - active_protocol = get_protocol_enum(current_state.profile_id) - logger.info(f"State JSON shows there is already an active profile with id {profile_id} and protocol {active_protocol}") - if active_protocol not in SINGBOX_PROTOCOLS: - return kill_external_protocol(current_state=current_state, active_protocol=active_protocol) - - # ------------------ - # KILL THE "REAL" KNOWN ACTIVE SINGBOX PROFILE - # ------------------ - try: - killed_existing = orchestrate_closing( - current_state=current_state, - interface=Constants.SINGBOX_TUN_IF - ) - except RuntimeError as e: # Interface error raised by process_closure_tool's interface_exists - return Result(valid=False, error_type=ResultError.INTERFACE, message=str(e)) - - if not killed_existing.valid: - logger.error(f"Could not kill singbox because: {killed_existing.error_type}") - return killed_existing - - # ======== FIREWALL =========== - disable_result_object = killswitch.disarm() - if not disable_result_object.valid: - error_msg = "We could not disable the firewall for singbox" - logger.error(error_msg) - raise FirewallError(error_msg) # bubbles to GUI - - # ======== DNS ============= - reverting_dns_worked = revert_dns() - if not disable_result_object.valid: - error_msg = "We could not disable the DNS for singbox, but they might not use systemD" - logger.error(error_msg) + logger.info("Starting end_singbox") # ======== JSON STATE ======== - # Wipe the JSON to reflect reality, - if killed_existing.valid: - logger.info("Successfully took down Singbox tunnel") - SystemState.dissolve() - return Result(valid=True) - else: - error_msg = "Could not delete the singbox state" - logger.error(error_msg) - return Result(valid=False, error_type=ResultError.SINGBOX, message=error_msg) + current_state = SystemState.get() + + # 1. WireGuard Cleanup. In case, it was a different protocol. + if current_state and get_protocol_enum(current_state.profile_id) == ProtocolChoice.WIREGUARD: + from core.services.networking.systemwide_wireguard import terminate_system_connection + terminate_system_connection( + firewall_setting=current_state.firewalled, + dns_setting=current_state.dns_set + ) + return Result(valid=True, message="Killed a wireguard process from within singbox.") + # 2. The Kill Attempt + close_result = orchestrate_closing( + current_state=current_state, + interface=Constants.SINGBOX_TUN_IF + ) + + # 3. System Restoration (Always do this, even if close_result is invalid) + # Otherwise, it's possible to lock the user out of the internet + + # ======== FIREWALL ======== + try: + if not killswitch.disarm().valid: + raise FirewallError("Could not disable the firewall killswitch") + except Exception as e: + logger.error(str(e)) + raise FirewallError(str(e)) + logger.info("Finished with killswitch") + + # ======== DNS ======== + dns_result = revert_dns() + if not dns_result.valid: + logger.warning(f"DNS reversion failed: {dns_result.message}") + + # 4. The "Gatekeeper" Check + # Now that the system is safe (DNS/Firewall restored), we check if the process actually died. + if not close_result.valid: + logger.error("The process is still running; keeping JSON state for diagnostic/retry.") + return close_result + + # 5. Bookkeeping + # Only dissolve the state if the process is confirmed dead. + SystemState.dissolve() + logger.info("Sing-box cleanup sequence complete.") + return Result(valid=True) def start_singbox( @@ -236,7 +215,7 @@ def start_singbox( requirements = [profile_id, 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 singbox runner") + return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Invalid inputs into singbox runner") logger.info("All requirements met.") # ========== KILL IT IF ALREADY UP ========== @@ -306,9 +285,10 @@ def start_singbox( profile_id=profile_id, firewalled=True, # intended setting, not result yet dns_set=True, # intended setting, not result yet + protocol=get_protocol_enum(profile_id), process_id=process_id ) - logger.info(f"State is created.") + logger.info("State is created.") # ============= CONFIRM IP ADDRESS ============= ip_result = ip.test(connection_observer) diff --git a/core/services/networking/systemwide/general_tools/interface_tools.py b/core/services/networking/systemwide/general_tools/interface_tools.py index 12a7762..177dc22 100644 --- a/core/services/networking/systemwide/general_tools/interface_tools.py +++ b/core/services/networking/systemwide/general_tools/interface_tools.py @@ -36,7 +36,7 @@ def exists(interface: str) -> bool: def get_output(interface: str) -> Result: command = ['ip', 'link', 'show', interface] human_readable_goal = "Checking if the interface exists" - result = run_generic_command(command, human_readable_goal, timeout=5) + result = run_generic_command(command, human_readable_goal, timeout=5, allow_blank=True) if not result.valid: return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message=f"We could not run the command to even check the interface. {result.message}") diff --git a/core/utils/basic_operations/pid_tools.py b/core/utils/basic_operations/pid_tools.py index 457ad25..e7320be 100644 --- a/core/utils/basic_operations/pid_tools.py +++ b/core/utils/basic_operations/pid_tools.py @@ -27,17 +27,20 @@ def is_running(pid: int, process_name: str) -> bool: return False # Function is public because it works for any app -def get_pid_by_app_name(exact_app_name: str) -> Result: +def get_pid_by_app_name(exact_app_name: str, allow_blank: bool = False) -> Result: command = ['pgrep', '-x', exact_app_name] human_readable_goal = "Get a pid by an exact match get_pid_by_app_name" - result = run_generic_command(command, human_readable_goal, timeout=7) + result = run_generic_command(command, human_readable_goal, timeout=7, allow_blank=allow_blank) + print(f"pgrep result: {result}") if result.valid: # equivalent: (returncode == 0): + print("pid command ran") try: - process_id = int(result.data.strip()) # Strip newline, convert to int + process_id = result.data.strip() # Strip newline, convert to int + print(f"process_id is {process_id}, returning true regardless..") return Result(valid=True, data=process_id) - except ValueError: - return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Could not parse PID") + except ValueError as e: + return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"Could not parse PID: {str(e)}") else: return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message="pgrep failed") diff --git a/core/utils/run_commands.py b/core/utils/run_commands.py index d7c8ea1..17cd179 100644 --- a/core/utils/run_commands.py +++ b/core/utils/run_commands.py @@ -134,7 +134,8 @@ def _run_command_via_terminal( def run_generic_command( command: list, human_readable_goal: str, - timeout: float | None = None + timeout: float | None = None, + allow_blank: bool = False ) -> Result: output_data = None @@ -149,7 +150,7 @@ def run_generic_command( # output_data = stdout - if returncode == 0: + if returncode == 0 or allow_blank: logger.info(f"{human_readable_goal} was successful") return Result(valid=True, data=stdout) else: @@ -189,6 +190,7 @@ def parse_errors(error_output: str) -> ResultError: return ResultError.MTU else: + print(f"error_output is {error_output}") return ResultError.UNKNOWN