June Major Update

This commit is contained in:
Zenaku 2026-06-16 08:10:19 -05:00
parent abe350294d
commit 309ac7bd02
4 changed files with 190 additions and 36 deletions

View file

@ -14,6 +14,8 @@ from cli.observers import profile_observer, application_version_observer, connec
from core.Errors import SingboxNotInstalledException from core.Errors import SingboxNotInstalledException
from cli.killswitch_monitor import KillswitchMonitor from cli.killswitch_monitor import KillswitchMonitor
import pprint import pprint
import signal
import sys
NAME = 'profile' NAME = 'profile'
@ -165,8 +167,28 @@ def handle(arguments, main_parser):
print() print()
_do_disable() _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_disconnect(_do_disable)
monitor.set_on_leak(_on_leak) monitor.set_on_leak(_on_leak)
monitor.set_on_displaced(_on_displaced)
obs._monitor = monitor obs._monitor = monitor
try: try:

View file

@ -1,20 +1,32 @@
import time import subprocess
import threading import threading
import time
from core.utils.encrypted_proxy import killswitch from core.utils.encrypted_proxy import killswitch
from core.utils.encrypted_proxy.net import get_proxied_ip
drop_state: dict = {"count": 0}
tunnel_state: dict = {"alive": True}
CAUSE_DISCONNECT = 'disconnect'
CAUSE_LEAK = 'leak'
CAUSE_DISPLACED = 'displaced'
class KillswitchMonitor: class KillswitchMonitor:
POLL_INTERVAL = 10 POLL_INTERVAL = 5
LOG_PREFIX = "hydraveil-drop"
def __init__(self): def __init__(self):
self._on_disconnect = None self._on_disconnect = None
self._on_leak = None self._on_leak = None
self._socks5_port = None self._on_displaced = None
self._real_ip = None self._socks5_port = None
self._running = False self._session_token = None
self._stop_event = threading.Event() self._running = False
self._stop_event = threading.Event()
self._cause_event = threading.Event()
self._cause: str | None = None
self._last_poll_ts: str | None = None
def set_on_disconnect(self, fn): def set_on_disconnect(self, fn):
self._on_disconnect = fn self._on_disconnect = fn
@ -22,33 +34,105 @@ class KillswitchMonitor:
def set_on_leak(self, fn): def set_on_leak(self, fn):
self._on_leak = fn self._on_leak = fn
def set_connection_data(self, socks5_port, real_ip): def set_on_displaced(self, fn):
self._on_displaced = fn
def set_connection_data(self, socks5_port: int):
self._socks5_port = socks5_port self._socks5_port = socks5_port
self._real_ip = real_ip
def set_session_token(self, token: str):
self._session_token = token
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")
def stop(self): def stop(self):
self._running = False self._running = False
self._stop_event.set() self._stop_event.set()
def run_blocking(self): def _trigger(self, cause: str):
self._running = True self._cause = cause
self._stop_event.clear() self._running = False
self._cause_event.set()
self._stop_event.set()
def _monitor_loop(self):
while self._running: while self._running:
interrupted = self._stop_event.wait(timeout=self.POLL_INTERVAL) interrupted = self._stop_event.wait(timeout=self.POLL_INTERVAL)
if interrupted or not self._running: if interrupted or not self._running:
break break
if not killswitch.status(): if not killswitch.status():
if self._on_disconnect: tunnel_state["alive"] = False
self._on_disconnect() self._trigger(CAUSE_DISCONNECT)
self._running = False
return return
if self._socks5_port and self._real_ip: try:
proxied = get_proxied_ip(self._socks5_port, timeout=10) result = subprocess.run(
if proxied == "unknown" or proxied == self._real_ip: ['pgrep', '-x', 'sing-box'],
if self._on_leak: capture_output=True,
self._on_leak() timeout=3,
self._running = False )
singbox_alive = result.returncode == 0
except (OSError, subprocess.TimeoutExpired):
singbox_alive = True
tunnel_state["alive"] = singbox_alive
if not singbox_alive:
self._trigger(CAUSE_LEAK)
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:
tunnel_state["alive"] = False
self._trigger(CAUSE_DISPLACED)
return return
self._poll_drops()
def run_blocking(self):
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)
cause = self._cause
if cause == CAUSE_DISCONNECT:
if self._on_disconnect:
self._on_disconnect()
elif cause == CAUSE_LEAK:
if self._on_leak:
self._on_leak()
elif cause == CAUSE_DISPLACED:
if self._on_displaced:
self._on_displaced()

View file

@ -1,4 +1,3 @@
from core.utils.encrypted_proxy.net import get_proxied_ip
from core.utils.encrypted_proxy import killswitch from core.utils.encrypted_proxy import killswitch
from core.controllers.ApplicationController import ApplicationController from core.controllers.ApplicationController import ApplicationController
from core.observers.ApplicationVersionObserver import ApplicationVersionObserver from core.observers.ApplicationVersionObserver import ApplicationVersionObserver
@ -6,8 +5,9 @@ from core.observers.ClientObserver import ClientObserver
from core.observers.ConnectionObserver import ConnectionObserver from core.observers.ConnectionObserver import ConnectionObserver
from core.observers.InvoiceObserver import InvoiceObserver from core.observers.InvoiceObserver import InvoiceObserver
from core.observers.ProfileObserver import ProfileObserver from core.observers.ProfileObserver import ProfileObserver
from cli.ui import Spinner, label_ok, label_error, label_warn, label_connected, label_disconnect, progress_bar, label_killswitch from cli.ui import Spinner, label_ok, label_error, label_warn, label_connected, label_disconnect, progress_bar, label_killswitch, label_dns
from cli.helpers import sanitize_profile from cli.helpers import sanitize_profile
from cli.killswitch_monitor import drop_state
import pprint import pprint
application_version_observer = ApplicationVersionObserver() application_version_observer = ApplicationVersionObserver()
@ -20,6 +20,7 @@ _spinner = None
_monitor = None _monitor = None
_timer_state = None _timer_state = None
application_version_observer.subscribe('downloading', lambda event: print(f'Downloading {ApplicationController.get(event.subject.application_code).name}, version {event.subject.version_number}...')) application_version_observer.subscribe('downloading', lambda event: print(f'Downloading {ApplicationController.get(event.subject.application_code).name}, version {event.subject.version_number}...'))
application_version_observer.subscribe('download_progressing', lambda event: print(f'Current progress: {event.meta.get("progress"):.2f}%', flush=True, end='\r')) application_version_observer.subscribe('download_progressing', lambda event: print(f'Current progress: {event.meta.get("progress"):.2f}%', flush=True, end='\r'))
application_version_observer.subscribe('downloaded', lambda event: print('\n')) application_version_observer.subscribe('downloaded', lambda event: print('\n'))
@ -55,16 +56,28 @@ def _on_connected(event):
if _timer_state: if _timer_state:
_timer_state['_retrying'] = False _timer_state['_retrying'] = False
d = event.subject or {} d = event.subject or {}
label_connected(f"proxy={d.get('proxy_ip','?')} real={d.get('real_ip','?')} port={d.get('socks5_port','?')}") label_connected(f"proxy={d.get('proxy_ip','?')} tunnel={d.get('tunnel_if','?')} port={d.get('socks5_port','?')}")
label_killswitch(killswitch.status()) label_killswitch(killswitch.status())
label_dns(
enabled=d.get('dns_enabled', True),
active=d.get('dns_active', False),
)
if _monitor is not None: if _monitor is not None:
_monitor.set_connection_data( _monitor.set_connection_data(
socks5_port=d.get('socks5_port'), socks5_port=d.get('socks5_port'),
real_ip=d.get('real_ip')
) )
if _timer_state is not None: if _timer_state is not None:
from cli.ui import session_timer from cli.ui import session_timer
_timer_state['stop'] = session_timer() from cli.killswitch_monitor import tunnel_state
_timer_state['stop'] = session_timer(drop_state=drop_state, tunnel_state=tunnel_state)
def _on_connected_token(event):
global _monitor
if _monitor is not None:
token = (event.subject or {}).get('session_token')
if token:
_monitor.set_session_token(token)
def _on_disconnected(event): def _on_disconnected(event):
@ -79,7 +92,7 @@ def _on_disconnected(event):
_monitor.stop() _monitor.stop()
_monitor = None _monitor = None
d = event.subject or {} d = event.subject or {}
label_disconnect(f"real={d.get('ip','?')}") label_disconnect(f"tunnel={d.get('tunnel_if','?')}")
def _on_error(event): def _on_error(event):
@ -118,6 +131,7 @@ def _on_tor_bootstrapped(event):
connection_observer.subscribe('connecting', _on_connecting) connection_observer.subscribe('connecting', _on_connecting)
connection_observer.subscribe('connected', _on_connected) connection_observer.subscribe('connected', _on_connected)
connection_observer.subscribe('connected_token', _on_connected_token)
connection_observer.subscribe('disconnected', _on_disconnected) connection_observer.subscribe('disconnected', _on_disconnected)
connection_observer.subscribe('error', _on_error) connection_observer.subscribe('error', _on_error)
connection_observer.subscribe('tor_bootstrapping', _on_tor_bootstrapping) connection_observer.subscribe('tor_bootstrapping', _on_tor_bootstrapping)

View file

@ -123,7 +123,6 @@ class Spinner:
def wait_with_spinner(msg: str, fn, *args, result_msg: str = "", **kwargs): def wait_with_spinner(msg: str, fn, *args, result_msg: str = "", **kwargs):
result_box = [None, None] result_box = [None, None]
def _run(): def _run():
@ -142,14 +141,28 @@ def wait_with_spinner(msg: str, fn, *args, result_msg: str = "", **kwargs):
if result_box[1] is not None: if result_box[1] is not None:
raise result_box[1] raise result_box[1]
return result_box[0] return result_box[0]
def label_dns(enabled: bool, active: bool):
if not enabled:
print(f" {BYELLOW}[ DNS ]{RESET} DNS management {BYELLOW}disabled{RESET} — using system resolver")
elif active:
print(f" {BGREEN}[ DNS ]{RESET} DNS over {BGREEN}tun0{RESET} active {DIM}(9.9.9.9){RESET}")
else:
print(f" {BRED}[ DNS ]{RESET} DNS on tun0 {BRED}FAILED{RESET} — possible DNS leak")
def label_killswitch(armed: bool): def label_killswitch(armed: bool):
if armed: if armed:
print(f" {BGREEN}[ KS ]{RESET} Kill switch active") print(f" {BGREEN}[ KS ]{RESET} Kill switch active")
else: else:
print(f" {BRED}[ KS ]{RESET} Kill switch {BRED}INACTIVE — connection unprotected{RESET}") print(f" {BRED}[ KS ]{RESET} Kill switch {BRED}INACTIVE — connection unprotected{RESET}")
def session_timer() -> threading.Event:
def session_timer(drop_state: dict | None = None, tunnel_state: dict | None = None) -> threading.Event:
stop_event = threading.Event() stop_event = threading.Event()
start = time.time() start = time.time()
def _run(): def _run():
while not stop_event.is_set(): while not stop_event.is_set():
@ -157,17 +170,38 @@ def session_timer() -> threading.Event:
h = elapsed // 3600 h = elapsed // 3600
m = (elapsed % 3600) // 60 m = (elapsed % 3600) // 60
s = elapsed % 60 s = elapsed % 60
time_str = f"{h:02d}:{m:02d}:{s:02d}"
alive = True if tunnel_state is None else tunnel_state.get("alive", True)
dot_color = BGREEN if alive else BRED
status_word = "Protected" if alive else "Disconnected"
if drop_state is not None:
count = drop_state["count"]
drop_color = BRED if count > 0 else DIM
drop_str = (
f" {DIM}{RESET} "
f"{drop_color}drops: {count}{RESET}"
)
else:
drop_str = ""
sys.stdout.write( sys.stdout.write(
f"\r {BGREEN}{RESET} {BWHITE}Protected{RESET} " f"\r {dot_color}{RESET} {BWHITE}{status_word}{RESET} "
f"{DIM}[{h:02d}:{m:02d}:{s:02d}]{RESET} " f"{DIM}[{time_str}]{RESET}"
f"{drop_str}"
f" "
) )
sys.stdout.flush() sys.stdout.flush()
time.sleep(1) time.sleep(1)
sys.stdout.write("\r" + " " * 50 + "\r")
sys.stdout.write("\r" + " " * 70 + "\r")
sys.stdout.flush() sys.stdout.flush()
threading.Thread(target=_run, daemon=True).start() threading.Thread(target=_run, daemon=True).start()
return stop_event return stop_event
def connecting_spinner() -> threading.Event: def connecting_spinner() -> threading.Event:
stop_event = threading.Event() stop_event = threading.Event()
frames = ("", "\\", "", "/") frames = ("", "\\", "", "/")