From eff49cf1f75fbb084792c0399f451a0b6b19789d Mon Sep 17 00:00:00 2001 From: zenaku Date: Fri, 5 Jun 2026 10:31:35 -0500 Subject: [PATCH] radical change --- README.md | 1 + core/controllers/ConnectionController.py | 31 ++-- core/controllers/ProfileController.py | 21 +-- core/observers/ConnectionObserver.py | 8 +- .../encrypted_proxy/disable_service.py | 49 +++---- .../encrypted_proxy/hysteria_service.py | 83 +++++++++-- .../services/encrypted_proxy/vless_service.py | 79 ++++++++-- core/utils/encrypted_proxy/dns.py | 51 +++++++ core/utils/encrypted_proxy/killswitch.py | 36 +++++ core/utils/encrypted_proxy/net.py | 5 +- core/utils/encrypted_proxy/singbox.py | 135 +++++++++--------- 11 files changed, 347 insertions(+), 152 deletions(-) create mode 100644 core/utils/encrypted_proxy/dns.py create mode 100644 core/utils/encrypted_proxy/killswitch.py diff --git a/README.md b/README.md index 59280df..ec54fb4 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,4 @@ controller = HysteriaController(1080) controller.disable(observer) " ``` +sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf \ No newline at end of file diff --git a/core/controllers/ConnectionController.py b/core/controllers/ConnectionController.py index ae64a83..35ef893 100644 --- a/core/controllers/ConnectionController.py +++ b/core/controllers/ConnectionController.py @@ -217,14 +217,27 @@ class ConnectionController: if protocol == 'vless': from core.controllers.encrypted_proxy.VlessController import VlessController - VlessController(1080).enable(operator_proxy_session.links[0], operator_proxy_session.username, connection_observer) + ok = VlessController(1080).enable( + operator_proxy_session.links[0], + operator_proxy_session.username, + connection_observer + ) elif protocol == 'hysteria2': from core.controllers.encrypted_proxy.HysteriaController import HysteriaController - HysteriaController(1080).enable(operator_proxy_session.username, operator_proxy_session.password, operator_proxy_session.operator_hysteria2_host, connection_observer) + ok = HysteriaController(1080).enable( + operator_proxy_session.username, + operator_proxy_session.password, + operator_proxy_session.operator_hysteria2_host, + connection_observer + ) + else: + ok = False + + if not ok: + raise ConnectionError('The connection could not be established.') SystemStateController.create(profile.id) return - if ConfigurationController.get_endpoint_verification_enabled(): ProfileController.verify_wireguard_endpoint(profile, ignore=ignore) @@ -338,7 +351,7 @@ class ConnectionController: return subprocess.Popen(('proxychains4', '-f', proxychains_configuration_file_path, 'microsocks', '-p', str(proxy_port_number)), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) @staticmethod - def terminate_system_connection(): + def terminate_system_connection(connection_observer: Optional[ConnectionObserver] = None): system_state = SystemStateController.get() if system_state is not None: profile = ProfileController.get(system_state.profile_id) @@ -346,10 +359,10 @@ class ConnectionController: protocol = profile.connection.get_protocol() if protocol == 'vless': from core.controllers.encrypted_proxy.VlessController import VlessController - VlessController(1080).disable() + VlessController(1080).disable(connection_observer) elif protocol == 'hysteria2': from core.controllers.encrypted_proxy.HysteriaController import HysteriaController - HysteriaController(1080).disable() + HysteriaController(1080).disable(connection_observer) SystemState.dissolve() return @@ -357,18 +370,18 @@ class ConnectionController: raise CommandNotFoundError('nmcli') if SystemStateController.exists(): - process = subprocess.Popen(('nmcli', 'connection', 'delete', 'wg'), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8) if completed_successfully or not ConnectionController.system_uses_wireguard_interface(): - subprocess.run(('nmcli', 'connection', 'delete', 'hv-ipv6-sink'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) ConnectionController.terminate_tor_connection() SystemState.dissolve() - + if connection_observer is not None: + connection_observer.notify('disconnected', {}) else: raise ConnectionTerminationError('The connection could not be terminated.') + @staticmethod def get_proxies(port_number: int): diff --git a/core/controllers/ProfileController.py b/core/controllers/ProfileController.py index 14473eb..688837c 100644 --- a/core/controllers/ProfileController.py +++ b/core/controllers/ProfileController.py @@ -86,51 +86,36 @@ class ProfileController: profile_observer.notify('enabled', profile) @staticmethod - def disable(profile: Union[SessionProfile, SystemProfile], explicitly: bool = True, ignore: tuple[type[Exception]] = (), profile_observer: ProfileObserver = None): + def disable(profile: Union[SessionProfile, SystemProfile], explicitly: bool = True, ignore: tuple[type[Exception]] = (), profile_observer: ProfileObserver = None, connection_observer: ConnectionObserver = None): from core.controllers.ConnectionController import ConnectionController if profile.is_session_profile(): - if SessionStateController.exists(profile.id): - session_state = SessionStateController.get(profile.id) - if session_state is not None: - for port_number in session_state.network_port_numbers.tor: ConnectionController.terminate_tor_session_connection(port_number) - session_state.dissolve(session_state.id) if profile.is_system_profile(): - subjects = ProfileController.get_all().values() - for subject in subjects: - if subject.is_session_profile(): - if subject.connection.is_unprotected() and ProfileController.is_enabled(subject) and not ConnectionUnprotectedError in ignore: raise ConnectionUnprotectedError('Disabling this system connection would leave one or more sessions exposed.') if SystemStateController.exists(): - system_state = SystemStateController.get() - if profile.id != system_state.profile_id: raise ProfileDeactivationError('The profile could not be disabled.') - try: - ConnectionController.terminate_system_connection() + ConnectionController.terminate_system_connection(connection_observer=connection_observer) except ConnectionTerminationError: raise ProfileDeactivationError('The profile could not be disabled.') if profile_observer is not None: - - profile_observer.notify('disabled', profile, dict( - explicitly=explicitly, - )) + profile_observer.notify('disabled', profile, dict(explicitly=explicitly)) time.sleep(1.0) diff --git a/core/observers/ConnectionObserver.py b/core/observers/ConnectionObserver.py index 88ad874..bd6574e 100644 --- a/core/observers/ConnectionObserver.py +++ b/core/observers/ConnectionObserver.py @@ -1,5 +1,9 @@ from essentials.observers.ConnectionObserver import ConnectionObserver as BaseConnectionObserver - class ConnectionObserver(BaseConnectionObserver): - pass + + def __init__(self): + super().__init__() + self.on_connected = [] + self.on_disconnected = [] + self.on_error = [] \ No newline at end of file diff --git a/core/services/encrypted_proxy/disable_service.py b/core/services/encrypted_proxy/disable_service.py index 6ac777b..35acaea 100644 --- a/core/services/encrypted_proxy/disable_service.py +++ b/core/services/encrypted_proxy/disable_service.py @@ -1,33 +1,26 @@ -import ssl +import subprocess import time -import urllib.request -from pathlib import Path from core.utils.encrypted_proxy.singbox import SingboxRunner +from core.utils.encrypted_proxy.dns import revert_dns_on_tun +from core.utils.encrypted_proxy import killswitch def get_public_ip(timeout: int = 8) -> str: - ctx = ssl.create_default_context() - try: - req = urllib.request.Request( - "https://ifconfig.me/ip", - headers={"User-Agent": "curl/7.0"} - ) - with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: - return resp.read().decode().strip() - except Exception as e: - return f"unknown ({e})" - - -def disable_proxy(tmp_dir: Path, wrapper: str, unit: str, observer=None) -> bool: - runner = SingboxRunner(wrapper, unit) - runner.stop() - - for f in tmp_dir.glob("*-sing-box.json"): - f.unlink(missing_ok=True) - - time.sleep(1) - ip = get_public_ip() - - if observer: - observer.notify("disconnected", {"ip": ip}) - return True \ No newline at end of file + endpoints = [ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + ] + for url in endpoints: + try: + r = subprocess.run( + ["curl", "-s", "--max-time", str(timeout), url], + capture_output=True, + timeout=timeout + 2, + ) + ip = r.stdout.decode().strip() + if ip and "." in ip and not ip.startswith("unknown"): + return ip + except Exception: + continue + return "unknown" \ No newline at end of file diff --git a/core/services/encrypted_proxy/hysteria_service.py b/core/services/encrypted_proxy/hysteria_service.py index 42bf09a..4dbb430 100644 --- a/core/services/encrypted_proxy/hysteria_service.py +++ b/core/services/encrypted_proxy/hysteria_service.py @@ -1,24 +1,33 @@ import socket +import subprocess import time from pathlib import Path from core.Constants import Constants from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner - +from core.utils.encrypted_proxy.dns import wait_for_tun, set_dns_on_tun, revert_dns_on_tun +from core.utils.encrypted_proxy import killswitch +from core.services.encrypted_proxy.disable_service import get_public_ip def _resolve(host: str) -> str: return socket.gethostbyname(host) - def build_hysteria_config(username: str, password: str, server_host: str, socks5_port: int) -> dict: server_ip = _resolve(server_host) return { "dns": { - "servers": [{"tag": "local", "type": "udp", "server": "9.9.9.9"}], - "final": "local", + "servers": [ + { + "tag": "tunnel-dns", + "type": "udp", + "server": "9.9.9.9", + }, + ], + "final": "tunnel-dns", "strategy": "ipv4_only", + "independent_cache": True, }, "inbounds": [ { @@ -28,7 +37,7 @@ def build_hysteria_config(username: str, password: str, "address": ["172.19.0.1/30"], "mtu": 9000, "auto_route": True, - "stack": "system", + "stack": "gvisor", }, { "type": "socks", @@ -57,43 +66,88 @@ def build_hysteria_config(username: str, password: str, "rules": [ {"protocol": "dns", "action": "hijack-dns"}, {"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"}, - # DNS y servidor salen directo — nunca por el TUN - {"ip_cidr": ["9.9.9.9/32"], "outbound": "direct"}, {"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"}, {"ip_is_private": True, "outbound": "direct"}, {"ip_version": 6, "outbound": "block"}, {"inbound": ["tun-in", "socks-in"], "outbound": "proxy"}, ], "final": "proxy", - "default_domain_resolver": "local", + "default_domain_resolver": "tunnel-dns", "auto_detect_interface": True, }, } +def _cleanup_all() -> None: + try: + SingboxRunner().stop() + except Exception: + pass + + try: + revert_dns_on_tun() + except Exception: + pass + + try: + killswitch.disarm() + except Exception: + pass + + time.sleep(1) + + def enable_hysteria(username: str, password: str, server_host: str, socks5_port: int, observer=None) -> bool: + + _cleanup_all() + + server_ip = _resolve(server_host) real_ip = get_real_ip() + + if not killswitch.arm(server_ip): + if observer: + observer.notify("error", "Failed to arm kill switch") + return False + runner = SingboxRunner() runner.stop() config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json" config = build_hysteria_config(username, password, server_host, socks5_port) + try: runner.write_config(config_path, config) ok = runner.start(config_path) except Exception as e: + killswitch.disarm() if observer: observer.notify("error", str(e)) return False + if not ok: + killswitch.disarm() if observer: observer.notify("error", "sing-box not active after start") return False - # Esperar que el proxy esté listo para rutear tráfico - time.sleep(3) + if not wait_for_tun(timeout=15.0): + killswitch.disarm() + runner.stop() + if observer: + observer.notify("error", "tun0 did not appear after 15s") + return False + + set_dns_on_tun() proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0) + if not proxy_ip or proxy_ip == "unknown" or proxy_ip == real_ip: + killswitch.disarm() + revert_dns_on_tun() + runner.stop() + if observer: + observer.notify("error", "IP did not change — possible leak") + return False + if observer: observer.notify("connected", { "real_ip": real_ip, @@ -102,9 +156,12 @@ def enable_hysteria(username: str, password: str, server_host: str, }) return True - def disable_hysteria(observer=None) -> bool: + revert_dns_on_tun() SingboxRunner().stop() + killswitch.disarm() + time.sleep(1) + ip = get_public_ip() if observer: - observer.notify("disconnected", {}) - return True + observer.notify("disconnected", {"ip": ip}) + return True \ No newline at end of file diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py index ac721c5..517579e 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -1,10 +1,14 @@ from urllib.parse import unquote from pathlib import Path +import socket +import subprocess +import time +from core.services.encrypted_proxy.disable_service import get_public_ip from core.Constants import Constants from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner -import socket -import time +from core.utils.encrypted_proxy.dns import wait_for_tun, set_dns_on_tun, revert_dns_on_tun +from core.utils.encrypted_proxy import killswitch def parse_vless_link(link: str) -> dict: @@ -39,13 +43,14 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: "dns": { "servers": [ { - "tag": "local", + "tag": "tunnel-dns", "type": "udp", "server": "9.9.9.9", }, ], - "final": "local", + "final": "tunnel-dns", "strategy": "ipv4_only", + "independent_cache": True, }, "inbounds": [ { @@ -55,7 +60,7 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: "address": ["172.19.0.1/30"], "mtu": 9000, "auto_route": True, - "stack": "system", + "stack": "gvisor", }, { "type": "socks", @@ -66,7 +71,7 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: ], "outbounds": [ {"type": "direct", "tag": "direct"}, - {"type": "block", "tag": "block"}, + {"type": "block", "tag": "block"}, { "type": "vless", "tag": "proxy", @@ -89,43 +94,89 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: "rules": [ {"protocol": "dns", "action": "hijack-dns"}, {"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"}, - {"ip_cidr": ["9.9.9.9/32"], "outbound": "direct"}, {"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"}, {"ip_is_private": True, "outbound": "direct"}, {"ip_version": 6, "outbound": "block"}, {"inbound": ["tun-in", "socks-in"], "outbound": "proxy"}, ], "final": "proxy", - "default_domain_resolver": "local", + "default_domain_resolver": "tunnel-dns", "auto_detect_interface": True, }, } +def _cleanup_all() -> None: + try: + SingboxRunner().stop() + except Exception: + pass + + try: + revert_dns_on_tun() + except Exception: + pass + + try: + killswitch.disarm() + except Exception: + pass + + time.sleep(1) + + def enable_vless(vless_link: str, username: str, socks5_port: int, observer=None) -> bool: + + _cleanup_all() + vless = parse_vless_link(vless_link) + server_ip = socket.gethostbyname(vless["host"]) real_ip = get_real_ip() + + if not killswitch.arm(server_ip): + if observer: + observer.notify("error", "Failed to arm kill switch") + return False + runner = SingboxRunner() runner.stop() config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json" config = build_vless_config(vless, socks5_port) + try: runner.write_config(config_path, config) ok = runner.start(config_path) except Exception as e: + killswitch.disarm() if observer: observer.notify("error", str(e)) return False + if not ok: + killswitch.disarm() if observer: observer.notify("error", "sing-box not active after start") return False - # Esperar que el proxy esté listo para rutear tráfico - time.sleep(3) + if not wait_for_tun(timeout=15.0): + killswitch.disarm() + runner.stop() + if observer: + observer.notify("error", "tun0 did not appear after 15s") + return False + + set_dns_on_tun() proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0) + if not proxy_ip or proxy_ip == "unknown" or proxy_ip == real_ip: + killswitch.disarm() + revert_dns_on_tun() + runner.stop() + if observer: + observer.notify("error", "IP did not change — possible leak") + return False + if observer: observer.notify("connected", { "real_ip": real_ip, @@ -136,7 +187,11 @@ def enable_vless(vless_link: str, username: str, def disable_vless(observer=None) -> bool: + revert_dns_on_tun() SingboxRunner().stop() + killswitch.disarm() + time.sleep(1) + ip = get_public_ip() if observer: - observer.notify("disconnected", {}) - return True + observer.notify("disconnected", {"ip": ip}) + return True \ No newline at end of file diff --git a/core/utils/encrypted_proxy/dns.py b/core/utils/encrypted_proxy/dns.py new file mode 100644 index 0000000..7b122b0 --- /dev/null +++ b/core/utils/encrypted_proxy/dns.py @@ -0,0 +1,51 @@ +import os +import subprocess +import time + +RESOLVECTL_WRAPPER = "/usr/local/bin/hydraveil-resolvectl" +TUN_IFACE = "tun0" +TUN_DNS = "9.9.9.9" + +_SUDO_KW = dict( + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + stdin=subprocess.DEVNULL, + env={**os.environ, "SUDO_ASKPASS": "/bin/false"}, + text=True, +) + + +def wait_for_tun(timeout: float = 15.0, interval: float = 0.5) -> bool: + elapsed = 0.0 + while elapsed < timeout: + result = subprocess.run( + ["ip", "link", "show", TUN_IFACE], + capture_output=True, + ) + if result.returncode == 0: + return True + time.sleep(interval) + elapsed += interval + return False + + +def set_dns_on_tun() -> bool: + result = subprocess.run( + ["sudo", RESOLVECTL_WRAPPER, "set", TUN_DNS], + **_SUDO_KW, + ) + if result.returncode != 0: + print(f"[DNS] Failed to configure tun0: {result.stderr.strip()}") + return False + return True + + +def revert_dns_on_tun() -> bool: + result = subprocess.run( + ["sudo", RESOLVECTL_WRAPPER, "revert"], + **_SUDO_KW, + ) + if result.returncode != 0: + print(f"[DNS] Failed to revert tun0: {result.stderr.strip()}") + return False + return True \ No newline at end of file diff --git a/core/utils/encrypted_proxy/killswitch.py b/core/utils/encrypted_proxy/killswitch.py new file mode 100644 index 0000000..09235bb --- /dev/null +++ b/core/utils/encrypted_proxy/killswitch.py @@ -0,0 +1,36 @@ +import subprocess + +KILLSWITCH_WRAPPER = "/usr/local/bin/hydraveil-killswitch" + + +def arm(server_ip: str) -> bool: + result = subprocess.run( + ["sudo", KILLSWITCH_WRAPPER, "arm", server_ip], + capture_output=True, + text=True + ) + if result.returncode != 0: + print(f"[killswitch] Failed to arm: {result.stderr.strip()}") + return False + return True + + +def disarm() -> bool: + result = subprocess.run( + ["sudo", KILLSWITCH_WRAPPER, "disarm"], + capture_output=True, + text=True + ) + if result.returncode != 0: + print(f"[killswitch] Failed to disarm: {result.stderr.strip()}") + return False + return True + + +def status() -> bool: + result = subprocess.run( + ["sudo", KILLSWITCH_WRAPPER, "status"], + capture_output=True, + text=True + ) + return result.returncode == 0 and result.stdout.strip() == "armed" \ No newline at end of file diff --git a/core/utils/encrypted_proxy/net.py b/core/utils/encrypted_proxy/net.py index 48f4fd9..14308c6 100644 --- a/core/utils/encrypted_proxy/net.py +++ b/core/utils/encrypted_proxy/net.py @@ -54,7 +54,6 @@ def get_proxied_ip(socks5_port: int, timeout: int = 15) -> str: stderr = r.stderr.decode().strip() if ip and not ip.startswith("<") and "." in ip: return ip - # Log silencioso para debug if stderr: print(f"[net] curl stderr ({url}): {stderr[:120]}") except subprocess.TimeoutExpired: @@ -66,10 +65,10 @@ def get_proxied_ip(socks5_port: int, timeout: int = 15) -> str: def verify_ip(socks5_port: int, retries: int = 5, delay: float = 3.0) -> str: for attempt in range(1, retries + 1): - print(f"[net] verify_ip intento {attempt}/{retries}...") + print(f"[net] verify_ip attempt {attempt}/{retries}...") ip = get_proxied_ip(socks5_port) if ip != "unknown": return ip if attempt < retries: time.sleep(delay) - return "unknown" + return "unknown" \ No newline at end of file diff --git a/core/utils/encrypted_proxy/singbox.py b/core/utils/encrypted_proxy/singbox.py index 76df3c0..784e689 100644 --- a/core/utils/encrypted_proxy/singbox.py +++ b/core/utils/encrypted_proxy/singbox.py @@ -8,33 +8,17 @@ from core.Constants import Constants class SingboxRunner: - """ - Gestiona el ciclo de vida de sing-box via wrapper sudo. - Flujo: - write_config() → start() → is_running() → stop() - - El wrapper es el único binario con NOPASSWD en sudoers. - Nunca se llama sing-box ni ip directamente con sudo desde Python. - """ - - _WRAPPER = Constants.SINGBOX_WRAPPER - _PID_FILE = Path(Constants.SINGBOX_PID_FILE) - _LOG_FILE = Path(Constants.SINGBOX_LOG_FILE) + _WRAPPER = Constants.SINGBOX_WRAPPER + _PID_FILE = Path(Constants.SINGBOX_PID_FILE) + _LOG_FILE = Path(Constants.SINGBOX_LOG_FILE) _CONFIG_DIR = Path(Constants.SINGBOX_CONFIG_DIR) - _START_TIMEOUT_S = 15.0 # tiempo total de espera - _START_POLL_S = 0.5 # intervalo de polling - _START_INITIAL_S = 2.0 # espera inicial antes de empezar a verificar - # (el wrapper necesita ~1s para escribir el PID file) - - # ── Público ─────────────────────────────────────────────────────────────── + _START_TIMEOUT_S = 15.0 + _START_POLL_S = 0.5 + _START_INITIAL_S = 2.0 def start(self, config_path: Path) -> bool: - """ - Para cualquier instancia previa y lanza sing-box via wrapper. - Retorna True solo cuando el proceso está confirmado vivo. - """ self.stop() self._assert_config_safe(config_path) self._ensure_log_file() @@ -49,11 +33,10 @@ class SingboxRunner: ) except FileNotFoundError: raise RuntimeError( - f'Wrapper no encontrado: {self._WRAPPER}\n' - 'Ejecutá el installer primero.' + f'Wrapper not found: {self._WRAPPER}\n' + 'Run the installer first.' ) - # Esperar que el wrapper escriba el PID file antes de empezar polling time.sleep(self._START_INITIAL_S) deadline = time.monotonic() + self._START_TIMEOUT_S @@ -63,15 +46,13 @@ class SingboxRunner: time.sleep(self._START_POLL_S) raise RuntimeError( - f'sing-box no levantó en {self._START_TIMEOUT_S}s.\n' - f'Último error:\n{self.get_last_error()}' + f'sing-box did not start within {self._START_TIMEOUT_S}s.\n' + f'Last error:\n{self.get_last_error()}' ) def stop(self) -> None: - """ - Para sing-box limpiamente via wrapper. - Nunca lanza excepción — stop debe ser siempre seguro de llamar. - """ + self._kill_orphans() + try: subprocess.run( ['sudo', self._WRAPPER, '', 'stop'], @@ -85,16 +66,7 @@ class SingboxRunner: self._emergency_kill() def is_running(self) -> bool: - """ - Verifica por PID file + señal 0. - PermissionError significa que el proceso VIVE (corre como root). - Si el PID file no existe todavía, verifica por pgrep como fallback - para cubrir la ventana entre que el proceso arranca y el wrapper - escribe el PID file. - """ if not self._PID_FILE.exists(): - # Fallback: el proceso puede estar vivo pero el PID file - # aún no fue escrito por el wrapper try: result = subprocess.run( ['pgrep', '-x', 'sing-box'], @@ -115,14 +87,9 @@ class SingboxRunner: self._PID_FILE.unlink(missing_ok=True) return False except PermissionError: - # Proceso vivo pero de root — PermissionError es señal de vida return True def get_last_error(self) -> str: - """ - Lee las últimas 80 líneas del log via sudo para evitar - problemas de permisos (el log lo escribe root via wrapper). - """ try: result = subprocess.run( ['sudo', self._WRAPPER, '', 'log'], @@ -141,10 +108,6 @@ class SingboxRunner: return '' def write_config(self, config_path: Path, config: dict) -> None: - """ - Escribe el JSON de forma atómica con permisos 0o600. - Write en .tmp + rename — nunca deja un config a medias. - """ self._assert_config_safe(config_path) config_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) @@ -158,44 +121,82 @@ class SingboxRunner: tmp.unlink(missing_ok=True) raise - # ── Privado ─────────────────────────────────────────────────────────────── - def _ensure_log_file(self) -> None: - """ - Crea el log file con permisos del usuario actual si no existe. - Debe llamarse ANTES de que el wrapper lo tome como root. - """ self._LOG_FILE.parent.mkdir(parents=True, exist_ok=True) if not self._LOG_FILE.exists(): self._LOG_FILE.touch(mode=0o600) def _assert_config_safe(self, config_path: Path) -> None: - """ - Verifica que el config está dentro del directorio permitido. - Lanza ValueError si no — nunca continúa con path inseguro. - """ try: config_path.resolve().relative_to(self._CONFIG_DIR.resolve()) except ValueError: raise ValueError( - f'Config fuera del directorio permitido.\n' + f'Config outside the allowed directory.\n' f' Config: {config_path}\n' - f' Permitido: {self._CONFIG_DIR}' + f' Allowed: {self._CONFIG_DIR}' ) def _emergency_kill(self) -> None: - """ - Kill directo por PID file cuando el wrapper no responde. - Último recurso — no usa sudo. - """ if not self._PID_FILE.exists(): return + try: pid = int(self._PID_FILE.read_text().strip()) if pid > 0: - os.kill(pid, 9) - except (ValueError, ProcessLookupError, PermissionError, OSError): + subprocess.run( + ['sudo', 'kill', '-9', str(pid)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, + timeout=5, + ) + except (ValueError, OSError, subprocess.TimeoutExpired): pass finally: self._PID_FILE.unlink(missing_ok=True) + def _kill_orphans(self) -> None: + try: + result = subprocess.run( + ['pgrep', '-x', 'sing-box'], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + return + + pids = [ + int(p) + for p in result.stdout.strip().splitlines() + if p.strip().isdigit() + ] + + if not pids: + return + + subprocess.run( + ['sudo', self._WRAPPER, '', 'stop'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, + timeout=15, + ) + + deadline = time.monotonic() + 4.0 + + while time.monotonic() < deadline: + check = subprocess.run( + ['pgrep', '-x', 'sing-box'], + capture_output=True, + ) + + if check.returncode != 0: + return + + time.sleep(0.3) + + except (OSError, ValueError, subprocess.TimeoutExpired): + pass \ No newline at end of file