refactor: align cli with core architecture
This commit is contained in:
parent
5ed7598315
commit
1a60511492
4 changed files with 176 additions and 8 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ prototype_client.py
|
||||||
__pycache__
|
__pycache__
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
docs.md
|
||||||
|
|
@ -81,6 +81,7 @@ def create_event_handlers(state: Dict[str, Any], drop_state: Dict, tunnel_state:
|
||||||
"""Factory: returns event handler closures bound to state."""
|
"""Factory: returns event handler closures bound to state."""
|
||||||
|
|
||||||
def _update_connection_data(event) -> ConnectionStateData:
|
def _update_connection_data(event) -> ConnectionStateData:
|
||||||
|
"""Extract connection fields from event subject into a ConnectionStateData."""
|
||||||
d = event.subject or {}
|
d = event.subject or {}
|
||||||
return ConnectionStateData(
|
return ConnectionStateData(
|
||||||
tunnel_interface=d.get('tunnel_if', "?"),
|
tunnel_interface=d.get('tunnel_if', "?"),
|
||||||
|
|
@ -91,6 +92,7 @@ def create_event_handlers(state: Dict[str, Any], drop_state: Dict, tunnel_state:
|
||||||
)
|
)
|
||||||
|
|
||||||
def _update_ui_labels(conn: ConnectionStateData) -> None:
|
def _update_ui_labels(conn: ConnectionStateData) -> None:
|
||||||
|
"""Refresh all CLI status labels from current connection data."""
|
||||||
from cli.ui import (
|
from cli.ui import (
|
||||||
label_connected, label_killswitch, label_dns, label_ipv6, label_ipv4
|
label_connected, label_killswitch, label_dns, label_ipv6, label_ipv4
|
||||||
)
|
)
|
||||||
|
|
@ -101,7 +103,13 @@ def create_event_handlers(state: Dict[str, Any], drop_state: Dict, tunnel_state:
|
||||||
if conn.server_ip:
|
if conn.server_ip:
|
||||||
label_ipv4(server_ip=conn.server_ip, reachable=True)
|
label_ipv4(server_ip=conn.server_ip, reachable=True)
|
||||||
|
|
||||||
|
def _update_monitor_port(event_data: Dict) -> None:
|
||||||
|
"""Update monitor with new SOCKS5 port."""
|
||||||
|
if state['monitor'] is not None:
|
||||||
|
state['monitor'].set_connection_data(socks5_port=event_data.get('socks5_port'))
|
||||||
|
|
||||||
def on_connecting(event) -> None:
|
def on_connecting(event) -> None:
|
||||||
|
"""Handle connecting event: show attempt spinner."""
|
||||||
try:
|
try:
|
||||||
cleanup_timer(state)
|
cleanup_timer(state)
|
||||||
if (state['timer_state'] and
|
if (state['timer_state'] and
|
||||||
|
|
@ -122,6 +130,7 @@ def create_event_handlers(state: Dict[str, Any], drop_state: Dict, tunnel_state:
|
||||||
logger.error(f"Error in on_connecting: {e}", exc_info=True)
|
logger.error(f"Error in on_connecting: {e}", exc_info=True)
|
||||||
|
|
||||||
def on_connected(event) -> None:
|
def on_connected(event) -> None:
|
||||||
|
"""Handle connected event: update labels, monitor port, and start session timer."""
|
||||||
try:
|
try:
|
||||||
cleanup_spinner(state)
|
cleanup_spinner(state)
|
||||||
cleanup_reconnecting_spinner(state)
|
cleanup_reconnecting_spinner(state)
|
||||||
|
|
@ -132,9 +141,8 @@ def create_event_handlers(state: Dict[str, Any], drop_state: Dict, tunnel_state:
|
||||||
state['connection_data'] = _update_connection_data(event)
|
state['connection_data'] = _update_connection_data(event)
|
||||||
_update_ui_labels(state['connection_data'])
|
_update_ui_labels(state['connection_data'])
|
||||||
|
|
||||||
d = event.subject or {}
|
event_data = event.subject or {}
|
||||||
if state['monitor'] is not None:
|
_update_monitor_port(event_data)
|
||||||
state['monitor'].set_connection_data(socks5_port=d.get('socks5_port'))
|
|
||||||
|
|
||||||
if state['timer_state'] is not None:
|
if state['timer_state'] is not None:
|
||||||
from cli.ui import session_timer
|
from cli.ui import session_timer
|
||||||
|
|
@ -146,6 +154,7 @@ def create_event_handlers(state: Dict[str, Any], drop_state: Dict, tunnel_state:
|
||||||
logger.error(f"Error in on_connected: {e}", exc_info=True)
|
logger.error(f"Error in on_connected: {e}", exc_info=True)
|
||||||
|
|
||||||
def on_connected_token(event) -> None:
|
def on_connected_token(event) -> None:
|
||||||
|
"""Handle session token event: pass token to monitor."""
|
||||||
try:
|
try:
|
||||||
if state['monitor'] is not None:
|
if state['monitor'] is not None:
|
||||||
token = (event.subject or {}).get('session_token')
|
token = (event.subject or {}).get('session_token')
|
||||||
|
|
@ -156,6 +165,7 @@ def create_event_handlers(state: Dict[str, Any], drop_state: Dict, tunnel_state:
|
||||||
logger.error(f"Error in on_connected_token: {e}", exc_info=True)
|
logger.error(f"Error in on_connected_token: {e}", exc_info=True)
|
||||||
|
|
||||||
def on_disconnected(event) -> None:
|
def on_disconnected(event) -> None:
|
||||||
|
"""Handle disconnected event: cleanup UI and show disconnect label."""
|
||||||
try:
|
try:
|
||||||
cleanup_all(state)
|
cleanup_all(state)
|
||||||
from cli.ui import label_disconnect
|
from cli.ui import label_disconnect
|
||||||
|
|
@ -165,6 +175,7 @@ def create_event_handlers(state: Dict[str, Any], drop_state: Dict, tunnel_state:
|
||||||
logger.error(f"Error in on_disconnected: {e}", exc_info=True)
|
logger.error(f"Error in on_disconnected: {e}", exc_info=True)
|
||||||
|
|
||||||
def on_error(event) -> None:
|
def on_error(event) -> None:
|
||||||
|
"""Handle error event: cleanup UI and show error label."""
|
||||||
try:
|
try:
|
||||||
cleanup_all(state)
|
cleanup_all(state)
|
||||||
from cli.ui import label_error
|
from cli.ui import label_error
|
||||||
|
|
@ -177,9 +188,9 @@ def create_event_handlers(state: Dict[str, Any], drop_state: Dict, tunnel_state:
|
||||||
logger.error(f"Error in on_error: {e}", exc_info=True)
|
logger.error(f"Error in on_error: {e}", exc_info=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'on_connecting': on_connecting,
|
'on_connecting': on_connecting,
|
||||||
'on_connected': on_connected,
|
'on_connected': on_connected,
|
||||||
'on_connected_token': on_connected_token,
|
'on_connected_token': on_connected_token,
|
||||||
'on_disconnected': on_disconnected,
|
'on_disconnected': on_disconnected,
|
||||||
'on_error': on_error,
|
'on_error': on_error,
|
||||||
}
|
}
|
||||||
143
core/utils/encrypted_proxy/killswitch_monitor.py
Normal file
143
core/utils/encrypted_proxy/killswitch_monitor.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
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")
|
||||||
|
|
@ -148,18 +148,31 @@ class SingboxRunner:
|
||||||
# ── Kill helpers ──────────────────────────────────────────────────────────
|
# ── Kill helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _kill_orphans(self) -> None:
|
def _kill_orphans(self) -> None:
|
||||||
|
"""Kill any lingering sing-box processes using escalating force."""
|
||||||
|
|
||||||
|
# Phase 1: Check if anything is actually running
|
||||||
if not self._has_running_processes():
|
if not self._has_running_processes():
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug("Found running sing-box processes, attempting graceful stop")
|
logger.debug("Found running sing-box processes, attempting graceful stop")
|
||||||
|
|
||||||
|
# Phase 2: Graceful stop attempt via wrapper
|
||||||
self._attempt_graceful_stop()
|
self._attempt_graceful_stop()
|
||||||
|
|
||||||
|
# Phase 3: Wait for graceful shutdown to complete
|
||||||
if self._wait_for_process_exit(self._ORPHAN_GRACEFUL_TIMEOUT_S):
|
if self._wait_for_process_exit(self._ORPHAN_GRACEFUL_TIMEOUT_S):
|
||||||
logger.info("sing-box stopped gracefully")
|
logger.info("sing-box stopped gracefully")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Phase 4: Graceful stop failed — force kill remaining processes
|
||||||
logger.warning("Graceful stop timeout, force killing remaining sing-box processes")
|
logger.warning("Graceful stop timeout, force killing remaining sing-box processes")
|
||||||
self._force_kill_all_processes()
|
self._force_kill_all_processes()
|
||||||
|
|
||||||
|
# Phase 5: Verify all processes are actually dead
|
||||||
if self._wait_for_process_exit(self._ORPHAN_FINAL_VERIFY_TIMEOUT_S):
|
if self._wait_for_process_exit(self._ORPHAN_FINAL_VERIFY_TIMEOUT_S):
|
||||||
logger.info("All sing-box processes terminated")
|
logger.info("All sing-box processes terminated")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.error("Failed to terminate all sing-box processes after force kill")
|
logger.error("Failed to terminate all sing-box processes after force kill")
|
||||||
|
|
||||||
def _has_running_processes(self) -> bool:
|
def _has_running_processes(self) -> bool:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue