diff --git a/README.md b/README.md index 4727c54..48e9c52 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,19 @@ sudo docker compose exec laravel php artisan tinker --execute=" \$sub->save(); echo 'OK' . PHP_EOL; " + + +docker compose exec laravel php artisan tinker --execute=" +\$sub = new \App\Models\Subscription(); +\$sub->subscription_plan_id = 3; +\$sub->operator_id = 3; +\$sub->billing_code = 'TEST-' . strtoupper(substr(uniqid(), -4)) . '-' . strtoupper(substr(uniqid(), -4)) . '-' . strtoupper(substr(uniqid(), -4)); +\$sub->payment_reference = 'bypass_' . uniqid(); +\$sub->duration = 720; +\$sub->expires_at = '2027-12-31 23:59:59'; +\$sub->save(); +echo \$sub->billing_code . PHP_EOL; +" ``` Then enable again: diff --git a/cli/__main__.py b/cli/__main__.py index 4a40ccd..57282e7 100644 --- a/cli/__main__.py +++ b/cli/__main__.py @@ -35,6 +35,18 @@ if __name__ == '__main__': ]: Path(path).mkdir(parents=True, exist_ok=True, mode=0o700) + # Bootstrap DB if needed + try: + from core.models.manage.session_management import create_ALL_tables, create_ONLY_db_version_table + from core.models.manage.version_check import insert_new_version, get_database_version + from core.models.manage.session_management import get_session + create_ONLY_db_version_table() + if get_database_version(get_session()) is None: + create_ALL_tables() + insert_new_version(get_session(), Constants.DB_VERSION_THIS_APP_WANTS) + except Exception: + pass + sys.excepthook = _handle_exception distribution = get_distribution() diff --git a/cli/commands/profile.py b/cli/commands/profile.py index ce28398..95ddf3e 100644 --- a/cli/commands/profile.py +++ b/cli/commands/profile.py @@ -79,8 +79,21 @@ def handle(arguments, main_parser): elif arguments.subcommand == 'create': if arguments.profile_type == 'operator': - connection = SystemConnection('operator', arguments.operator_id, arguments.protocol) - profile = SystemProfile(arguments.id, arguments.name, None, None, connection) + from core.models.manage.session_management import get_session + from core.models.orm_models.Location import Location + from sqlalchemy import select + session = get_session() + capable_field = Location.is_vless_capable if arguments.protocol == 'vless' else Location.is_hysteria2_capable + loc = session.execute( + select(Location).where( + (Location.operator_id == arguments.operator_id) & + (capable_field == True) + ) + ).scalar_one_or_none() + if loc is None: + main_parser.error(f'No location found for operator {arguments.operator_id} with protocol {arguments.protocol}') + connection = SystemConnection(arguments.protocol, arguments.operator_id) + profile = SystemProfile(arguments.id, arguments.name, None, loc, connection) ProfileController.create(profile, profile_observer=profile_observer) return @@ -119,8 +132,7 @@ def handle(arguments, main_parser): monitor = KillswitchMonitor() timer_state = {'stop': None, 'reconnecting': None, '_retrying': False} - obs._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() @@ -155,7 +167,7 @@ def handle(arguments, main_parser): _do_disable() elif choice == '2': try: - obs._timer_state['_retrying'] = True + obs.get_ui_state()['timer_state']['_retrying'] = True ProfileController.enable( profile, ignore=ignore, pristine=False, asynchronous=True, profile_observer=profile_observer, @@ -189,7 +201,7 @@ def handle(arguments, main_parser): monitor.set_on_disconnect(_do_disable) monitor.set_on_leak(_on_leak) monitor.set_on_displaced(_on_displaced) - obs._monitor = monitor + obs.get_ui_state()['monitor'] = monitor try: ProfileController.enable( diff --git a/cli/killswitch_monitor.py b/cli/killswitch_monitor.py index b3db515..2d71c70 100644 --- a/cli/killswitch_monitor.py +++ b/cli/killswitch_monitor.py @@ -2,32 +2,29 @@ import subprocess import threading import time from core.utils.encrypted_proxy import killswitch -from core.utils.encrypted_proxy.singbox import SingboxRunner +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} +drop_state: dict = {"count": 0} tunnel_state: dict = {"alive": True} -CAUSE_DISCONNECT = 'disconnect' -CAUSE_LEAK = 'leak' -CAUSE_DISPLACED = 'displaced' +class KillswitchMonitor(SingboxProcessMonitor): + """CLI-specific monitor: adds killswitch + drops detection.""" -class KillswitchMonitor: - - POLL_INTERVAL = 5 - LOG_PREFIX = "hydraveil-drop" + LOG_PREFIX = "hydraveil-drop" def __init__(self): - self._on_disconnect = None - self._on_leak = None - self._on_displaced = None - self._socks5_port = None - self._session_token = None - self._running = False - self._stop_event = threading.Event() - self._cause_event = threading.Event() + 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 - self._last_poll_ts: str | None = None + + # ── Setters ─────────────────────────────────────────────────────────────── def set_on_disconnect(self, fn): self._on_disconnect = fn @@ -44,6 +41,65 @@ class KillswitchMonitor: 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: @@ -66,65 +122,22 @@ class KillswitchMonitor: finally: self._last_poll_ts = time.strftime("%Y-%m-%dT%H:%M:%S") - def stop(self): - self._running = False - self._stop_event.set() + # ── Override run_blocking to handle cause_event ─────────────────────────── - def _trigger(self, cause: str): - self._cause = cause - self._running = False - self._cause_event.set() - self._stop_event.set() + def run_blocking(self) -> None: + with self._lock: + self._running = True - def _monitor_loop(self): - while self._running: - interrupted = self._stop_event.wait(timeout=self.POLL_INTERVAL) - if interrupted or not self._running: - break - - if not killswitch.status(): - tunnel_state["alive"] = False - self._trigger(CAUSE_DISCONNECT) - return - - singbox_alive = SingboxRunner().is_running() - 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 - - 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") + 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() \ No newline at end of file + self.on_monitor_stopped(self._cause or "user_requested") \ No newline at end of file diff --git a/cli/observers.py b/cli/observers.py index ec7a907..8c4353b 100644 --- a/cli/observers.py +++ b/cli/observers.py @@ -1,158 +1,94 @@ -from core.utils.encrypted_proxy import killswitch -from core.controllers.ApplicationController import ApplicationController from core.observers.ApplicationVersionObserver import ApplicationVersionObserver from core.observers.ClientObserver import ClientObserver from core.observers.ConnectionObserver import ConnectionObserver from core.observers.InvoiceObserver import InvoiceObserver from core.observers.ProfileObserver import ProfileObserver -from cli.ui import ( - Spinner, label_ok, label_error, label_warn, - label_connected, label_disconnect, label_killswitch, - label_dns, label_ipv6, label_ipv4, progress_bar, -) -from cli.helpers import sanitize_profile +from core.services.encrypted_proxy.observer_helpers import create_ui_state, create_event_handlers +from core.controllers.ApplicationController import ApplicationController from cli.killswitch_monitor import drop_state, tunnel_state +from cli.ui import progress_bar +from cli.helpers import sanitize_profile import pprint +# ── Observer instances ──────────────────────────────────────────────────────── + application_version_observer = ApplicationVersionObserver() client_observer = ClientObserver() connection_observer = ConnectionObserver() invoice_observer = InvoiceObserver() profile_observer = ProfileObserver() -_spinner = None -_monitor = None -_timer_state = None +# ── Shared UI state (used by observer_helpers) ──────────────────────────────── +_ui_state = create_ui_state() -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('downloaded', lambda event: print('\n')) +# Public refs for profile.py to access monitor and timer +def get_ui_state(): + return _ui_state -client_observer.subscribe('synchronizing', lambda event: print('Synchronizing...\n')) -client_observer.subscribe('updating', lambda event: print('Updating client...')) -client_observer.subscribe('update_progressing', lambda event: print(f'Current progress: {event.meta.get("progress"):.2f}%', flush=True, end='\r')) -client_observer.subscribe('updated', lambda event: print('\n')) +# ── Connection event handlers (from core) ───────────────────────────────────── +_handlers = create_event_handlers(_ui_state, drop_state, tunnel_state) -def _on_connecting(event): - global _spinner, _timer_state - if _timer_state and _timer_state['stop']: - _timer_state['stop'].set() - _timer_state['stop'] = None - if _timer_state and _timer_state['reconnecting'] is None and _timer_state.get('_retrying'): - from cli.ui import connecting_spinner - _timer_state['reconnecting'] = connecting_spinner() - attempt = event.subject.get("attempt_count", "?") - total = event.subject.get("maximum_number_of_attempts", "?") - _spinner = Spinner(f"Connecting... attempt {attempt}/{total}") - _spinner.start() - - -def _on_connected(event): - global _spinner, _monitor, _timer_state - if _spinner: - _spinner.stop() - _spinner = None - if _timer_state and _timer_state.get('reconnecting'): - _timer_state['reconnecting'].set() - _timer_state['reconnecting'] = None - if _timer_state: - _timer_state['_retrying'] = False - - d = event.subject or {} - tunnel_if = d.get('tunnel_if', '?') - server_ip = d.get('server_ip') - socks5_port = d.get('socks5_port', '?') - - label_connected(f"tunnel={tunnel_if} port={socks5_port}") - label_killswitch(killswitch.status()) - label_dns( - enabled=d.get('dns_enabled', True), - active=d.get('dns_active', False), - ) - label_ipv6(blocked=True) - if server_ip: - label_ipv4(server_ip=server_ip, reachable=True) - - if _monitor is not None: - _monitor.set_connection_data(socks5_port=d.get('socks5_port')) - - if _timer_state is not None: - from cli.ui import session_timer - _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): - global _spinner, _monitor, _timer_state - if _spinner: - _spinner.stop() - _spinner = None - if _timer_state and _timer_state['stop']: - _timer_state['stop'].set() - _timer_state['stop'] = None - if _monitor is not None: - _monitor.stop() - _monitor = None - d = event.subject or {} - label_disconnect(f"tunnel={d.get('tunnel_if','?')}") - - -def _on_error(event): - global _spinner, _monitor, _timer_state - if _spinner: - _spinner.stop() - _spinner = None - if _timer_state and _timer_state['stop']: - _timer_state['stop'].set() - _timer_state['stop'] = None - if _monitor is not None: - _monitor.stop() - _monitor = None - label_error(str(event.subject or "Unknown error")) +connection_observer.subscribe('connecting', _handlers['on_connecting']) +connection_observer.subscribe('connected', _handlers['on_connected']) +connection_observer.subscribe('connected_token', _handlers['on_connected_token']) +connection_observer.subscribe('disconnected', _handlers['on_disconnected']) +connection_observer.subscribe('error', _handlers['on_error']) +# ── Tor events ──────────────────────────────────────────────────────────────── def _on_tor_bootstrapping(event): - global _spinner - _spinner = Spinner("Starting Tor...") - _spinner.start() - + from cli.ui import Spinner + _ui_state['spinner'] = Spinner("Starting Tor...") + _ui_state['spinner'].start() def _on_tor_bootstrap_progressing(event): pct = int(event.meta.get("progress", 0)) progress_bar(pct, 100, "Tor bootstrap") - def _on_tor_bootstrapped(event): - global _spinner - if _spinner: - _spinner.stop() - _spinner = None + from cli.ui import label_ok + if _ui_state['spinner']: + _ui_state['spinner'].stop() + _ui_state['spinner'] = None print() label_ok("Tor ready") - -connection_observer.subscribe('connecting', _on_connecting) -connection_observer.subscribe('connected', _on_connected) -connection_observer.subscribe('connected_token', _on_connected_token) -connection_observer.subscribe('disconnected', _on_disconnected) -connection_observer.subscribe('error', _on_error) connection_observer.subscribe('tor_bootstrapping', _on_tor_bootstrapping) connection_observer.subscribe('tor_bootstrap_progressing', _on_tor_bootstrap_progressing) connection_observer.subscribe('tor_bootstrapped', _on_tor_bootstrapped) +# ── Application version events ──────────────────────────────────────────────── + +application_version_observer.subscribe( + 'downloading', + lambda event: print( + f'Downloading {ApplicationController.get(event.subject.application_code).name}, ' + f'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('downloaded', lambda event: print('\n')) + +# ── Client events ───────────────────────────────────────────────────────────── + +client_observer.subscribe('synchronizing', lambda event: print('Synchronizing...\n')) +client_observer.subscribe('updating', lambda event: print('Updating client...')) +client_observer.subscribe('update_progressing', lambda event: print(f'Current progress: {event.meta.get("progress"):.2f}%', flush=True, end='\r')) +client_observer.subscribe('updated', lambda event: print('\n')) + +# ── Invoice events ──────────────────────────────────────────────────────────── + invoice_observer.subscribe('retrieved', lambda event: print(f'\n{pprint.pp(event.subject)}\n')) invoice_observer.subscribe('processing', lambda event: print('A payment has been detected and is being verified...\n')) invoice_observer.subscribe('settled', lambda event: print('The payment has been successfully verified.\n')) +# ── Profile events ──────────────────────────────────────────────────────────── + profile_observer.subscribe('created', lambda event: pprint.pp((sanitize_profile(event.subject), 'Created'))) profile_observer.subscribe('destroyed', lambda event: pprint.pp((sanitize_profile(event.subject), 'Destroyed'))) profile_observer.subscribe('disabled', lambda event: pprint.pp((sanitize_profile(event.subject), 'Disabled')) if event.meta.get('explicitly') else None)