From 309ac7bd027c649b99a416625a31adee8c220fc8 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 16 Jun 2026 08:10:19 -0500 Subject: [PATCH] June Major Update --- cli/commands/profile.py | 22 +++++++ cli/killswitch_monitor.py | 132 +++++++++++++++++++++++++++++++------- cli/observers.py | 26 ++++++-- cli/ui.py | 46 +++++++++++-- 4 files changed, 190 insertions(+), 36 deletions(-) diff --git a/cli/commands/profile.py b/cli/commands/profile.py index 7eac9bc..ce28398 100644 --- a/cli/commands/profile.py +++ b/cli/commands/profile.py @@ -14,6 +14,8 @@ from cli.observers import profile_observer, application_version_observer, connec from core.Errors import SingboxNotInstalledException from cli.killswitch_monitor import KillswitchMonitor import pprint +import signal +import sys NAME = 'profile' @@ -165,8 +167,28 @@ def handle(arguments, main_parser): print() _do_disable() + def _on_displaced(): + _stop_timer() + monitor.stop() + print('\n') + print(' ⚡ Connection terminated: taken over by another session.') + print() + sys.exit(0) + + def _sigusr1_handler(signum, frame): + _on_displaced() + + signal.signal(signal.SIGUSR1, _sigusr1_handler) + + def _sighup_handler(signum, frame): + _do_disable() + sys.exit(0) + + signal.signal(signal.SIGHUP, _sighup_handler) + monitor.set_on_disconnect(_do_disable) monitor.set_on_leak(_on_leak) + monitor.set_on_displaced(_on_displaced) obs._monitor = monitor try: diff --git a/cli/killswitch_monitor.py b/cli/killswitch_monitor.py index 2351535..079daf3 100644 --- a/cli/killswitch_monitor.py +++ b/cli/killswitch_monitor.py @@ -1,20 +1,32 @@ -import time +import subprocess import threading +import time from core.utils.encrypted_proxy import killswitch -from core.utils.encrypted_proxy.net import get_proxied_ip + +drop_state: dict = {"count": 0} +tunnel_state: dict = {"alive": True} + +CAUSE_DISCONNECT = 'disconnect' +CAUSE_LEAK = 'leak' +CAUSE_DISPLACED = 'displaced' class KillswitchMonitor: - POLL_INTERVAL = 10 + POLL_INTERVAL = 5 + LOG_PREFIX = "hydraveil-drop" def __init__(self): - self._on_disconnect = None - self._on_leak = None - self._socks5_port = None - self._real_ip = None - self._running = False - self._stop_event = threading.Event() + 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 @@ -22,33 +34,105 @@ class KillswitchMonitor: def set_on_leak(self, fn): self._on_leak = fn - def set_connection_data(self, socks5_port, real_ip): + def set_on_displaced(self, fn): + self._on_displaced = fn + + def set_connection_data(self, socks5_port: int): self._socks5_port = socks5_port - self._real_ip = real_ip + + 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 run_blocking(self): - self._running = True - self._stop_event.clear() + 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(): - if self._on_disconnect: - self._on_disconnect() - self._running = False + tunnel_state["alive"] = False + self._trigger(CAUSE_DISCONNECT) return - if self._socks5_port and self._real_ip: - proxied = get_proxied_ip(self._socks5_port, timeout=10) - if proxied == "unknown" or proxied == self._real_ip: - if self._on_leak: - self._on_leak() - self._running = False - return \ No newline at end of file + 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() \ No newline at end of file diff --git a/cli/observers.py b/cli/observers.py index fdc8734..1c41366 100644 --- a/cli/observers.py +++ b/cli/observers.py @@ -1,4 +1,3 @@ -from core.utils.encrypted_proxy.net import get_proxied_ip from core.utils.encrypted_proxy import killswitch from core.controllers.ApplicationController import ApplicationController from core.observers.ApplicationVersionObserver import ApplicationVersionObserver @@ -6,8 +5,9 @@ from core.observers.ClientObserver import ClientObserver from core.observers.ConnectionObserver import ConnectionObserver from core.observers.InvoiceObserver import InvoiceObserver from core.observers.ProfileObserver import ProfileObserver -from cli.ui import Spinner, label_ok, label_error, label_warn, label_connected, label_disconnect, progress_bar, label_killswitch +from cli.ui import Spinner, label_ok, label_error, label_warn, label_connected, label_disconnect, progress_bar, label_killswitch, label_dns from cli.helpers import sanitize_profile +from cli.killswitch_monitor import drop_state import pprint application_version_observer = ApplicationVersionObserver() @@ -20,6 +20,7 @@ _spinner = None _monitor = None _timer_state = None + application_version_observer.subscribe('downloading', lambda event: print(f'Downloading {ApplicationController.get(event.subject.application_code).name}, version {event.subject.version_number}...')) application_version_observer.subscribe('download_progressing', lambda event: print(f'Current progress: {event.meta.get("progress"):.2f}%', flush=True, end='\r')) application_version_observer.subscribe('downloaded', lambda event: print('\n')) @@ -55,16 +56,28 @@ def _on_connected(event): if _timer_state: _timer_state['_retrying'] = False d = event.subject or {} - label_connected(f"proxy={d.get('proxy_ip','?')} real={d.get('real_ip','?')} port={d.get('socks5_port','?')}") + label_connected(f"proxy={d.get('proxy_ip','?')} tunnel={d.get('tunnel_if','?')} port={d.get('socks5_port','?')}") label_killswitch(killswitch.status()) + label_dns( + enabled=d.get('dns_enabled', True), + active=d.get('dns_active', False), + ) if _monitor is not None: _monitor.set_connection_data( socks5_port=d.get('socks5_port'), - real_ip=d.get('real_ip') ) if _timer_state is not None: from cli.ui import session_timer - _timer_state['stop'] = session_timer() + from cli.killswitch_monitor import tunnel_state + _timer_state['stop'] = session_timer(drop_state=drop_state, tunnel_state=tunnel_state) + + +def _on_connected_token(event): + global _monitor + if _monitor is not None: + token = (event.subject or {}).get('session_token') + if token: + _monitor.set_session_token(token) def _on_disconnected(event): @@ -79,7 +92,7 @@ def _on_disconnected(event): _monitor.stop() _monitor = None d = event.subject or {} - label_disconnect(f"real={d.get('ip','?')}") + label_disconnect(f"tunnel={d.get('tunnel_if','?')}") def _on_error(event): @@ -118,6 +131,7 @@ def _on_tor_bootstrapped(event): connection_observer.subscribe('connecting', _on_connecting) connection_observer.subscribe('connected', _on_connected) +connection_observer.subscribe('connected_token', _on_connected_token) connection_observer.subscribe('disconnected', _on_disconnected) connection_observer.subscribe('error', _on_error) connection_observer.subscribe('tor_bootstrapping', _on_tor_bootstrapping) diff --git a/cli/ui.py b/cli/ui.py index a1052be..9d6354d 100644 --- a/cli/ui.py +++ b/cli/ui.py @@ -123,7 +123,6 @@ class Spinner: def wait_with_spinner(msg: str, fn, *args, result_msg: str = "", **kwargs): - result_box = [None, None] def _run(): @@ -142,14 +141,28 @@ def wait_with_spinner(msg: str, fn, *args, result_msg: str = "", **kwargs): if result_box[1] is not None: raise result_box[1] return result_box[0] + + +def label_dns(enabled: bool, active: bool): + if not enabled: + print(f" {BYELLOW}[ DNS ]{RESET} DNS management {BYELLOW}disabled{RESET} — using system resolver") + elif active: + print(f" {BGREEN}[ DNS ]{RESET} DNS over {BGREEN}tun0{RESET} active {DIM}(9.9.9.9){RESET}") + else: + print(f" {BRED}[ DNS ]{RESET} DNS on tun0 {BRED}FAILED{RESET} — possible DNS leak") + + def label_killswitch(armed: bool): if armed: print(f" {BGREEN}[ KS ]{RESET} Kill switch active") else: print(f" {BRED}[ KS ]{RESET} Kill switch {BRED}INACTIVE — connection unprotected{RESET}") -def session_timer() -> threading.Event: + + +def session_timer(drop_state: dict | None = None, tunnel_state: dict | None = None) -> threading.Event: + stop_event = threading.Event() - start = time.time() + start = time.time() def _run(): while not stop_event.is_set(): @@ -157,17 +170,38 @@ def session_timer() -> threading.Event: h = elapsed // 3600 m = (elapsed % 3600) // 60 s = elapsed % 60 + time_str = f"{h:02d}:{m:02d}:{s:02d}" + + alive = True if tunnel_state is None else tunnel_state.get("alive", True) + dot_color = BGREEN if alive else BRED + status_word = "Protected" if alive else "Disconnected" + + if drop_state is not None: + count = drop_state["count"] + drop_color = BRED if count > 0 else DIM + drop_str = ( + f" {DIM}│{RESET} " + f"{drop_color}drops: {count}{RESET}" + ) + else: + drop_str = "" + sys.stdout.write( - f"\r {BGREEN}●{RESET} {BWHITE}Protected{RESET} " - f"{DIM}[{h:02d}:{m:02d}:{s:02d}]{RESET} " + f"\r {dot_color}●{RESET} {BWHITE}{status_word}{RESET} " + f"{DIM}[{time_str}]{RESET}" + f"{drop_str}" + f" " ) sys.stdout.flush() time.sleep(1) - sys.stdout.write("\r" + " " * 50 + "\r") + + sys.stdout.write("\r" + " " * 70 + "\r") sys.stdout.flush() threading.Thread(target=_run, daemon=True).start() return stop_event + + def connecting_spinner() -> threading.Event: stop_event = threading.Event() frames = ("─", "\\", "│", "/")