TunnelMonitor with is_tunnel_alive(), add WireGuardMonitor, arm/disarm killswitch on WireGuard connect/disconnect

This commit is contained in:
Zenaku 2026-07-15 17:05:14 -05:00
parent a4bbfa4ec5
commit 9c5636eb8c
8 changed files with 159 additions and 17 deletions

View file

@ -441,6 +441,11 @@ class ConnectionController:
if completed_successfully or not ConnectionController.system_uses_wireguard_interface(): if completed_successfully or not ConnectionController.system_uses_wireguard_interface():
subprocess.run(('nmcli', 'connection', 'delete', 'hv-ipv6-sink'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.run(('nmcli', 'connection', 'delete', 'hv-ipv6-sink'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
ConnectionController.terminate_tor_connection() ConnectionController.terminate_tor_connection()
try:
from core.utils.encrypted_proxy import killswitch
killswitch.disarm()
except Exception:
pass
SystemState.dissolve() SystemState.dissolve()
if connection_observer is not None: if connection_observer is not None:
connection_observer.notify('disconnected', {}) connection_observer.notify('disconnected', {})
@ -549,6 +554,15 @@ class ConnectionController:
except CalledProcessError: except CalledProcessError:
raise ConnectionError('The connection could not be established.') raise ConnectionError('The connection could not be established.')
# Arm killswitch for WireGuard
try:
wg_server_ip = ConnectionController.__extract_wireguard_endpoint(profile)
if wg_server_ip:
from core.utils.encrypted_proxy import killswitch
killswitch.arm(wg_server_ip, 'wg')
except Exception:
pass
token = SystemStateController.create(profile.id) token = SystemStateController.create(profile.id)
if connection_observer is not None: if connection_observer is not None:
connection_observer.notify('connected_token', {'session_token': token}) connection_observer.notify('connected_token', {'session_token': token})
@ -559,6 +573,23 @@ class ConnectionController:
except ConnectionError: except ConnectionError:
raise ConnectionError('The connection could not be established.') raise ConnectionError('The connection could not be established.')
@staticmethod
def __extract_wireguard_endpoint(profile):
import re, socket
try:
config = profile.get_wireguard_configuration()
if config is None:
return None
match = re.search(r'Endpoint\s*=\s*([^\s:]+):\d+', config)
if not match:
return None
host = match.group(1)
if re.match(r'^(\d{1,3}\.){3}\d{1,3}$', host):
return host
return socket.gethostbyname(host)
except Exception:
return None
@staticmethod @staticmethod
def __with_tor_connection(*args, task: Callable[..., Any], connection_observer: Optional[ConnectionObserver] = None, **kwargs): def __with_tor_connection(*args, task: Callable[..., Any], connection_observer: Optional[ConnectionObserver] = None, **kwargs):

View file

@ -104,7 +104,7 @@ def enable_hysteria(username: str, password: str, server_host: str,
observer.notify("error", f"{Constants.SINGBOX_TUN_IF} did not appear after 15s") observer.notify("error", f"{Constants.SINGBOX_TUN_IF} did not appear after 15s")
return False return False
if not killswitch.arm(server_ip, Constants.SINGBOX_TUN_IF): if not killswitch.arm(server_ip, Constants.SINGBOX_TUN_IF, Constants.SINGBOX_INTERNAL_SUBNET):
if observer: if observer:
observer.notify("error", "Failed to arm kill switch") observer.notify("error", "Failed to arm kill switch")
return False return False

View file

@ -134,7 +134,7 @@ def enable_vless(vless_link: str, username: str, server_ip: str,
observer.notify("error", f"{Constants.SINGBOX_TUN_IF} did not appear after 15s") observer.notify("error", f"{Constants.SINGBOX_TUN_IF} did not appear after 15s")
return False return False
if not killswitch.arm(server_ip, Constants.SINGBOX_TUN_IF): if not killswitch.arm(server_ip, Constants.SINGBOX_TUN_IF, Constants.SINGBOX_INTERNAL_SUBNET):
if observer: if observer:
observer.notify("error", "Failed to arm kill switch") observer.notify("error", "Failed to arm kill switch")
return False return False

View file

@ -4,12 +4,11 @@ from core.Constants import Constants
_C = Constants() _C = Constants()
def arm(server_ip: str, tunnel_if: str) -> bool: def arm(server_ip: str, tunnel_if: str, internal_subnet: str = None) -> bool:
result = subprocess.run( cmd = ["sudo", _C.KILLSWITCH_WRAPPER, "arm", server_ip, tunnel_if]
["sudo", _C.KILLSWITCH_WRAPPER, "arm", server_ip, tunnel_if], if internal_subnet:
capture_output=True, cmd.append(internal_subnet)
text=True, result = subprocess.run(cmd, capture_output=True, text=True)
)
if result.returncode != 0: if result.returncode != 0:
print(f"[killswitch] Failed to arm: {result.stderr.strip()}") print(f"[killswitch] Failed to arm: {result.stderr.strip()}")
return False return False

View file

@ -10,12 +10,13 @@ tunnel_state: dict = {"alive": True}
class KillswitchMonitor(SingboxProcessMonitor): class KillswitchMonitor(SingboxProcessMonitor):
"""CLI-specific monitor: adds killswitch + drops detection.""" """CLI-specific monitor for sing-box (vless/hysteria2): checks sing-box process health."""
LOG_PREFIX = "hydraveil-drop" LOG_PREFIX = "hydraveil-drop"
def __init__(self): def __init__(self):
super().__init__(SingboxRunner()) super().__init__()
self._runner = SingboxRunner()
self._on_disconnect = None self._on_disconnect = None
self._on_leak = None self._on_leak = None
self._on_displaced = None self._on_displaced = None
@ -43,6 +44,9 @@ class KillswitchMonitor(SingboxProcessMonitor):
# ── Abstract impl ───────────────────────────────────────────────────────── # ── Abstract impl ─────────────────────────────────────────────────────────
def is_tunnel_alive(self) -> bool:
return self._runner.is_running()
def on_process_alive(self) -> None: def on_process_alive(self) -> None:
tunnel_state["alive"] = True tunnel_state["alive"] = True

View file

@ -270,12 +270,11 @@ class SingboxProcessMonitor(ABC):
threading model (threading.Event for CLI, Qt signals for GUI). threading model (threading.Event for CLI, Qt signals for GUI).
""" """
POLL_INTERVAL = 5 POLL_INTERVAL = 10
def __init__(self, runner: SingboxRunner): def __init__(self):
self._runner = runner self._lock = threading.Lock()
self._lock = threading.Lock() self._running = False
self._running = False
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._last_poll_ts = None self._last_poll_ts = None
@ -284,6 +283,11 @@ class SingboxProcessMonitor(ABC):
self._running = False self._running = False
self._stop_event.set() self._stop_event.set()
@abstractmethod
def is_tunnel_alive(self) -> bool:
"""Return True if the tunnel is healthy."""
pass
@abstractmethod @abstractmethod
def on_process_alive(self) -> None: def on_process_alive(self) -> None:
pass pass
@ -306,7 +310,7 @@ class SingboxProcessMonitor(ABC):
if interrupted: if interrupted:
break break
alive = self._runner.is_running() alive = self.is_tunnel_alive()
if alive: if alive:
self.on_process_alive() self.on_process_alive()
else: else:
@ -327,5 +331,5 @@ class SingboxProcessMonitor(ABC):
self._running = False self._running = False
t.join(timeout=5) t.join(timeout=5)
self._runner.stop() # tunnel teardown handled by subclass
self.on_monitor_stopped("user_requested") self.on_monitor_stopped("user_requested")

View file

View file

@ -0,0 +1,104 @@
import subprocess
import threading
from core.utils.encrypted_proxy.singbox import SingboxProcessMonitor
from core.utils.encrypted_proxy.singbox import CAUSE_DISCONNECT, CAUSE_DISPLACED
class WireGuardMonitor(SingboxProcessMonitor):
"""Monitor for WireGuard system connections.
Checks if the wg interface is alive instead of a sing-box process.
Shared between CLI and GUI.
"""
def __init__(self):
super().__init__()
self._session_token = None
self._on_disconnect = None
self._on_displaced = 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_displaced(self, fn):
self._on_displaced = fn
def set_session_token(self, token: str):
self._session_token = token
# ── Tunnel health check ───────────────────────────────────────────────────
def is_tunnel_alive(self) -> bool:
try:
result = subprocess.run(
['ip', 'link', 'show', 'wg'],
capture_output=True,
timeout=5,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
# ── Abstract impl ─────────────────────────────────────────────────────────
def on_process_alive(self) -> None:
pass
def on_process_dead(self) -> None:
self._trigger(CAUSE_DISCONNECT)
def on_monitor_stopped(self, cause: str) -> None:
if cause == CAUSE_DISCONNECT and self._on_disconnect:
self._on_disconnect()
elif cause == CAUSE_DISPLACED and self._on_displaced:
self._on_displaced()
# ── Monitor loop override ─────────────────────────────────────────────────
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
if not self.is_tunnel_alive():
self._trigger(CAUSE_DISCONNECT)
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:
self._trigger(CAUSE_DISPLACED)
return
def _trigger(self, cause: str):
self._cause = cause
with self._lock:
self._running = False
self._cause_event.set()
self._stop_event.set()
# ── run_blocking override ─────────────────────────────────────────────────
def run_blocking(self) -> None:
with self._lock:
self._running = True
self._stop_event.clear()
self._cause_event.clear()
self._cause = None
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")