refactor: align cli with core architecture

This commit is contained in:
Zenaku 2026-07-08 11:15:43 -05:00
parent 4b0e6c1a3f
commit 282527125d
4 changed files with 92 additions and 223 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
.venv .venv
__pycache__ __pycache__
dist dist
docs.md

View file

@ -127,82 +127,22 @@ def handle(arguments, main_parser):
profile = ProfileController.get(arguments.id) profile = ProfileController.get(arguments.id)
if profile is None: if profile is None:
main_parser.error('the following argument should be a valid reference: --id/-i') main_parser.error('the following argument should be a valid reference: --id/-i')
try: try:
from cli import observers as obs from cli import observers as obs
monitor = KillswitchMonitor() monitor = KillswitchMonitor()
timer_state = {'stop': None, 'reconnecting': None, '_retrying': False} timer_state = {'stop': None, 'reconnecting': None, '_retrying': False}
obs.get_ui_state()['timer_state'] = timer_state 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 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: try:
ProfileController.enable( ProfileController.enable(
profile, ignore=ignore, pristine=arguments.pristine, profile, ignore=ignore, pristine=arguments.pristine,
@ -211,10 +151,10 @@ def handle(arguments, main_parser):
connection_observer=connection_observer connection_observer=connection_observer
) )
monitor.run_blocking() monitor.run_blocking()
_stop_timer() _stop_timer(obs.get_ui_state()['timer_state'])
except KeyboardInterrupt: except KeyboardInterrupt:
print() print()
_do_disable() _do_disable(profile, ignore)
except SingboxNotInstalledException: except SingboxNotInstalledException:
main_parser.error('sing-box is not installed. Please run the installation script first.') 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') 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): def _handle_subscription_error(exception, profile, ignore, arguments, main_parser):
if type(exception).__name__ == 'InvalidSubscriptionError': if type(exception).__name__ == 'InvalidSubscriptionError':
print('The profile\'s subscription appears to be invalid.\n') 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') print('\nInput appears to be invalid. Please try again.\n')
# ── Argument parsers ──────────────────────────────────────────────────────────
def _build_ignore(arguments): def _build_ignore(arguments):
ignore = [] ignore = []
if getattr(arguments, 'without_connection_protection', False): if getattr(arguments, 'without_connection_protection', False):

View file

@ -1,143 +1 @@
import subprocess from core.utils.encrypted_proxy.killswitch_monitor import KillswitchMonitor, drop_state, tunnel_state
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")

View file

@ -20,6 +20,8 @@ BBLUE = "\033[1;34m"
BCYAN = "\033[1;36m" BCYAN = "\033[1;36m"
BWHITE = "\033[1;37m" BWHITE = "\033[1;37m"
_stdout_lock = threading.Lock()
def label_ok(msg: str): def label_ok(msg: str):
print(f" {BGREEN}[ OK ]{RESET} {msg}") print(f" {BGREEN}[ OK ]{RESET} {msg}")
@ -108,10 +110,10 @@ class Spinner:
i = 0 i = 0
while not self._stop_event.is_set(): while not self._stop_event.is_set():
frame = self.FRAMES[i % len(self.FRAMES)] frame = self.FRAMES[i % len(self.FRAMES)]
sys.stdout.write( output = f"\r {BCYAN}{frame}{RESET} {DIM}{self.msg}{RESET} "
f"\r {BCYAN}{frame}{RESET} {DIM}{self.msg}{RESET} " with _stdout_lock:
) sys.stdout.write(output)
sys.stdout.flush() sys.stdout.flush()
i += 1 i += 1
time.sleep(0.1) time.sleep(0.1)
@ -200,13 +202,15 @@ def session_timer(drop_state: dict | None = None, tunnel_state: dict | None = No
else: else:
drop_str = "" drop_str = ""
sys.stdout.write( output = (
f"\r {dot_color}{RESET} {BWHITE}{status_word}{RESET} " f"\r {dot_color}{RESET} {BWHITE}{status_word}{RESET} "
f"{DIM}[{time_str}]{RESET}" f"{DIM}[{time_str}]{RESET}"
f"{drop_str}" f"{drop_str}"
f" " f" "
) )
sys.stdout.flush() with _stdout_lock:
sys.stdout.write(output)
sys.stdout.flush()
time.sleep(1) time.sleep(1)
sys.stdout.write("\r" + " " * 70 + "\r") sys.stdout.write("\r" + " " * 70 + "\r")
@ -224,10 +228,10 @@ def connecting_spinner() -> threading.Event:
i = 0 i = 0
while not stop_event.is_set(): while not stop_event.is_set():
frame = frames[i % len(frames)] frame = frames[i % len(frames)]
sys.stdout.write( output = f"\r {BYELLOW}{frame}{RESET} {DIM}Reconnecting...{RESET} "
f"\r {BYELLOW}{frame}{RESET} {DIM}Reconnecting...{RESET} " with _stdout_lock:
) sys.stdout.write(output)
sys.stdout.flush() sys.stdout.flush()
i += 1 i += 1
time.sleep(0.12) time.sleep(0.12)
sys.stdout.write("\r" + " " * 60 + "\r") sys.stdout.write("\r" + " " * 60 + "\r")