import subprocess import threading import time from core.utils.encrypted_proxy import killswitch from core.utils.encrypted_proxy.singbox import SingboxRunner, SingboxProcessMonitor from core.utils.encrypted_proxy.singbox import CAUSE_DISCONNECT, CAUSE_LEAK, CAUSE_DISPLACED drop_state: dict = {"count": 0} tunnel_state: dict = {"alive": True} class KillswitchMonitor(SingboxProcessMonitor): """CLI-specific monitor: adds killswitch + drops detection.""" LOG_PREFIX = "hydraveil-drop" def __init__(self): super().__init__(SingboxRunner()) self._on_disconnect = None self._on_leak = None self._on_displaced = None self._socks5_port = None self._session_token = None self._cause_event = threading.Event() self._cause: str | None = None # ── Setters ─────────────────────────────────────────────────────────────── def set_on_disconnect(self, fn): self._on_disconnect = fn def set_on_leak(self, fn): self._on_leak = fn def set_on_displaced(self, fn): self._on_displaced = fn def set_connection_data(self, socks5_port: int): self._socks5_port = socks5_port def set_session_token(self, token: str): self._session_token = token # ── Abstract impl ───────────────────────────────────────────────────────── def on_process_alive(self) -> None: tunnel_state["alive"] = True def on_process_dead(self) -> None: tunnel_state["alive"] = False def on_monitor_stopped(self, cause: str) -> None: if cause == CAUSE_DISCONNECT and self._on_disconnect: self._on_disconnect() elif cause == CAUSE_LEAK and self._on_leak: self._on_leak() elif cause == CAUSE_DISPLACED and self._on_displaced: self._on_displaced() # ── Override monitor loop to add CLI-specific checks ────────────────────── def _monitor_loop(self) -> None: while True: with self._lock: if not self._running: break interrupted = self._stop_event.wait(timeout=self.POLL_INTERVAL) if interrupted: break # Killswitch check if not killswitch.status(): tunnel_state["alive"] = False self._trigger(CAUSE_DISCONNECT) return # Sing-box check alive = self._runner.is_running() tunnel_state["alive"] = alive if not alive: self._trigger(CAUSE_LEAK) return # Session token check if self._session_token is not None: from core.models.system.SystemState import SystemState current = SystemState.get() if current is None or current.session_token != self._session_token: tunnel_state["alive"] = False self._trigger(CAUSE_DISPLACED) return self._poll_drops() def _trigger(self, cause: str): self._cause = cause with self._lock: self._running = False self._cause_event.set() self._stop_event.set() def _poll_drops(self) -> None: since = self._last_poll_ts or "10 seconds ago" try: result = subprocess.run( [ "journalctl", "-k", "--no-pager", f"--grep={self.LOG_PREFIX}", "--since", since, "-o", "short-iso", ], capture_output=True, text=True, timeout=5, ) new_drops = result.stdout.count(self.LOG_PREFIX) if new_drops: drop_state["count"] += new_drops except (subprocess.TimeoutExpired, FileNotFoundError): pass finally: self._last_poll_ts = time.strftime("%Y-%m-%dT%H:%M:%S") # ── Override run_blocking to handle cause_event ─────────────────────────── def run_blocking(self) -> None: with self._lock: self._running = True self._stop_event.clear() self._cause_event.clear() self._cause = None drop_state["count"] = 0 tunnel_state["alive"] = True self._last_poll_ts = time.strftime("%Y-%m-%dT%H:%M:%S") t = threading.Thread(target=self._monitor_loop, daemon=True) t.start() self._cause_event.wait() t.join(timeout=2) self.on_monitor_stopped(self._cause or "user_requested")