import subprocess import threading import time from core.utils.encrypted_proxy import killswitch drop_state: dict = {"count": 0} tunnel_state: dict = {"alive": True} CAUSE_DISCONNECT = 'disconnect' CAUSE_LEAK = 'leak' CAUSE_DISPLACED = 'displaced' class KillswitchMonitor: POLL_INTERVAL = 5 LOG_PREFIX = "hydraveil-drop" def __init__(self): self._on_disconnect = None self._on_leak = None self._on_displaced = None self._socks5_port = None self._session_token = None self._running = False self._stop_event = threading.Event() self._cause_event = threading.Event() self._cause: str | None = None self._last_poll_ts: str | None = None 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 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") def stop(self): self._running = False self._stop_event.set() def _trigger(self, cause: str): self._cause = cause self._running = False self._cause_event.set() self._stop_event.set() def _monitor_loop(self): while self._running: interrupted = self._stop_event.wait(timeout=self.POLL_INTERVAL) if interrupted or not self._running: break if not killswitch.status(): tunnel_state["alive"] = False self._trigger(CAUSE_DISCONNECT) return try: result = subprocess.run( ['pgrep', '-x', 'sing-box'], capture_output=True, timeout=3, ) singbox_alive = result.returncode == 0 except (OSError, subprocess.TimeoutExpired): singbox_alive = True tunnel_state["alive"] = singbox_alive if not singbox_alive: self._trigger(CAUSE_LEAK) return 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 run_blocking(self): 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) cause = self._cause if cause == CAUSE_DISCONNECT: if self._on_disconnect: self._on_disconnect() elif cause == CAUSE_LEAK: if self._on_leak: self._on_leak() elif cause == CAUSE_DISPLACED: if self._on_displaced: self._on_displaced()