Improved singbox cleanup process. And added support for killing wireguard profiles inside end_singbox.

This commit is contained in:
SimplifiedPrivacy 2026-10-15 19:02:47 -04:00
parent ba3ce97e34
commit 6107a355f9
7 changed files with 155 additions and 198 deletions

View file

@ -1,5 +1,5 @@
from core.models.system.SystemState import SystemState from core.models.system.SystemState import SystemState
from core.models.enum_models import ProtocolChoice
class SystemStateController: class SystemStateController:
@ -12,11 +12,11 @@ class SystemStateController:
return SystemState.exists() return SystemState.exists()
@staticmethod @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: 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: else:
current_state = SystemState(profile_id, firewalled, dns_set) current_state = SystemState(profile_id, firewalled, dns_set, protocol)
current_state.save() current_state.save()
return current_state return current_state

View file

@ -1,5 +1,6 @@
from core.models.enum_models import ProtocolChoice from core.models.enum_models import ProtocolChoice
from core.Constants import Constants from core.Constants import Constants
from core.utils.basic_operations.enum_tools import serialize_enums
# generic # generic
from dataclasses import dataclass from dataclasses import dataclass
@ -22,7 +23,12 @@ class SystemState:
def save(self: Self): 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) os.makedirs(SystemState.__get_state_path(), exist_ok=True, mode=0o700)
system_state_file_path = f'{SystemState.__get_state_path()}/system.json' 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: with open(system_state_file_path, 'w') as system_state_file:
system_state_file.write(system_state_file_contents) system_state_file.write(system_state_file_contents)
print("saved system state")
@staticmethod @staticmethod
def get(): def get():

View file

@ -7,6 +7,7 @@ from core.errors.logger import logger
from core.services.networking.systemwide.encrypted_proxy import singbox from core.services.networking.systemwide.encrypted_proxy import singbox
from core.utils.basic_operations import process_tools from core.utils.basic_operations import process_tools
from core.utils.run_commands import run_generic_command from core.utils.run_commands import run_generic_command
from core.utils.type_checker import type_checker
from typing import Callable, cast, Optional from typing import Callable, cast, Optional
import time import time
@ -14,37 +15,8 @@ import time
KILL_WAIT_TIME = 1.3 KILL_WAIT_TIME = 1.3
APP_NAME = 'sing-box' 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: def get_process_id_from_state(current_state: Optional[SystemState]) -> Result:
if current_state is None: if current_state is None:
return Result(valid=False, error_type=ResultError.MISSING_DATA, message="Missing the State itself.") 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.") 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. @type_checker
def _shut_down_by_known_process_id(process_id: int) -> Result: def terminate_pid(pid: int) -> Result:
graceful_close = singbox.stop(process_id) """
if not graceful_close.valid: The single source of truth for killing a process.
logger.error(f"Could not gracefully close it. So we'll {KILL_WAIT_TIME} seconds and kill it.") Logic: Graceful Stop -> Wait -> Force Kill.
"""
if not process_tools.is_running(pid):
return Result(valid=True, message=f"PID {pid} already down")
still_active = pid_tools.is_running(pid=process_id, process_name="sing-box") # 1. Attempt Graceful Stop
logger.info(f"Attempting graceful stop of PID {pid}...")
if not still_active: singbox.stop(pid) # We don't strictly need the result here, we check is_running
return Result(valid=True, message=f"The process ID {process_id} gracefully closed or never was running to begin with.")
time.sleep(KILL_WAIT_TIME) time.sleep(KILL_WAIT_TIME)
logger.info(f"Graceful closed failed, as {process_id} is still running. Escalating to forced killing..")
force_kill = singbox.force_kill(process_id) if not process_tools.is_running(pid):
return Result(valid=True, message=f"PID {pid} closed gracefully")
if force_kill.valid: # 2. Attempt Force Kill
return Result(valid=True, message=f"The process ID {process_id} was forcefully closed.") logger.warning(f"PID {pid} resisted graceful stop. Escalating to force kill...")
else: force_result = singbox.force_kill(pid)
return Result(valid=False, message=f"Even a force kill couldn't stop the process ID {process_id}.")
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")
@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: def _hunt_and_kill(interface_name: str) -> Result:
""" """Finds all potential sing-box PIDs and kills them until the interface disappears."""
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"
id_results = pid_tools.get_pids_by_phrase(phase=APP_NAME) id_results = pid_tools.get_pids_by_phrase(phase=APP_NAME)
if id_results.valid: if not id_results.valid:
pid_data_list = id_results.data
else:
return id_results 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'] 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}") terminate_pid(int(pid))
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
if not process_tools.is_running(pid): # Check if the interface finally dropped
logger.info(f"[{function_name}] PID {pid} force killed successfully") if not interface_tools.exists(interface=interface_name):
return Result(valid=True, message=f"Tunnel stopped after killing PID {pid}")
# EVALUATION: Did killing this PID stop the app? is the interface still running? return Result(valid=False, message="Hunted all matching PIDs, but interface remains active")
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, error_type=ResultError.UNKNOWN, message="Could not kill any PIDs")
def _try_shutdown_by_exact_match() -> Result: def orchestrate_closing(current_state: Optional[SystemState], interface: str) -> Result:
exact_match = pid_tools.get_pid_by_app_name(exact_app_name=APP_NAME) """
if exact_match.valid: Tries increasingly aggressive strategies to bring down the proxy.
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 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)
def _try_known_pid_from_state(current_state: Optional[SystemState]) -> Result: # Strategy 2: Exact name match (pgrep -x)
pid_query = get_process_id_from_state(current_state) def try_exact_match():
if not pid_query.valid: match = pid_tools.get_pid_by_app_name(exact_app_name=APP_NAME)
return pid_query if not match.valid:
return _shut_down_by_known_process_id(cast(int, pid_query.data)) return match
return terminate_pid(int(match.data))
# Strategy 3: Hunt all related processes
def try_hunt():
return _hunt_and_kill(interface)
def orchestrate_closing(current_state: SystemState, interface: str) -> Result: strategies = [try_state_pid, try_exact_match, try_hunt]
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)
for strategy in strategies: for strategy in strategies:
result = _try_with_permission_fallback(strategy, interface) result = _try_with_permission_fallback(strategy, interface)
if result.valid: if result.valid:
# remove state json:
SystemState.dissolve()
return result return result
# Final sanity check: Did the strategy fail, but the interface is gone anyway?
if not interface_tools.exists(interface): if not interface_tools.exists(interface):
# remove state json: return Result(valid=True, message="Interface disappeared despite strategy reporting failure")
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=False, message="All shutdown strategies exhausted. Proxy is still active.")

