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.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

View file

@ -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():

View file

@ -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.")

View file

@ -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)

View file

@ -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}")

View file

@ -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")

View file

@ -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