Singbox takedown modules, with a graceful shutdown, then kill, then searching for processes with the phrase. And doing tunnel checks inbetween
This commit is contained in:
parent
26b2562415
commit
bf22f764d0
7 changed files with 277 additions and 5 deletions
|
|
@ -12,8 +12,11 @@ class SystemStateController:
|
||||||
return SystemState.exists()
|
return SystemState.exists()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(profile_id: int, firewalled: bool, dns_set: bool) -> SystemState:
|
def create(profile_id: int, firewalled: bool, dns_set: bool, process_id: int = None) -> SystemState:
|
||||||
current_state = SystemState(profile_id, firewalled, dns_set)
|
if process_id:
|
||||||
|
current_state = SystemState(profile_id, firewalled, dns_set, process_id)
|
||||||
|
else:
|
||||||
|
current_state = SystemState(profile_id, firewalled, dns_set)
|
||||||
current_state.save()
|
current_state.save()
|
||||||
return current_state
|
return current_state
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ class ResultError(Enum):
|
||||||
INVALID_INPUT = "invalid_input"
|
INVALID_INPUT = "invalid_input"
|
||||||
MISSING_FILE = "missing_file"
|
MISSING_FILE = "missing_file"
|
||||||
MISSING_DEPENDENCY = "missing_dependency"
|
MISSING_DEPENDENCY = "missing_dependency"
|
||||||
|
MISSING_DATA = "missing_data"
|
||||||
CONNECTION = "connection"
|
CONNECTION = "connection"
|
||||||
DATABASE = "database"
|
DATABASE = "database"
|
||||||
PERMISSION = "permission"
|
PERMISSION = "permission"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from typing import Self
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
@dataclass_json
|
@dataclass_json
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -13,6 +14,7 @@ class SystemState:
|
||||||
profile_id: int
|
profile_id: int
|
||||||
firewalled: bool
|
firewalled: bool
|
||||||
dns_set: bool
|
dns_set: bool
|
||||||
|
process_id: Optional[int] = None
|
||||||
|
|
||||||
def save(self: Self):
|
def save(self: Self):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
from core.services.networking.systemwide.general_tools.interface_tools import check_interface_exists, check_interface_up
|
||||||
|
from core.errors.exceptions import SudoScript
|
||||||
|
from core.models.system.SystemState import SystemState
|
||||||
|
from core.models.Result import Result, ResultError
|
||||||
|
from core.errors.logger import logger
|
||||||
|
from core.services.networking.systemwide.encrypted_proxy import singbox
|
||||||
|
from core.utils.basic_operations import process_tools
|
||||||
|
import subprocess
|
||||||
|
from typing import Callable, cast
|
||||||
|
import time
|
||||||
|
|
||||||
|
KILL_WAIT_TIME = 1.3
|
||||||
|
APP_NAME = 'sing-box'
|
||||||
|
|
||||||
|
|
||||||
|
def is_tunnel_active(interface: str) -> bool:
|
||||||
|
"""Returns True if active, False if inactive. Raises on check failure."""
|
||||||
|
result = check_interface_exists(interface)
|
||||||
|
if result.valid:
|
||||||
|
return True
|
||||||
|
elif result.error_type == ResultError.INTERFACE:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Could not verify tunnel status: {result.message}")
|
||||||
|
|
||||||
|
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("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,")
|
||||||
|
existance = check_interface_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'}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_process_id(current_state: SystemState) -> Result:
|
||||||
|
process_id = current_state.process_id
|
||||||
|
|
||||||
|
if not process_id:
|
||||||
|
return Result(valid=False, error_type=ResultError.MISSING_DATA, message="Missing the critical process id.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
process_id_as_int = int(process_id)
|
||||||
|
return Result(valid=True, data=process_id_as_int)
|
||||||
|
except ValueError as e:
|
||||||
|
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:
|
||||||
|
function_name = "_shut_down_by_known_process_id"
|
||||||
|
|
||||||
|
active = process_tools.is_running(process_id)
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
logger.error(f"[{function_name}] The process ID we have for Singbox is not actually active still.")
|
||||||
|
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"The process ID {process_id} Already was down.")
|
||||||
|
|
||||||
|
graceful_close = singbox.turn_off(process_id)
|
||||||
|
if not graceful_close.valid:
|
||||||
|
logger.error(f"[{function_name}] Could not gracefully close it. So we'll {KILL_WAIT_TIME} seconds and kill it.")
|
||||||
|
|
||||||
|
still_active = process_tools.is_running(process_id)
|
||||||
|
|
||||||
|
if not still_active:
|
||||||
|
return Result(valid=True, message=f"The process ID {process_id} gracefully closed.")
|
||||||
|
|
||||||
|
time.sleep(KILL_WAIT_TIME)
|
||||||
|
force_kill = singbox.force_kill(process_id)
|
||||||
|
|
||||||
|
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}.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_any_pid_with_the_phrase(which_application: str) -> Result:
|
||||||
|
result = subprocess.run(['pgrep', '-a', which_application], capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
lines = result.stdout.strip().split('\n')
|
||||||
|
pid_data = [{'pid': int(line.split()[0]), 'command': line} for line in lines if line]
|
||||||
|
if pid_data:
|
||||||
|
return Result(valid=True, data=pid_data) # List of dicts with pid and full command
|
||||||
|
else:
|
||||||
|
return Result(valid=False, error_type=ResultError.MISSING_DATA, message="No processes found")
|
||||||
|
else:
|
||||||
|
return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message="pgrep failed")
|
||||||
|
|
||||||
|
|
||||||
|
# Function is public because it works for any app
|
||||||
|
def get_pid_by_exact_match(which_application: str) -> Result:
|
||||||
|
result = subprocess.run(['pgrep', '-x', which_application], capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
try:
|
||||||
|
process_id = int(result.stdout.strip()) # Strip newline, convert to int
|
||||||
|
return Result(valid=True, data=process_id)
|
||||||
|
except ValueError:
|
||||||
|
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Could not parse PID")
|
||||||
|
else:
|
||||||
|
return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message="pgrep failed")
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
id_results = get_any_pid_with_the_phrase(APP_NAME)
|
||||||
|
if id_results.valid:
|
||||||
|
pid_data_list = id_results.data
|
||||||
|
else:
|
||||||
|
return id_results
|
||||||
|
|
||||||
|
for idx, pid_info in enumerate(pid_data_list):
|
||||||
|
pid = pid_info['pid']
|
||||||
|
command = pid_info['command']
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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 = check_interface_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:
|
||||||
|
exact_match = get_pid_by_exact_match(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
|
||||||
|
|
||||||
|
|
||||||
|
def _try_known_pid_from_state(current_state: SystemState) -> Result:
|
||||||
|
pid_query = _get_process_id(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_name: str) -> Result:
|
||||||
|
strategies = [
|
||||||
|
lambda: _try_known_pid_from_state(current_state),
|
||||||
|
lambda: _try_shutdown_by_exact_match(),
|
||||||
|
lambda: _hunt_and_kill(interface_name),
|
||||||
|
]
|
||||||
|
quantity_of_strategies = len(strategies)
|
||||||
|
|
||||||
|
for strategy in strategies:
|
||||||
|
result = _try_with_permission_fallback(strategy, interface_name)
|
||||||
|
if result.valid:
|
||||||
|
return result
|
||||||
|
if not is_tunnel_active(interface_name):
|
||||||
|
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.")
|
||||||
|
|
||||||
|
|
@ -32,3 +32,10 @@ def hello_world_test(name: str) -> str:
|
||||||
command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && hello_world_test "{name}"']
|
command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && hello_world_test "{name}"']
|
||||||
human_readable_goal = "Hello world test"
|
human_readable_goal = "Hello world test"
|
||||||
return run_generic_command(command, human_readable_goal, timeout=5)
|
return run_generic_command(command, human_readable_goal, timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pid_by_exact_match(name: str) -> str:
|
||||||
|
command = ['pgrep', '-x', 'sing-box']
|
||||||
|
human_readable_goal = "Get the exact match of the PID"
|
||||||
|
return run_generic_command(command, human_readable_goal, timeout=5)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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.process_closure_tools import orchestrate_closing
|
||||||
from core.utils.basic_operations import process_tools
|
from core.utils.basic_operations import process_tools
|
||||||
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
|
||||||
|
|
@ -11,6 +12,7 @@ from core.services.networking.systemwide.general_connection_tools.general_firewa
|
||||||
from core.errors.exceptions import FirewallError, DNSError
|
from core.errors.exceptions import FirewallError, DNSError
|
||||||
|
|
||||||
# generic
|
# generic
|
||||||
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
||||||
QUANTITY_OF_ATTEMPTS = 2
|
QUANTITY_OF_ATTEMPTS = 2
|
||||||
|
|
@ -53,6 +55,26 @@ def _attempt_start_with_retry(config_path: str, quantity_of_attempts: int = 2) -
|
||||||
logger.error(f"[SINGBOX] Attempt {current_attempt} for Singbox Failed. Because: {activation_result.message}. Trying again..")
|
logger.error(f"[SINGBOX] Attempt {current_attempt} for Singbox Failed. Because: {activation_result.message}. Trying again..")
|
||||||
|
|
||||||
|
|
||||||
|
def end_singbox(
|
||||||
|
connection_observer: Optional[ConnectionObserver] = None
|
||||||
|
) -> Result:
|
||||||
|
|
||||||
|
function_name = "END_SINGBOX"
|
||||||
|
|
||||||
|
current_state = SystemState.get()
|
||||||
|
if not current_state:
|
||||||
|
return Result(valid=False, error_type=ResultError.MISSING_FILE, message="Already disabled or missing State JSON.")
|
||||||
|
|
||||||
|
dead_singbox = orchestrate_closing(
|
||||||
|
current_state=current_state,
|
||||||
|
interface_name=SINGBOX_TUN_IF
|
||||||
|
)
|
||||||
|
|
||||||
|
if dead_singbox.valid:
|
||||||
|
logger.info("Successfully took down Singbox tunnel")
|
||||||
|
SystemState.dissolve()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def start_singbox(
|
def start_singbox(
|
||||||
profile_id: int,
|
profile_id: int,
|
||||||
|
|
@ -104,9 +126,10 @@ def start_singbox(
|
||||||
|
|
||||||
logger.info(f"Setting State JSON with INTENDED firewall & Dns settings")
|
logger.info(f"Setting State JSON with INTENDED firewall & Dns settings")
|
||||||
current_state = SystemStateController.create(
|
current_state = SystemStateController.create(
|
||||||
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
|
||||||
|
process_id=process_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# ============= FIREWALL =============
|
# ============= FIREWALL =============
|
||||||
|
|
@ -132,5 +155,5 @@ def start_singbox(
|
||||||
# raises error if not okay.
|
# raises error if not okay.
|
||||||
|
|
||||||
# ============= CONCLUSION =============
|
# ============= CONCLUSION =============
|
||||||
return Result(valid=True, data=current_state)
|
return Result(valid=True, data=process_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
from core.models.Result import Result, ResultError
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
|
||||||
|
def check_interface_exists(interface: str) -> Result:
|
||||||
|
result = subprocess.run(['ip', 'link', 'show', interface], capture_output=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message=f"We could not run the command to even check the interface. {result.stdout}")
|
||||||
|
elif 'does not exist' in result.stderr.decode():
|
||||||
|
return Result(valid=False, error_type=ResultError.INTERFACE, message="Interface does not exist.")
|
||||||
|
else:
|
||||||
|
# Check if interface name exists in the expected format
|
||||||
|
# Pattern: "digits: interface_name: <flags>"
|
||||||
|
output = result.stdout.decode()
|
||||||
|
pattern = rf'^\d+:\s+{re.escape(interface)}:\s+<[^>]+>'
|
||||||
|
if re.search(pattern, output, re.MULTILINE):
|
||||||
|
return Result(valid=True, data=output)
|
||||||
|
else:
|
||||||
|
return Result(valid=False, error_type=ResultError.INTERFACE, message="Interface format not recognized.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def check_interface_up(output: str) -> Result:
|
||||||
|
"""
|
||||||
|
Check if interface has LOWER_UP flag (meaning it's operationally up)
|
||||||
|
"""
|
||||||
|
if re.search(r'LOWER_UP', output):
|
||||||
|
return Result(valid=True)
|
||||||
|
else:
|
||||||
|
return Result(valid=False, error_type=ResultError.INTERFACE, message="Interface is not up. LOWER_UP flag not found.")
|
||||||
Loading…
Reference in a new issue