View file

@ -1,8 +1,8 @@
from core.controllers.profile_state.get_profile_data import get_protocol_enum from core.controllers.profile_state.get_profile_data import get_protocol_enum
from core.models.enum_models import ProtocolChoice 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 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.general_tools import interface_tools
from core.services.networking.systemwide import killswitch from core.services.networking.systemwide import killswitch
from core.services.networking.systemwide.wireguard.wg_firewall_dns import revert_dns 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) return Result(valid=False, error_type=ResultError.PROCESS_WONT_START, message=error_msg)
def end_singbox() -> Result: def end_singbox() -> Result:
"""Orchestrate closing singbox's process, firewall, dns, and json state."""
# get by systemstate: logger.info("Starting end_singbox")
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)
# ======== JSON STATE ======== # ======== JSON STATE ========
# Wipe the JSON to reflect reality, current_state = SystemState.get()
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)
# 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( def start_singbox(
@ -236,7 +215,7 @@ def start_singbox(
requirements = [profile_id, server_ip] requirements = [profile_id, server_ip]
for each_requirement in requirements: for each_requirement in requirements:
if each_requirement is None: 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.") logger.info("All requirements met.")
# ========== KILL IT IF ALREADY UP ========== # ========== KILL IT IF ALREADY UP ==========
@ -306,9 +285,10 @@ def start_singbox(
profile_id=profile_id, profile_id=profile_id,
firewalled=True, # intended setting, not result yet firewalled=True, # intended setting, not result yet
dns_set=True, # intended setting, not result yet dns_set=True, # intended setting, not result yet
protocol=get_protocol_enum(profile_id),
process_id=process_id process_id=process_id
) )
logger.info(f"State is created.") logger.info("State is created.")
# ============= CONFIRM IP ADDRESS ============= # ============= CONFIRM IP ADDRESS =============
ip_result = ip.test(connection_observer) ip_result = ip.test(connection_observer)

View file

@ -36,7 +36,7 @@ def exists(interface: str) -> bool:
def get_output(interface: str) -> Result: def get_output(interface: str) -> Result:
command = ['ip', 'link', 'show', interface] command = ['ip', 'link', 'show', interface]
human_readable_goal = "Checking if the interface exists" 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: 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}") return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message=f"We could not run the command to even check the interface. {result.message}")

View file

@ -27,17 +27,20 @@ def is_running(pid: int, process_name: str) -> bool:
return False return False
# Function is public because it works for any app # 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] command = ['pgrep', '-x', exact_app_name]
human_readable_goal = "Get a pid by an exact match get_pid_by_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): if result.valid: # equivalent: (returncode == 0):
print("pid command ran")
try: 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) return Result(valid=True, data=process_id)
except ValueError: except ValueError as e:
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Could not parse PID") return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"Could not parse PID: {str(e)}")
else: else:
return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message="pgrep failed") return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message="pgrep failed")

View file

@ -134,7 +134,8 @@ def _run_command_via_terminal(
def run_generic_command( def run_generic_command(
command: list, command: list,
human_readable_goal: str, human_readable_goal: str,
timeout: float | None = None timeout: float | None = None,
allow_blank: bool = False
) -> Result: ) -> Result:
output_data = None output_data = None
@ -149,7 +150,7 @@ def run_generic_command(
# output_data = stdout # output_data = stdout
if returncode == 0: if returncode == 0 or allow_blank:
logger.info(f"{human_readable_goal} was successful") logger.info(f"{human_readable_goal} was successful")
return Result(valid=True, data=stdout) return Result(valid=True, data=stdout)
else: else:
@ -189,6 +190,7 @@ def parse_errors(error_output: str) -> ResultError:
return ResultError.MTU return ResultError.MTU
else: else:
print(f"error_output is {error_output}")
return ResultError.UNKNOWN return ResultError.UNKNOWN