diff --git a/cli/commands/profile.py b/cli/commands/profile.py index b6fcf89..e5a11d3 100644 --- a/cli/commands/profile.py +++ b/cli/commands/profile.py @@ -1,4 +1,4 @@ -from core.Errors import MissingSubscriptionError, InvalidSubscriptionError, ConnectionUnprotectedError, EndpointVerificationError, ProfileStateConflictError +from core.Errors import MissingSubscriptionError, InvalidSubscriptionError, ConnectionUnprotectedError, EndpointVerificationError, ProfileStateConflictError, PaymentRequiredError from core.controllers.ApplicationVersionController import ApplicationVersionController from core.controllers.InvoiceController import InvoiceController from core.controllers.LocationController import LocationController @@ -12,6 +12,7 @@ from core.models.system.SystemProfile import SystemProfile from cli.helpers import sanitize_profile, parse_application_argument, parse_location_argument from cli.observers import profile_observer, application_version_observer, connection_observer, invoice_observer from core.Errors import SingboxNotInstalledException +from cli.killswitch_monitor import KillswitchMonitor import pprint NAME = 'profile' @@ -112,11 +113,82 @@ def handle(arguments, main_parser): if profile is None: main_parser.error('the following argument should be a valid reference: --id/-i') try: - ProfileController.enable(profile, ignore=ignore, pristine=arguments.pristine, asynchronous=True, profile_observer=profile_observer, application_version_observer=application_version_observer, connection_observer=connection_observer) - except SingboxNotInstalledException as exception: + from cli import observers as obs + + monitor = KillswitchMonitor() + timer_state = {'stop': None, 'reconnecting': None, '_retrying': False} + obs._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._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() + + monitor.set_on_disconnect(_do_disable) + monitor.set_on_leak(_on_leak) + obs._monitor = monitor + + try: + ProfileController.enable( + profile, ignore=ignore, pristine=arguments.pristine, + asynchronous=True, profile_observer=profile_observer, + application_version_observer=application_version_observer, + connection_observer=connection_observer + ) + monitor.run_blocking() + _stop_timer() + except KeyboardInterrupt: + print() + _do_disable() + + except SingboxNotInstalledException: main_parser.error('sing-box is not installed. Please run the installation script first.') + except PaymentRequiredError: + print('Payment processing is currently unavailable. The subscription could not be activated.\nPlease try again later or contact support.') except (InvalidSubscriptionError, MissingSubscriptionError) as exception: _handle_subscription_error(exception, profile, ignore, arguments, main_parser) + elif arguments.subcommand == 'disable': profile = ProfileController.get(arguments.id) if profile is not None: @@ -124,6 +196,7 @@ def handle(arguments, main_parser): else: main_parser.error('the following argument should be a valid reference: --id/-i') + def _handle_subscription_error(exception, profile, ignore, arguments, main_parser): if type(exception).__name__ == 'InvalidSubscriptionError': print('The profile\'s subscription appears to be invalid.\n') @@ -150,17 +223,21 @@ def _handle_subscription_error(exception, profile, ignore, arguments, main_parse potential_subscription = SubscriptionController.create(subscription_plan, profile, connection_observer=connection_observer) if potential_subscription is None: raise RuntimeError('The subscription could not be created. Please try again later.') - + ProfileController.attach_subscription(profile, potential_subscription) - - # Operator no necesita invoice + if potential_subscription.operator_id is None: subscription = InvoiceController.handle_payment(potential_subscription.billing_code, invoice_observer=invoice_observer, connection_observer=connection_observer) if subscription is None: - raise RuntimeError('The subscription could not be activated. Please try again later.') + print('\nPayment processing is currently unavailable. Please try again later or contact support.') + return ProfileController.attach_subscription(profile, subscription) - - ProfileController.enable(profile, ignore=ignore, pristine=arguments.pristine, asynchronous=True, profile_observer=profile_observer, application_version_observer=application_version_observer, connection_observer=connection_observer) + + try: + ProfileController.enable(profile, ignore=ignore, pristine=arguments.pristine, asynchronous=True, profile_observer=profile_observer, application_version_observer=application_version_observer, connection_observer=connection_observer) + except PaymentRequiredError: + print('\nPayment processing is currently unavailable. The subscription could not be activated.\nPlease try again later or contact support.') + return elif manage_subscription_input == '2': billing_code = input('\nEnter your billing code: ') @@ -168,7 +245,11 @@ def _handle_subscription_error(exception, profile, ignore, arguments, main_parse subscription = SubscriptionController.get(billing_code, connection_observer=connection_observer) if subscription is not None: ProfileController.attach_subscription(profile, subscription) - ProfileController.enable(profile, ignore=ignore, pristine=arguments.pristine, asynchronous=True, profile_observer=profile_observer, application_version_observer=application_version_observer, connection_observer=connection_observer) + try: + ProfileController.enable(profile, ignore=ignore, pristine=arguments.pristine, asynchronous=True, profile_observer=profile_observer, application_version_observer=application_version_observer, connection_observer=connection_observer) + except PaymentRequiredError: + print('\nPayment processing is currently unavailable. The subscription could not be activated.\nPlease try again later or contact support.') + return else: print('\nThe billing code appears to be invalid.\n') manage_subscription_input = None @@ -178,6 +259,7 @@ def _handle_subscription_error(exception, profile, ignore, arguments, main_parse else: print('\nInput appears to be invalid. Please try again.\n') + def _build_ignore(arguments): ignore = [] if getattr(arguments, 'without_connection_protection', False): diff --git a/cli/killswitch_monitor.py b/cli/killswitch_monitor.py new file mode 100644 index 0000000..2351535 --- /dev/null +++ b/cli/killswitch_monitor.py @@ -0,0 +1,54 @@ +import time +import threading +from core.utils.encrypted_proxy import killswitch +from core.utils.encrypted_proxy.net import get_proxied_ip + + +class KillswitchMonitor: + + POLL_INTERVAL = 10 + + def __init__(self): + self._on_disconnect = None + self._on_leak = None + self._socks5_port = None + self._real_ip = None + self._running = False + self._stop_event = threading.Event() + + def set_on_disconnect(self, fn): + self._on_disconnect = fn + + def set_on_leak(self, fn): + self._on_leak = fn + + def set_connection_data(self, socks5_port, real_ip): + self._socks5_port = socks5_port + self._real_ip = real_ip + + def stop(self): + self._running = False + self._stop_event.set() + + def run_blocking(self): + self._running = True + self._stop_event.clear() + + while self._running: + interrupted = self._stop_event.wait(timeout=self.POLL_INTERVAL) + if interrupted or not self._running: + break + + if not killswitch.status(): + if self._on_disconnect: + self._on_disconnect() + self._running = False + return + + if self._socks5_port and self._real_ip: + proxied = get_proxied_ip(self._socks5_port, timeout=10) + if proxied == "unknown" or proxied == self._real_ip: + if self._on_leak: + self._on_leak() + self._running = False + return \ No newline at end of file diff --git a/cli/observers.py b/cli/observers.py index 983990a..fdc8734 100644 --- a/cli/observers.py +++ b/cli/observers.py @@ -1,10 +1,12 @@ +from core.utils.encrypted_proxy.net import get_proxied_ip +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, progress_bar +from cli.ui import Spinner, label_ok, label_error, label_warn, label_connected, label_disconnect, progress_bar, label_killswitch from cli.helpers import sanitize_profile import pprint @@ -14,6 +16,10 @@ connection_observer = ConnectionObserver() invoice_observer = InvoiceObserver() profile_observer = ProfileObserver() +_spinner = None +_monitor = 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('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')) @@ -23,48 +29,84 @@ 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')) -_spinner = None def _on_connecting(event): - global _spinner + 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}/{total}]") + _spinner = Spinner(f"[net] verify_ip attempt {attempt}/{total}...") _spinner.start() + def _on_connected(event): - global _spinner + 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 {} label_connected(f"proxy={d.get('proxy_ip','?')} real={d.get('real_ip','?')} port={d.get('socks5_port','?')}") + label_killswitch(killswitch.status()) + if _monitor is not None: + _monitor.set_connection_data( + socks5_port=d.get('socks5_port'), + real_ip=d.get('real_ip') + ) + if _timer_state is not None: + from cli.ui import session_timer + _timer_state['stop'] = session_timer() + def _on_disconnected(event): - global _spinner + 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 {} - ip = d.get("ip", "?") - label_disconnect(f"real={ip}") + label_disconnect(f"real={d.get('ip','?')}") + def _on_error(event): - global _spinner + 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")) + def _on_tor_bootstrapping(event): global _spinner _spinner = Spinner("Starting Tor...") _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: @@ -73,6 +115,7 @@ def _on_tor_bootstrapped(event): print() label_ok("Tor ready") + connection_observer.subscribe('connecting', _on_connecting) connection_observer.subscribe('connected', _on_connected) connection_observer.subscribe('disconnected', _on_disconnected) diff --git a/cli/ui.py b/cli/ui.py index fe68a7d..a1052be 100644 --- a/cli/ui.py +++ b/cli/ui.py @@ -141,4 +141,49 @@ def wait_with_spinner(msg: str, fn, *args, result_msg: str = "", **kwargs): if result_box[1] is not None: raise result_box[1] - return result_box[0] \ No newline at end of file + return result_box[0] +def label_killswitch(armed: bool): + if armed: + print(f" {BGREEN}[ KS ]{RESET} Kill switch active") + else: + print(f" {BRED}[ KS ]{RESET} Kill switch {BRED}INACTIVE — connection unprotected{RESET}") +def session_timer() -> threading.Event: + stop_event = threading.Event() + start = time.time() + + def _run(): + while not stop_event.is_set(): + elapsed = int(time.time() - start) + h = elapsed // 3600 + m = (elapsed % 3600) // 60 + s = elapsed % 60 + sys.stdout.write( + f"\r {BGREEN}●{RESET} {BWHITE}Protected{RESET} " + f"{DIM}[{h:02d}:{m:02d}:{s:02d}]{RESET} " + ) + sys.stdout.flush() + time.sleep(1) + sys.stdout.write("\r" + " " * 50 + "\r") + sys.stdout.flush() + + threading.Thread(target=_run, daemon=True).start() + return stop_event +def connecting_spinner() -> threading.Event: + stop_event = threading.Event() + frames = ("─", "\\", "│", "/") + + def _run(): + i = 0 + while not stop_event.is_set(): + frame = frames[i % len(frames)] + sys.stdout.write( + f"\r {BYELLOW}{frame}{RESET} {DIM}Reconnecting...{RESET} " + ) + sys.stdout.flush() + i += 1 + time.sleep(0.12) + sys.stdout.write("\r" + " " * 60 + "\r") + sys.stdout.flush() + + threading.Thread(target=_run, daemon=True).start() + return stop_event \ No newline at end of file