refactor: align cli with core architecture
This commit is contained in:
parent
4b0e6c1a3f
commit
282527125d
4 changed files with 92 additions and 223 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@
|
||||||
.venv
|
.venv
|
||||||
__pycache__
|
__pycache__
|
||||||
dist
|
dist
|
||||||
|
docs.md
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
24
cli/ui.py
24
cli/ui.py
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue