diff --git a/.gitignore b/.gitignore index c7d50d7..ad7d41f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .venv __pycache__ dist +docs.md \ No newline at end of file diff --git a/cli/commands/profile.py b/cli/commands/profile.py index 95ddf3e..8dd02d0 100644 --- a/cli/commands/profile.py +++ b/cli/commands/profile.py @@ -127,82 +127,22 @@ def handle(arguments, main_parser): profile = ProfileController.get(arguments.id) if profile is None: main_parser.error('the following argument should be a valid reference: --id/-i') + try: from cli import observers as obs monitor = KillswitchMonitor() timer_state = {'stop': None, 'reconnecting': None, '_retrying': False} obs.get_ui_state()['timer_state'] = timer_state - def _stop_timer(): - if timer_state['stop'] is not None: - timer_state['stop'].set() - timer_state['stop'] = None - - def _do_disable(): - _stop_timer() - ProfileController.disable( - profile, - ignore=ignore, - profile_observer=profile_observer, - connection_observer=connection_observer - ) - - def _on_leak(): - _stop_timer() - print('\n') - print(' ⚠ Leak detected. Network blocked.') - print() - choice = None - while choice not in ('1', '2'): - print(' [1] Disable profile and restore internet') - print(' [2] Retry connection') - try: - choice = input('\n Choose an option: ').strip() - except KeyboardInterrupt: - print() - _do_disable() - return - - if choice == '1': - _do_disable() - elif choice == '2': - try: - obs.get_ui_state()['timer_state']['_retrying'] = True - ProfileController.enable( - profile, ignore=ignore, pristine=False, - asynchronous=True, profile_observer=profile_observer, - application_version_observer=application_version_observer, - connection_observer=connection_observer - ) - monitor.run_blocking() - except KeyboardInterrupt: - 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.get_ui_state()['monitor'] = monitor + monitor.set_on_disconnect(lambda: _do_disable(profile, ignore)) + monitor.set_on_leak(lambda: _on_leak(profile, ignore, monitor, obs)) + monitor.set_on_displaced(lambda: _on_displaced(monitor)) + + signal.signal(signal.SIGUSR1, lambda s, f: _on_displaced(monitor)) + signal.signal(signal.SIGHUP, lambda s, f: (_do_disable(profile, ignore), sys.exit(0))) + try: ProfileController.enable( profile, ignore=ignore, pristine=arguments.pristine, @@ -211,10 +151,10 @@ def handle(arguments, main_parser): connection_observer=connection_observer ) monitor.run_blocking() - _stop_timer() + _stop_timer(obs.get_ui_state()['timer_state']) except KeyboardInterrupt: print() - _do_disable() + _do_disable(profile, ignore) except SingboxNotInstalledException: main_parser.error('sing-box is not installed. Please run the installation script first.') @@ -235,6 +175,70 @@ def handle(arguments, main_parser): main_parser.error('the following argument should be a valid reference: --id/-i') +# ── Enable helpers ──────────────────────────────────────────────────────────── + +def _stop_timer(timer_state: dict) -> None: + if timer_state['stop'] is not None: + timer_state['stop'].set() + timer_state['stop'] = None + + +def _do_disable(profile, ignore) -> None: + from cli import observers as obs + _stop_timer(obs.get_ui_state()['timer_state']) + ProfileController.disable( + profile, + ignore=ignore, + profile_observer=profile_observer, + connection_observer=connection_observer + ) + + +def _on_leak(profile, ignore, monitor, obs) -> None: + _stop_timer(obs.get_ui_state()['timer_state']) + print('\n') + print(' Leak detected. Network blocked.') + print() + choice = None + while choice not in ('1', '2'): + print(' [1] Disable profile and restore internet') + print(' [2] Retry connection') + try: + choice = input('\n Choose an option: ').strip() + except KeyboardInterrupt: + print() + _do_disable(profile, ignore) + return + + if choice == '1': + _do_disable(profile, ignore) + elif choice == '2': + try: + obs.get_ui_state()['timer_state']['_retrying'] = True + ProfileController.enable( + profile, ignore=ignore, pristine=False, + asynchronous=True, profile_observer=profile_observer, + application_version_observer=application_version_observer, + connection_observer=connection_observer + ) + monitor.run_blocking() + except KeyboardInterrupt: + print() + _do_disable(profile, ignore) + + +def _on_displaced(monitor) -> None: + from cli import observers as obs + _stop_timer(obs.get_ui_state()['timer_state']) + monitor.stop() + print('\n') + print(' Connection terminated: taken over by another session.') + print() + sys.exit(0) + + +# ── Subscription error handler ──────────────────────────────────────────────── + def _handle_subscription_error(exception, profile, ignore, arguments, main_parser): if type(exception).__name__ == 'InvalidSubscriptionError': print('The profile\'s subscription appears to be invalid.\n') @@ -305,6 +309,8 @@ def _handle_subscription_error(exception, profile, ignore, arguments, main_parse print('\nInput appears to be invalid. Please try again.\n') +# ── Argument parsers ────────────────────────────────────────────────────────── + def _build_ignore(arguments): ignore = [] if getattr(arguments, 'without_connection_protection', False): diff --git a/cli/killswitch_monitor.py b/cli/killswitch_monitor.py index 2d71c70..bd7daf6 100644 --- a/cli/killswitch_monitor.py +++ b/cli/killswitch_monitor.py @@ -1,143 +1 @@ -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") \ No newline at end of file +from core.utils.encrypted_proxy.killswitch_monitor import KillswitchMonitor, drop_state, tunnel_state \ No newline at end of file diff --git a/cli/ui.py b/cli/ui.py index 64d1199..94a34c6 100644 --- a/cli/ui.py +++ b/cli/ui.py @@ -20,6 +20,8 @@ BBLUE = "\033[1;34m" BCYAN = "\033[1;36m" BWHITE = "\033[1;37m" +_stdout_lock = threading.Lock() + def label_ok(msg: str): print(f" {BGREEN}[ OK ]{RESET} {msg}") @@ -108,10 +110,10 @@ class Spinner: i = 0 while not self._stop_event.is_set(): frame = self.FRAMES[i % len(self.FRAMES)] - sys.stdout.write( - f"\r {BCYAN}{frame}{RESET} {DIM}{self.msg}{RESET} " - ) - sys.stdout.flush() + output = f"\r {BCYAN}{frame}{RESET} {DIM}{self.msg}{RESET} " + with _stdout_lock: + sys.stdout.write(output) + sys.stdout.flush() i += 1 time.sleep(0.1) @@ -200,13 +202,15 @@ def session_timer(drop_state: dict | None = None, tunnel_state: dict | None = No else: drop_str = "" - sys.stdout.write( + output = ( f"\r {dot_color}●{RESET} {BWHITE}{status_word}{RESET} " f"{DIM}[{time_str}]{RESET}" f"{drop_str}" f" " ) - sys.stdout.flush() + with _stdout_lock: + sys.stdout.write(output) + sys.stdout.flush() time.sleep(1) sys.stdout.write("\r" + " " * 70 + "\r") @@ -224,10 +228,10 @@ def connecting_spinner() -> threading.Event: i = 0 while not stop_event.is_set(): frame = frames[i % len(frames)] - sys.stdout.write( - f"\r {BYELLOW}{frame}{RESET} {DIM}Reconnecting...{RESET} " - ) - sys.stdout.flush() + output = f"\r {BYELLOW}{frame}{RESET} {DIM}Reconnecting...{RESET} " + with _stdout_lock: + sys.stdout.write(output) + sys.stdout.flush() i += 1 time.sleep(0.12) sys.stdout.write("\r" + " " * 60 + "\r")