From a3cf476af445c247d4c63ff7c58f9c3af0c0b3e1 Mon Sep 17 00:00:00 2001 From: zenaku Date: Sat, 16 May 2026 00:36:11 -0500 Subject: [PATCH 01/51] core-laravel-proxyTEST --- .env | 6 + core/Constants.py | 32 ++--- core/controllers/ConfigurationController.py | 2 +- core/controllers/ConnectionController.py | 46 +++++- .../encrypted_proxy/DisableController.py | 17 +++ .../encrypted_proxy/HysteriaController.py | 30 ++++ .../encrypted_proxy/VlessController.py | 28 ++++ core/controllers/encrypted_proxy/__init__.py | 0 core/models/BaseConnection.py | 2 + core/models/Operator.py | 12 +- core/models/OperatorProxySession.py | 16 +++ core/models/Subscription.py | 9 +- core/models/session/NetworkPortNumbers.py | 6 +- core/models/session/SessionConnection.py | 10 +- core/models/session/SessionProfile.py | 31 ++++ core/observers/EncryptedProxyObserver.py | 8 ++ core/services/WebServiceApiService.py | 55 ++++++-- core/services/encrypted_proxy/__init__.py | 0 .../encrypted_proxy/disable_service.py | 33 +++++ .../encrypted_proxy/hysteria_service.py | 114 +++++++++++++++ .../services/encrypted_proxy/vless_service.py | 133 ++++++++++++++++++ core/utils/encrypted_proxy/__init__.py | 0 core/utils/encrypted_proxy/net.py | 44 ++++++ core/utils/encrypted_proxy/singbox.py | 35 +++++ 24 files changed, 628 insertions(+), 41 deletions(-) create mode 100644 .env create mode 100644 core/controllers/encrypted_proxy/DisableController.py create mode 100644 core/controllers/encrypted_proxy/HysteriaController.py create mode 100644 core/controllers/encrypted_proxy/VlessController.py create mode 100644 core/controllers/encrypted_proxy/__init__.py create mode 100644 core/models/OperatorProxySession.py create mode 100644 core/observers/EncryptedProxyObserver.py create mode 100644 core/services/encrypted_proxy/__init__.py create mode 100644 core/services/encrypted_proxy/disable_service.py create mode 100644 core/services/encrypted_proxy/hysteria_service.py create mode 100644 core/services/encrypted_proxy/vless_service.py create mode 100644 core/utils/encrypted_proxy/__init__.py create mode 100644 core/utils/encrypted_proxy/net.py create mode 100644 core/utils/encrypted_proxy/singbox.py diff --git a/.env b/.env new file mode 100644 index 0000000..904689b --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +# Environment: local | production +APP_ENV=local + +# API URLs +SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 +SP_API_BASE_URL_PRODUCTION=https://api.hydraveil.net/api/v1 diff --git a/core/Constants.py b/core/Constants.py index 26d2815..f12f6cc 100644 --- a/core/Constants.py +++ b/core/Constants.py @@ -1,57 +1,49 @@ from dataclasses import dataclass from typing import Final +from dotenv import load_dotenv import os +load_dotenv(os.path.join(os.path.dirname(__file__), '../.env')) + +_env = os.environ.get('APP_ENV', 'production') +_sp_api_base_url = ( + os.environ.get('SP_API_BASE_URL_LOCAL', 'http://simplifiedprivacy.test/api/v1') + if _env == 'local' + else os.environ.get('SP_API_BASE_URL_PRODUCTION', 'https://api.hydraveil.net/api/v1') +) @dataclass(frozen=True) class Constants: - # ticketing group: TICKET_API_BASE_URL: Final[str] = os.environ.get( "TICKET_API_BASE_URL", "https://ticket.hydraveil.net" ) - - SP_API_BASE_URL: Final[str] = os.environ.get('SP_API_BASE_URL', 'https://api.hydraveil.net/api/v1') - PING_URL: Final[str] = os.environ.get('PING_URL', 'https://api.hydraveil.net/api/v1/health') - + SP_API_BASE_URL: Final[str] = _sp_api_base_url + PING_URL: Final[str] = os.environ.get('PING_URL', f'{_sp_api_base_url}/health') CONNECTION_RETRY_INTERVAL: Final[int] = int(os.environ.get('CONNECTION_RETRY_INTERVAL', '5')) MAX_CONNECTION_ATTEMPTS: Final[int] = int(os.environ.get('MAX_CONNECTION_ATTEMPTS', '2')) - HV_CLIENT_PATH: Final[str] = os.environ.get('HV_CLIENT_PATH') HV_CLIENT_VERSION_NUMBER: Final[str] = os.environ.get('HV_CLIENT_VERSION_NUMBER') - HOME: Final[str] = os.path.expanduser('~') - SYSTEM_CONFIG_PATH: Final[str] = '/etc' - CACHE_HOME: Final[str] = os.environ.get('XDG_CACHE_HOME', os.path.join(HOME, '.cache')) CONFIG_HOME: Final[str] = os.environ.get('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) DATA_HOME: Final[str] = os.environ.get('XDG_DATA_HOME', os.path.join(HOME, '.local/share')) STATE_HOME: Final[str] = os.environ.get('XDG_STATE_HOME', os.path.join(HOME, '.local/state')) - HV_SYSTEM_CONFIG_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/hydra-veil' - HV_CACHE_HOME: Final[str] = f'{CACHE_HOME}/hydra-veil' HV_CONFIG_HOME: Final[str] = f'{CONFIG_HOME}/hydra-veil' HV_DATA_HOME: Final[str] = f'{DATA_HOME}/hydra-veil' HV_STATE_HOME: Final[str] = f'{STATE_HOME}/hydra-veil' - HV_SYSTEM_PROFILE_CONFIG_PATH: Final[str] = f'{HV_SYSTEM_CONFIG_PATH}/profiles' - HV_PROFILE_CONFIG_HOME: Final[str] = f'{HV_CONFIG_HOME}/profiles' HV_PROFILE_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/profiles' - - # ticketing group: HV_TICKETING_CONFIG_HOME: Final[str] = f"{HV_CONFIG_HOME}/ticketing" HV_TICKETING_DATA_HOME: Final[str] = f"{HV_DATA_HOME}/ticket_data" - HV_APPLICATION_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/applications' HV_INCIDENT_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/incidents' HV_RUNTIME_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/runtime' - HV_STORAGE_DATABASE_PATH: Final[str] = f'{HV_DATA_HOME}/storage.db' - HV_CAPABILITY_POLICY_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/apparmor.d/hydra-veil' HV_PRIVILEGE_POLICY_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/sudoers.d/hydra-veil' - HV_SESSION_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/sessions' - HV_TOR_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/tor' + HV_TOR_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/tor' \ No newline at end of file diff --git a/core/controllers/ConfigurationController.py b/core/controllers/ConfigurationController.py index 3dc0d09..004d043 100644 --- a/core/controllers/ConfigurationController.py +++ b/core/controllers/ConfigurationController.py @@ -25,7 +25,7 @@ class ConfigurationController: configuration = ConfigurationController.get() - if configuration is None or configuration.connection not in ('system', 'tor'): + if configuration is None or configuration.connection not in ('system', 'tor', 'vless', 'hysteria2'): raise UnknownConnectionTypeError('The preferred connection type could not be determined.') return configuration.connection diff --git a/core/controllers/ConnectionController.py b/core/controllers/ConnectionController.py index bc380e4..689cde8 100644 --- a/core/controllers/ConnectionController.py +++ b/core/controllers/ConnectionController.py @@ -23,6 +23,7 @@ import subprocess import sys import tempfile import time +from core.models.OperatorProxySession import OperatorProxySession class ConnectionController: @@ -65,6 +66,32 @@ class ConnectionController: if connection.needs_wireguard_configuration() and not profile.has_wireguard_configuration(): + + + + if connection.needs_operator_proxy(): + + if profile.has_subscription(): + + if not profile.subscription.has_been_activated(): + ProfileController.activate_subscription(profile, connection_observer=connection_observer) + + operator_proxy_session = ConnectionController.with_preferred_connection( + profile.subscription.billing_code, + profile.subscription.operator_id, + connection.get_protocol(), + task=WebServiceApiService.post_operator_proxy, + connection_observer=connection_observer + ) + + if operator_proxy_session is None: + raise InvalidSubscriptionError() + + profile.attach_operator_proxy_session(operator_proxy_session) + + else: + raise MissingSubscriptionError() + if profile.has_subscription(): if not profile.subscription.has_been_activated(): @@ -150,6 +177,24 @@ class ConnectionController: ConnectionController.establish_wireguard_session_connection(profile, session_directory, port_number) session_state.network_port_numbers.wireguard.append(port_number) + elif profile.connection.code in ('vless', 'hysteria2'): + + if not profile.has_operator_proxy_session(): + raise MissingSubscriptionError() + + operator_proxy_session = profile.get_operator_proxy_session() + port_number = ConnectionService.get_random_available_port_number() + + if profile.connection.code == 'vless': + from core.controllers.encrypted_proxy.VlessController import VlessController + VlessController.enable(operator_proxy_session, port_number) + session_state.network_port_numbers.vless.append(port_number) + + elif profile.connection.code == 'hysteria2': + from core.controllers.encrypted_proxy.HysteriaController import HysteriaController + HysteriaController.enable(operator_proxy_session, port_number) + session_state.network_port_numbers.hysteria2.append(port_number) + if profile.connection.masked: while proxy_port_number is None or proxy_port_number == port_number: @@ -164,7 +209,6 @@ class ConnectionController: SessionStateController.update_or_create(session_state) return proxy_port_number or port_number - @staticmethod def establish_system_connection(profile: SystemProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None): diff --git a/core/controllers/encrypted_proxy/DisableController.py b/core/controllers/encrypted_proxy/DisableController.py new file mode 100644 index 0000000..26c451b --- /dev/null +++ b/core/controllers/encrypted_proxy/DisableController.py @@ -0,0 +1,17 @@ +from pathlib import Path +from core.services.encrypted_proxy.disable_service import disable_proxy + + +class DisableController: + def __init__(self, tmp_dir: Path, wrapper: str, unit: str): + self.tmp_dir = tmp_dir + self.wrapper = wrapper + self.unit = unit + + def disable(self, observer=None) -> bool: + return disable_proxy( + tmp_dir=self.tmp_dir, + wrapper=self.wrapper, + unit=self.unit, + observer=observer, + ) \ No newline at end of file diff --git a/core/controllers/encrypted_proxy/HysteriaController.py b/core/controllers/encrypted_proxy/HysteriaController.py new file mode 100644 index 0000000..55e0a19 --- /dev/null +++ b/core/controllers/encrypted_proxy/HysteriaController.py @@ -0,0 +1,30 @@ +from pathlib import Path +from core.services.encrypted_proxy.hysteria_service import enable_hysteria, disable_hysteria + + +class HysteriaController: + def __init__(self, config_dir: Path, socks5_port: int): + self.config_dir = config_dir + self.socks5_port = socks5_port + + def enable(self, username: str, password: str, + server_host: str, observer=None) -> bool: + if not username or not isinstance(username, str): + if observer: + observer.notify("error", "Invalid username") + return False + if not password or not server_host: + if observer: + observer.notify("error", "Missing password or server_host") + return False + return enable_hysteria( + username=username, + password=password, + server_host=server_host, + config_dir=self.config_dir, + socks5_port=self.socks5_port, + observer=observer, + ) + + def disable(self, observer=None) -> bool: + return disable_hysteria(observer=observer) diff --git a/core/controllers/encrypted_proxy/VlessController.py b/core/controllers/encrypted_proxy/VlessController.py new file mode 100644 index 0000000..ae61140 --- /dev/null +++ b/core/controllers/encrypted_proxy/VlessController.py @@ -0,0 +1,28 @@ +from pathlib import Path +from core.services.encrypted_proxy.vless_service import enable_vless, disable_vless + + +class VlessController: + def __init__(self, config_dir: Path, socks5_port: int): + self.config_dir = config_dir + self.socks5_port = socks5_port + + def enable(self, vless_link: str, username: str, observer=None) -> bool: + if not username or not isinstance(username, str): + if observer: + observer.notify("error", "Invalid username") + return False + if not vless_link or not vless_link.startswith("vless://"): + if observer: + observer.notify("error", "Invalid vless link") + return False + return enable_vless( + vless_link=vless_link, + username=username, + config_dir=self.config_dir, + socks5_port=self.socks5_port, + observer=observer, + ) + + def disable(self, observer=None) -> bool: + return disable_vless(observer=observer) \ No newline at end of file diff --git a/core/controllers/encrypted_proxy/__init__.py b/core/controllers/encrypted_proxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models/BaseConnection.py b/core/models/BaseConnection.py index c394b52..d5f3995 100644 --- a/core/models/BaseConnection.py +++ b/core/models/BaseConnection.py @@ -15,3 +15,5 @@ class BaseConnection: def is_system_connection(self): return type(self).__name__ == 'SystemConnection' + def needs_operator_proxy(self): + return False \ No newline at end of file diff --git a/core/models/Operator.py b/core/models/Operator.py index 8ad7856..8cb21e7 100644 --- a/core/models/Operator.py +++ b/core/models/Operator.py @@ -6,6 +6,7 @@ _table_name: str = 'operators' _table_definition: str = """ 'id' int UNIQUE, 'name' varchar, + 'type' varchar, 'public_key' varchar, 'nostr_public_key' varchar, 'nostr_profile_reference' varchar, @@ -17,11 +18,18 @@ _table_definition: str = """ class Operator(Model): id: int name: str + type: str public_key: str nostr_public_key: str nostr_profile_reference: str nostr_attestation_event_reference: str + def is_external(self) -> bool: + return self.type == 'external' + + def is_internal(self) -> bool: + return self.type == 'internal' + @staticmethod def find_by_id(id: int): Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) @@ -44,7 +52,7 @@ class Operator(Model): @staticmethod def save_many(operators): Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) - Model._insert_many('INSERT INTO operators VALUES(?, ?, ?, ?, ?, ?)', Operator.tuple_factory, operators) + Model._insert_many('INSERT INTO operators VALUES(?, ?, ?, ?, ?, ?, ?)', Operator.tuple_factory, operators) @staticmethod def factory(cursor, row): @@ -53,4 +61,4 @@ class Operator(Model): @staticmethod def tuple_factory(operator): - return operator.id, operator.name, operator.public_key, operator.nostr_public_key, operator.nostr_profile_reference, operator.nostr_attestation_event_reference + return operator.id, operator.name, operator.type, operator.public_key, operator.nostr_public_key, operator.nostr_profile_reference, operator.nostr_attestation_event_reference \ No newline at end of file diff --git a/core/models/OperatorProxySession.py b/core/models/OperatorProxySession.py new file mode 100644 index 0000000..a9e5276 --- /dev/null +++ b/core/models/OperatorProxySession.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from dataclasses_json import dataclass_json +from typing import Optional + + +@dataclass_json +@dataclass +class OperatorProxySession: + id: int + type: str + username: Optional[str] + password: Optional[str] + links: Optional[list] + subscription_url: Optional[str] + operator_id: int + operator_name: str diff --git a/core/models/Subscription.py b/core/models/Subscription.py index 5211690..ac4ece7 100644 --- a/core/models/Subscription.py +++ b/core/models/Subscription.py @@ -10,6 +10,13 @@ import dataclasses_json @dataclass class Subscription: billing_code: str + operator_id: Optional[int] = field( + default=None, + metadata=config( + undefined=dataclasses_json.Undefined.EXCLUDE, + exclude=lambda value: value is None + ) + ) expires_at: Optional[datetime] = field( default=None, metadata=config( @@ -37,4 +44,4 @@ class Subscription: @staticmethod def _iso_format(datetime_instance: datetime): - return datetime.isoformat(datetime_instance).replace('+00:00', 'Z') + return datetime.isoformat(datetime_instance).replace('+00:00', 'Z') \ No newline at end of file diff --git a/core/models/session/NetworkPortNumbers.py b/core/models/session/NetworkPortNumbers.py index 26f91f7..f515ca8 100644 --- a/core/models/session/NetworkPortNumbers.py +++ b/core/models/session/NetworkPortNumbers.py @@ -6,11 +6,13 @@ class NetworkPortNumbers: proxy: list[int] = field(default_factory=list) wireguard: list[int] = field(default_factory=list) tor: list[int] = field(default_factory=list) + vless: list[int] = field(default_factory=list) + hysteria2: list[int] = field(default_factory=list) @property def all(self): - return self.proxy + self.wireguard + self.tor + return self.proxy + self.wireguard + self.tor + self.vless + self.hysteria2 @property def isolated(self): - return self.proxy + self.wireguard + return self.proxy + self.wireguard + self.vless + self.hysteria2 \ No newline at end of file diff --git a/core/models/session/SessionConnection.py b/core/models/session/SessionConnection.py index 026a9d2..4e99b68 100644 --- a/core/models/session/SessionConnection.py +++ b/core/models/session/SessionConnection.py @@ -1,14 +1,12 @@ from core.models.BaseConnection import BaseConnection from dataclasses import dataclass - @dataclass class SessionConnection(BaseConnection): masked: bool = False def __post_init__(self): - - if self.code not in ('system', 'tor', 'wireguard'): + if self.code not in ('system', 'tor', 'wireguard', 'vless', 'hysteria2'): raise ValueError('Invalid connection code.') def is_unprotected(self): @@ -16,3 +14,9 @@ class SessionConnection(BaseConnection): def needs_proxy_configuration(self): return self.masked is True + + def needs_operator_proxy(self): + return self.code in ('vless', 'hysteria2') + + def get_protocol(self): + return self.code if self.needs_operator_proxy() else None \ No newline at end of file diff --git a/core/models/session/SessionProfile.py b/core/models/session/SessionProfile.py index b2e7f9c..f4d6b3d 100644 --- a/core/models/session/SessionProfile.py +++ b/core/models/session/SessionProfile.py @@ -116,3 +116,34 @@ class SessionProfile(BaseProfile): def __delete_wireguard_configuration(self): Path(self.get_wireguard_configuration_path()).unlink(missing_ok=True) + def attach_operator_proxy_session(self, operator_proxy_session): + + from core.models.OperatorProxySession import OperatorProxySession + operator_proxy_session_file_contents = f'{operator_proxy_session.to_json(indent=4)}\n' + os.makedirs(self.get_config_path(), exist_ok=True) + + operator_proxy_session_file_path = self.get_operator_proxy_session_path() + + with open(operator_proxy_session_file_path, 'w') as operator_proxy_session_file: + operator_proxy_session_file.write(operator_proxy_session_file_contents) + + def get_operator_proxy_session_path(self): + return f'{self.get_config_path()}/operator_proxy_session.json' + + def get_operator_proxy_session(self): + + try: + config_file_contents = open(self.get_operator_proxy_session_path(), 'r').read() + except FileNotFoundError: + return None + + try: + data = json.loads(config_file_contents) + except ValueError: + return None + + from core.models.OperatorProxySession import OperatorProxySession + return OperatorProxySession.from_dict(data) + + def has_operator_proxy_session(self): + return os.path.isfile(self.get_operator_proxy_session_path()) \ No newline at end of file diff --git a/core/observers/EncryptedProxyObserver.py b/core/observers/EncryptedProxyObserver.py new file mode 100644 index 0000000..73f3bd4 --- /dev/null +++ b/core/observers/EncryptedProxyObserver.py @@ -0,0 +1,8 @@ +from core.observers.BaseObserver import BaseObserver + + +class EncryptedProxyObserver(BaseObserver): + def __init__(self): + self.on_connected = [] + self.on_disconnected = [] + self.on_error = [] \ No newline at end of file diff --git a/core/services/WebServiceApiService.py b/core/services/WebServiceApiService.py index b216915..1f56e2a 100644 --- a/core/services/WebServiceApiService.py +++ b/core/services/WebServiceApiService.py @@ -2,6 +2,7 @@ from core.Constants import Constants from core.models.ClientVersion import ClientVersion from core.models.Location import Location from core.models.Operator import Operator +from core.models.OperatorProxySession import OperatorProxySession from core.models.Subscription import Subscription from core.models.SubscriptionPlan import SubscriptionPlan from core.models.invoice.Invoice import Invoice @@ -67,7 +68,7 @@ class WebServiceApiService: if response.status_code == status_codes.OK: for operator in response.json()['data']: - operators.append(Operator(operator['id'], operator['name'], operator['public_key'], operator['nostr_public_key'], operator['nostr_profile_reference'], operator['nostr_attestation']['event_reference'])) + operators.append(Operator(operator['id'], operator['name'], operator['type'], operator['public_key'], operator['nostr_public_key'], operator['nostr_profile_reference'], operator['nostr_attestation']['event_reference'])) return operators @@ -100,17 +101,21 @@ class WebServiceApiService: return subscription_plans @staticmethod - def post_subscription(subscription_plan_id, location_id, proxies: Optional[dict] = None): + def post_subscription(subscription_plan_id, location_id=None, operator_id=None, proxies: Optional[dict] = None): from requests.status_codes import codes as status_codes - response = WebServiceApiService.__post('/subscriptions', None, { - 'subscription_plan_id': subscription_plan_id, - 'location_id': location_id - }, proxies) + body = {'subscription_plan_id': subscription_plan_id} + + if operator_id is not None: + body['operator_id'] = operator_id + else: + body['location_id'] = location_id + + response = WebServiceApiService.__post('/subscriptions', None, body, proxies) if response.status_code == status_codes.CREATED: - return Subscription(response.headers['X-Billing-Code']) + return Subscription(response.headers['X-Billing-Code'], operator_id=operator_id) else: return None @@ -127,9 +132,12 @@ class WebServiceApiService: response = WebServiceApiService.__get('/subscriptions/current', billing_code, proxies) if response.status_code == status_codes.OK: - subscription = response.json()['data'] - return Subscription(billing_code, Subscription.from_iso_format(subscription['expires_at'])) + return Subscription( + billing_code, + operator_id=subscription.get('operator_id'), + expires_at=Subscription.from_iso_format(subscription['expires_at']) + ) else: return None @@ -168,13 +176,38 @@ class WebServiceApiService: response = WebServiceApiService.__get('/proxy-configurations/current', billing_code, proxies) if response.status_code == status_codes.OK: - proxy_configuration = response.json()['data'] return ProxyConfiguration(proxy_configuration['ip_address'], proxy_configuration['port'], proxy_configuration['username'], proxy_configuration['password'], proxy_configuration['location']['time_zone']['code']) else: return None + @staticmethod + def post_operator_proxy(billing_code: str, operator_id: int, protocol: str, proxies: Optional[dict] = None): + + from requests.status_codes import codes as status_codes + + response = WebServiceApiService.__post('/subscriptions/current/operator-proxies', billing_code, { + 'operator_id': operator_id, + 'protocol': protocol, + }, proxies) + + if response.status_code == status_codes.OK: + data = response.json()['data'] + return OperatorProxySession( + data['id'], + data['type'], + data['username'], + data.get('password'), + data.get('links'), + data.get('subscription_url'), + data['operator']['id'], + data['operator']['name'], + ) + + else: + return None + @staticmethod def post_wireguard_session(country_code: str, location_code: str, billing_code: str, public_key: str, proxies: Optional[dict] = None): @@ -211,4 +244,4 @@ class WebServiceApiService: else: headers = None - return requests.post(Constants.SP_API_BASE_URL + path, headers=headers, json=body, proxies=proxies) + return requests.post(Constants.SP_API_BASE_URL + path, headers=headers, json=body, proxies=proxies) \ No newline at end of file diff --git a/core/services/encrypted_proxy/__init__.py b/core/services/encrypted_proxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/services/encrypted_proxy/disable_service.py b/core/services/encrypted_proxy/disable_service.py new file mode 100644 index 0000000..6ac777b --- /dev/null +++ b/core/services/encrypted_proxy/disable_service.py @@ -0,0 +1,33 @@ +import ssl +import time +import urllib.request +from pathlib import Path +from core.utils.encrypted_proxy.singbox import SingboxRunner + + +def get_public_ip(timeout: int = 8) -> str: + ctx = ssl.create_default_context() + try: + req = urllib.request.Request( + "https://ifconfig.me/ip", + headers={"User-Agent": "curl/7.0"} + ) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return resp.read().decode().strip() + except Exception as e: + return f"unknown ({e})" + + +def disable_proxy(tmp_dir: Path, wrapper: str, unit: str, observer=None) -> bool: + runner = SingboxRunner(wrapper, unit) + runner.stop() + + for f in tmp_dir.glob("*-sing-box.json"): + f.unlink(missing_ok=True) + + time.sleep(1) + ip = get_public_ip() + + if observer: + observer.notify("disconnected", {"ip": ip}) + return True \ No newline at end of file diff --git a/core/services/encrypted_proxy/hysteria_service.py b/core/services/encrypted_proxy/hysteria_service.py new file mode 100644 index 0000000..1a93d8e --- /dev/null +++ b/core/services/encrypted_proxy/hysteria_service.py @@ -0,0 +1,114 @@ +from pathlib import Path +from core.utils.encrypted_proxy.net import get_real_ip, verify_ip +from core.utils.encrypted_proxy.singbox import SingboxRunner +import socket + + +def _resolve(host: str) -> str: + return socket.gethostbyname(host) + + +def _get_hy2_domain(server_host: str) -> str: + parts = server_host.split(".", 1) + if len(parts) == 2: + return f"hy2.{parts[1]}" + return server_host + + +def build_hysteria_config(username: str, password: str, + server_host: str, socks5_port: int) -> dict: + hy2_domain = _get_hy2_domain(server_host) + server_ip = _resolve(hy2_domain) + return { + "dns": { + "servers": [{"tag": "local", "type": "udp", "server": "1.1.1.1"}], + "final": "local", + "strategy": "ipv4_only", + }, + "inbounds": [ + { + "type": "tun", + "tag": "tun-in", + "interface_name": "tun0", + "address": ["172.19.0.1/30"], + "mtu": 9000, + "auto_route": True, + "stack": "system", + }, + { + "type": "socks", + "tag": "socks-in", + "listen": "127.0.0.1", + "listen_port": socks5_port, + }, + ], + "outbounds": [ + {"type": "direct", "tag": "direct"}, + {"type": "block", "tag": "block"}, + { + "type": "hysteria2", + "tag": "proxy", + "server": server_ip, + "server_port": 443, + "password": f"{username}:{password}", + "tls": { + "enabled": True, + "server_name": hy2_domain, + "insecure": False, + }, + }, + ], + "route": { + "rules": [ + {"protocol": "dns", "action": "hijack-dns"}, + {"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"}, + {"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"}, + {"ip_is_private": True, "outbound": "direct"}, + {"ip_version": 6, "outbound": "block"}, + {"inbound": ["tun-in", "socks-in"], "outbound": "proxy"}, + ], + "final": "proxy", + "default_domain_resolver": "local", + "auto_detect_interface": True, + }, + } + + +def enable_hysteria(username: str, password: str, server_host: str, + config_dir: Path, socks5_port: int, + observer=None) -> bool: + real_ip = get_real_ip() + runner = SingboxRunner() + runner.stop() + + config_path = config_dir / f"{username}-sing-box.json" + config = build_hysteria_config(username, password, server_host, socks5_port) + + try: + runner.write_config(config_path, config) + ok = runner.start(config_path) + except Exception as e: + if observer: + observer.notify("error", str(e)) + return False + + if not ok: + if observer: + observer.notify("error", "sing-box not active after start") + return False + + proxy_ip = verify_ip(socks5_port) + if observer: + observer.notify("connected", { + "real_ip": real_ip, + "proxy_ip": proxy_ip, + "socks5_port": socks5_port, + }) + return True + + +def disable_hysteria(observer=None) -> bool: + SingboxRunner().stop() + if observer: + observer.notify("disconnected", {}) + return True diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py new file mode 100644 index 0000000..1a5a7ca --- /dev/null +++ b/core/services/encrypted_proxy/vless_service.py @@ -0,0 +1,133 @@ +from urllib.parse import unquote +from pathlib import Path +from core.utils.encrypted_proxy.net import get_real_ip, verify_ip +from core.utils.encrypted_proxy.singbox import SingboxRunner + + +def parse_vless_link(link: str) -> dict: + link = link.replace("vless://", "") + uuid, rest = link.split("@", 1) + hostport, qs = rest.split("?", 1) + query = qs.split("#")[0] + host, port = hostport.rsplit(":", 1) + params = {} + for part in query.split("&"): + if "=" in part: + k, v = part.split("=", 1) + params[k] = v + sni = params.get("sni", host) + ws_host = params.get("host", "").strip() or sni + return { + "uuid": uuid, + "host": host, + "port": int(port), + "path": unquote(params.get("path", "/vless")), + "sni": sni, + "ws_host": ws_host, + "security": params.get("security", "tls"), + "network": params.get("type", "ws"), + } + + +def _get_vpn_domain(sni: str) -> str: + parts = sni.split(".", 1) + if len(parts) == 2: + return f"vpn.{parts[1]}" + return sni + + +def build_vless_config(vless: dict, socks5_port: int) -> dict: + vpn_domain = _get_vpn_domain(vless["sni"]) + return { + "dns": { + "servers": [{"tag": "local", "type": "udp", "server": "1.1.1.1"}], + "final": "local", + "strategy": "ipv4_only", + }, + "inbounds": [ + { + "type": "tun", + "tag": "tun-in", + "interface_name": "tun0", + "address": ["172.19.0.1/30"], + "mtu": 9000, + "auto_route": True, + "stack": "system", + }, + { + "type": "socks", + "tag": "socks-in", + "listen": "127.0.0.1", + "listen_port": socks5_port, + }, + ], + "outbounds": [ + {"type": "direct", "tag": "direct"}, + {"type": "block", "tag": "block"}, + { + "type": "vless", + "tag": "proxy", + "server": vpn_domain, + "server_port": 443, + "uuid": vless["uuid"], + "tls": { + "enabled": True, + "server_name": vpn_domain, + "insecure": False, + }, + "transport": { + "type": "ws", + "path": vless["path"], + "headers": {"Host": vpn_domain}, + }, + }, + ], + "route": { + "rules": [ + {"protocol": "dns", "action": "hijack-dns"}, + {"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"}, + {"ip_is_private": True, "outbound": "direct"}, + {"ip_version": 6, "outbound": "block"}, + {"inbound": ["tun-in", "socks-in"], "outbound": "proxy"}, + ], + "final": "proxy", + "default_domain_resolver": "local", + "auto_detect_interface": True, + }, + } + + +def enable_vless(vless_link: str, username: str, config_dir: Path, + socks5_port: int, observer=None) -> bool: + vless = parse_vless_link(vless_link) + real_ip = get_real_ip() + runner = SingboxRunner() + runner.stop() + config_path = config_dir / f"{username}-sing-box.json" + config = build_vless_config(vless, socks5_port) + try: + runner.write_config(config_path, config) + ok = runner.start(config_path) + except Exception as e: + if observer: + observer.notify("error", str(e)) + return False + if not ok: + if observer: + observer.notify("error", "sing-box not active after start") + return False + proxy_ip = verify_ip(socks5_port) + if observer: + observer.notify("connected", { + "real_ip": real_ip, + "proxy_ip": proxy_ip, + "socks5_port": socks5_port, + }) + return True + + +def disable_vless(observer=None) -> bool: + SingboxRunner().stop() + if observer: + observer.notify("disconnected", {}) + return True \ No newline at end of file diff --git a/core/utils/encrypted_proxy/__init__.py b/core/utils/encrypted_proxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/utils/encrypted_proxy/net.py b/core/utils/encrypted_proxy/net.py new file mode 100644 index 0000000..167c10d --- /dev/null +++ b/core/utils/encrypted_proxy/net.py @@ -0,0 +1,44 @@ +import socket +import subprocess +import time + + +def resolve(host: str) -> str | None: + try: + return socket.gethostbyname(host) + except Exception: + return None + + +def get_real_ip(timeout: int = 10) -> str: + try: + r = subprocess.run( + ["curl", "-s", "--max-time", str(timeout), "https://ifconfig.me/ip"], + capture_output=True, timeout=timeout + 2, + ) + return r.stdout.decode().strip() or "unknown (empty)" + except Exception as e: + return f"unknown ({e})" + + +def get_proxied_ip(socks5_port: int, timeout: int = 10) -> str: + try: + r = subprocess.run( + ["curl", "-s", "--max-time", str(timeout), + "-x", f"socks5h://127.0.0.1:{socks5_port}", + "https://ifconfig.me/ip"], + capture_output=True, timeout=timeout + 2, + ) + return r.stdout.decode().strip() or "unknown (empty)" + except Exception as e: + return f"unknown ({e})" + + +def verify_ip(socks5_port: int, retries: int = 3, delay: float = 2.0) -> str: + for attempt in range(1, retries + 1): + ip = get_proxied_ip(socks5_port) + if not ip.startswith("unknown"): + return ip + if attempt < retries: + time.sleep(delay) + return "unknown" \ No newline at end of file diff --git a/core/utils/encrypted_proxy/singbox.py b/core/utils/encrypted_proxy/singbox.py new file mode 100644 index 0000000..5950b41 --- /dev/null +++ b/core/utils/encrypted_proxy/singbox.py @@ -0,0 +1,35 @@ +import json +import subprocess +import time +from pathlib import Path + + +class SingboxRunner: + WRAPPER = "/usr/local/bin/hydraveil-singbox" + + def start(self, config_path: Path) -> bool: + self.stop() + time.sleep(1) + self._process = subprocess.Popen( + ["sudo", self.WRAPPER, str(config_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + time.sleep(2) + return self.is_running() + + def stop(self) -> str: + subprocess.run(["sudo", self.WRAPPER, "", "stop"], capture_output=True) + subprocess.run(["sudo", "ip", "link", "delete", "tun0"], + capture_output=True) + self._process = None + return "stopped" + + def is_running(self) -> bool: + r = subprocess.run(["pgrep", "-f", "sing-box"], capture_output=True) + return r.returncode == 0 + + def write_config(self, config_path: Path, config: dict) -> None: + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w") as f: + json.dump(config, f, indent=2) \ No newline at end of file -- 2.45.2 From 89705bbab7ce440166ee9eac754e8429f8a2d4f8 Mon Sep 17 00:00:00 2001 From: zenaku Date: Sat, 23 May 2026 03:02:36 +0000 Subject: [PATCH 02/51] feat: use SystemProfile path for sing-box config storage --- core/services/encrypted_proxy/hysteria_service.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/core/services/encrypted_proxy/hysteria_service.py b/core/services/encrypted_proxy/hysteria_service.py index 1a93d8e..855091f 100644 --- a/core/services/encrypted_proxy/hysteria_service.py +++ b/core/services/encrypted_proxy/hysteria_service.py @@ -1,20 +1,18 @@ from pathlib import Path +from core.Constants import Constants from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner import socket - def _resolve(host: str) -> str: return socket.gethostbyname(host) - def _get_hy2_domain(server_host: str) -> str: parts = server_host.split(".", 1) if len(parts) == 2: return f"hy2.{parts[1]}" return server_host - def build_hysteria_config(username: str, password: str, server_host: str, socks5_port: int) -> dict: hy2_domain = _get_hy2_domain(server_host) @@ -73,17 +71,14 @@ def build_hysteria_config(username: str, password: str, }, } - def enable_hysteria(username: str, password: str, server_host: str, config_dir: Path, socks5_port: int, observer=None) -> bool: real_ip = get_real_ip() runner = SingboxRunner() runner.stop() - - config_path = config_dir / f"{username}-sing-box.json" + config_path = Path(Constants.HV_DATA_HOME) / f"{username}-sing-box.json" config = build_hysteria_config(username, password, server_host, socks5_port) - try: runner.write_config(config_path, config) ok = runner.start(config_path) @@ -91,12 +86,10 @@ def enable_hysteria(username: str, password: str, server_host: str, if observer: observer.notify("error", str(e)) return False - if not ok: if observer: observer.notify("error", "sing-box not active after start") return False - proxy_ip = verify_ip(socks5_port) if observer: observer.notify("connected", { @@ -106,9 +99,8 @@ def enable_hysteria(username: str, password: str, server_host: str, }) return True - def disable_hysteria(observer=None) -> bool: SingboxRunner().stop() if observer: observer.notify("disconnected", {}) - return True + return True \ No newline at end of file -- 2.45.2 From 8ef8f9e1c9ee9d0039b9e8a2fc7ba10e763e539a Mon Sep 17 00:00:00 2001 From: zenaku Date: Sat, 23 May 2026 03:03:23 +0000 Subject: [PATCH 03/51] feat: use SystemProfile path for sing-box config storage --- core/services/encrypted_proxy/vless_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py index 1a5a7ca..25c9282 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -1,5 +1,6 @@ from urllib.parse import unquote from pathlib import Path +from core.Constants import Constants from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner @@ -103,7 +104,7 @@ def enable_vless(vless_link: str, username: str, config_dir: Path, real_ip = get_real_ip() runner = SingboxRunner() runner.stop() - config_path = config_dir / f"{username}-sing-box.json" + config_path = Path(Constants.HV_DATA_HOME) / f"{username}-sing-box.json" config = build_vless_config(vless, socks5_port) try: runner.write_config(config_path, config) -- 2.45.2 From 811b285fe66436e1d8bbb3799d5d9aba1c95d7bf Mon Sep 17 00:00:00 2001 From: zenaku Date: Sat, 23 May 2026 03:12:33 +0000 Subject: [PATCH 04/51] add install.sh --- install.sh | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 install.sh diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..399b90b --- /dev/null +++ b/install.sh @@ -0,0 +1,173 @@ +#!/bin/bash +set -e + +REPO_URL="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-core" +BRANCH="core-laravel-proxyTEST" +INSTALL_DIR="$HOME/sp-hydra-veil-core" +SINGBOX_VERSION="1.13.5" +SINGBOX_BIN="/usr/bin/sing-box" +WRAPPER_PATH="/usr/local/bin/hydraveil-singbox" +SUDOERS_PATH="/etc/sudoers.d/hydraveil" +HV_DATA_HOME="$HOME/.local/share/hydra-veil" + +echo "=== sp-hydra-veil-core installer ===" + +# 1. Clone repo +echo "" +echo "[1/6] Cloning repository (branch: $BRANCH)..." +if [ -d "$INSTALL_DIR" ]; then + echo "Directory already exists, removing..." + rm -rf "$INSTALL_DIR" +fi +git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" +cd "$INSTALL_DIR" + +# 2. .env +echo "" +echo "[2/6] Creating .env..." +cat > "$INSTALL_DIR/.env" << 'DOTENV' +SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 +SP_API_BASE_URL_PRODUCTION=https://fake.simplifiedprivacy.org/api/v1 +APP_ENV=production +DOTENV +echo ".env created." + +# 3. venv + dependencies +echo "" +echo "[3/6] Setting up virtual environment..." +python3 -m venv "$INSTALL_DIR/.venv" +source "$INSTALL_DIR/.venv/bin/activate" + +pip install --quiet --upgrade pip + +pip install \ + python-dotenv \ + "cryptography~=46.0.3" \ + "dataclasses-json~=0.6.7" \ + "marshmallow~=3.26.1" \ + "psutil~=7.1.3" \ + "pysocks~=1.7.1" \ + "python-dateutil~=2.9.0.post0" \ + "requests~=2.32.5" \ + "annotated-types==0.7.0" \ + "certifi==2026.4.22" \ + "charset-normalizer==3.4.7" \ + "click==8.3.3" \ + "cytoolz==1.1.0" \ + "eth-hash==0.8.0" \ + "eth-typing==6.0.0" \ + "eth-utils==6.0.0" \ + "idna==3.13" \ + "packaging==26.2" \ + "pathspec==1.1.1" \ + "platformdirs==4.9.6" \ + "py-ecc==8.0.0" \ + "pydantic==2.13.3" \ + "pydantic_core==2.46.3" \ + "pydeps==3.0.6" \ + "pytokens==0.4.1" \ + "stdlib-list==0.12.0" \ + "toolz==1.1.0" \ + "typing-inspect==0.9.0" \ + "typing-inspection==0.4.2" \ + "typing_extensions==4.15.0" \ + "urllib3==2.6.3" + +pip install -e "$INSTALL_DIR" --no-deps +echo "Dependencies installed." + +# 4. sing-box +echo "" +echo "[4/6] Checking sing-box..." + +# Find sing-box in any common location +FOUND_SINGBOX="" +for candidate in /usr/bin/sing-box /usr/local/bin/sing-box /bin/sing-box /usr/local/sbin/sing-box; do + if [ -x "$candidate" ]; then + FOUND_SINGBOX="$candidate" + break + fi +done + +# Also check PATH +if [ -z "$FOUND_SINGBOX" ]; then + FOUND_SINGBOX=$(command -v sing-box 2>/dev/null || true) +fi + +if [ -n "$FOUND_SINGBOX" ]; then + echo "sing-box found at: $FOUND_SINGBOX" + echo "Version: $($FOUND_SINGBOX version | head -1)" + # If not at /usr/bin/sing-box, create symlink + if [ "$FOUND_SINGBOX" != "$SINGBOX_BIN" ]; then + echo "Creating symlink: $FOUND_SINGBOX -> $SINGBOX_BIN" + sudo ln -sf "$FOUND_SINGBOX" "$SINGBOX_BIN" + fi +else + echo "sing-box not found, installing v$SINGBOX_VERSION..." + wget -q "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-amd64.tar.gz" -O /tmp/sing-box.tar.gz + tar -xzf /tmp/sing-box.tar.gz -C /tmp + sudo cp "/tmp/sing-box-${SINGBOX_VERSION}-linux-amd64/sing-box" "$SINGBOX_BIN" + sudo chmod 755 "$SINGBOX_BIN" + rm -rf /tmp/sing-box.tar.gz "/tmp/sing-box-${SINGBOX_VERSION}-linux-amd64" + echo "sing-box installed: $($SINGBOX_BIN version | head -1)" +fi + +# Final check +if [ ! -x "$SINGBOX_BIN" ]; then + echo "ERROR: sing-box not found at $SINGBOX_BIN after install. Aborting." + exit 1 +fi +echo "sing-box OK at $SINGBOX_BIN" + +# 5. wrapper + sudoers + HV_DATA_HOME +echo "" +echo "[5/6] Installing wrapper..." + +sudo tee "$WRAPPER_PATH" > /dev/null << WRAPPER +#!/bin/bash +CONFIG=\$1 +ACTION=\$2 + +if [[ "\$ACTION" == "stop" ]]; then + pkill -9 -f sing-box + exit 0 +fi + +if [[ "\$CONFIG" != /tmp/*.json ]] && \\ + [[ "\$CONFIG" != /home/*/.local/share/hydra-veil/*.json ]]; then + echo "Error: config path not allowed" + exit 1 +fi + +exec $SINGBOX_BIN run -c "\$CONFIG" +WRAPPER + +sudo chmod 755 "$WRAPPER_PATH" + +# Verify wrapper points to correct sing-box +if grep -q "$SINGBOX_BIN" "$WRAPPER_PATH"; then + echo "Wrapper OK — points to $SINGBOX_BIN" +else + echo "ERROR: wrapper does not reference $SINGBOX_BIN" + exit 1 +fi + +# 6. sudoers +echo "" +echo "[6/6] Configuring sudoers..." +if [ ! -f "$SUDOERS_PATH" ]; then + echo "$USER ALL=(ALL) NOPASSWD: $WRAPPER_PATH" | sudo tee "$SUDOERS_PATH" > /dev/null + sudo chmod 440 "$SUDOERS_PATH" + echo "Sudoers configured." +else + echo "Sudoers already exists, skipping." +fi + +# Create HV_DATA_HOME directory +mkdir -p "$HV_DATA_HOME" +echo "HV_DATA_HOME created: $HV_DATA_HOME" + +echo "" +echo "=== Installation complete ===" +echo "Activate the environment with:" +echo " source $INSTALL_DIR/.venv/bin/activate" \ No newline at end of file -- 2.45.2 From 93fc03c6ca0b590952a2778e88207f03220277ea Mon Sep 17 00:00:00 2001 From: zenaku Date: Sat, 23 May 2026 04:39:37 +0000 Subject: [PATCH 05/51] fix subdomain --- core/services/encrypted_proxy/hysteria_service.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/core/services/encrypted_proxy/hysteria_service.py b/core/services/encrypted_proxy/hysteria_service.py index 855091f..a425a1b 100644 --- a/core/services/encrypted_proxy/hysteria_service.py +++ b/core/services/encrypted_proxy/hysteria_service.py @@ -4,22 +4,17 @@ from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner import socket + def _resolve(host: str) -> str: return socket.gethostbyname(host) -def _get_hy2_domain(server_host: str) -> str: - parts = server_host.split(".", 1) - if len(parts) == 2: - return f"hy2.{parts[1]}" - return server_host def build_hysteria_config(username: str, password: str, server_host: str, socks5_port: int) -> dict: - hy2_domain = _get_hy2_domain(server_host) - server_ip = _resolve(hy2_domain) + server_ip = _resolve(server_host) return { "dns": { - "servers": [{"tag": "local", "type": "udp", "server": "1.1.1.1"}], + "servers": [{"tag": "local", "type": "udp", "server": "9.9.9.9"}], "final": "local", "strategy": "ipv4_only", }, @@ -51,7 +46,7 @@ def build_hysteria_config(username: str, password: str, "password": f"{username}:{password}", "tls": { "enabled": True, - "server_name": hy2_domain, + "server_name": server_host, "insecure": False, }, }, @@ -71,6 +66,7 @@ def build_hysteria_config(username: str, password: str, }, } + def enable_hysteria(username: str, password: str, server_host: str, config_dir: Path, socks5_port: int, observer=None) -> bool: @@ -99,6 +95,7 @@ def enable_hysteria(username: str, password: str, server_host: str, }) return True + def disable_hysteria(observer=None) -> bool: SingboxRunner().stop() if observer: -- 2.45.2 From a6d412b5890da6bea47f9d21b64513699e512688 Mon Sep 17 00:00:00 2001 From: zenaku Date: Sat, 23 May 2026 04:40:13 +0000 Subject: [PATCH 06/51] fix subdomain --- core/services/encrypted_proxy/vless_service.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py index 25c9282..5c0e325 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -30,18 +30,10 @@ def parse_vless_link(link: str) -> dict: } -def _get_vpn_domain(sni: str) -> str: - parts = sni.split(".", 1) - if len(parts) == 2: - return f"vpn.{parts[1]}" - return sni - - def build_vless_config(vless: dict, socks5_port: int) -> dict: - vpn_domain = _get_vpn_domain(vless["sni"]) return { "dns": { - "servers": [{"tag": "local", "type": "udp", "server": "1.1.1.1"}], + "servers": [{"tag": "local", "type": "udp", "server": "9.9.9.9"}], "final": "local", "strategy": "ipv4_only", }, @@ -68,18 +60,18 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: { "type": "vless", "tag": "proxy", - "server": vpn_domain, + "server": vless["sni"], "server_port": 443, "uuid": vless["uuid"], "tls": { "enabled": True, - "server_name": vpn_domain, + "server_name": vless["sni"], "insecure": False, }, "transport": { "type": "ws", "path": vless["path"], - "headers": {"Host": vpn_domain}, + "headers": {"Host": vless["sni"]}, }, }, ], -- 2.45.2 From 918c070f7303a8d2ed78df1ffa2e846f0069ae3d Mon Sep 17 00:00:00 2001 From: zenaku Date: Sat, 23 May 2026 15:14:09 +0000 Subject: [PATCH 07/51] fix system-profile --- core/models/system/SystemProfile.py | 50 ++++++++++++++++------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/core/models/system/SystemProfile.py b/core/models/system/SystemProfile.py index bd00de4..1bcf0a9 100644 --- a/core/models/system/SystemProfile.py +++ b/core/models/system/SystemProfile.py @@ -4,11 +4,11 @@ from core.models.BaseProfile import BaseProfile from core.models.system.SystemConnection import SystemConnection from dataclasses import dataclass from typing import Optional +import json import os import shutil import subprocess - @dataclass class SystemProfile(BaseProfile): connection: Optional[SystemConnection] @@ -17,33 +17,23 @@ class SystemProfile(BaseProfile): return self.__get_system_config_path(self.id) def save(self): - if 'location' in self._get_dirty_keys(): self.__delete_wireguard_configuration() - super().save() def attach_wireguard_configuration(self, wireguard_configuration): - if shutil.which('pkexec') is None: raise CommandNotFoundError('pkexec') - wireguard_configuration_file_backup_path = f'{self.get_config_path()}/wg.conf.bak' - with open(wireguard_configuration_file_backup_path, 'w') as wireguard_configuration_file: wireguard_configuration_file.write(wireguard_configuration) - wireguard_configuration_is_attached = False failed_attempt_count = 0 - while not wireguard_configuration_is_attached and failed_attempt_count < 3: - process = subprocess.Popen(('pkexec', 'install', '-D', wireguard_configuration_file_backup_path, self.get_wireguard_configuration_path(), '-o', 'root', '-m', '744')) wireguard_configuration_is_attached = not bool(os.waitpid(process.pid, 0)[1] >> 8) - if not wireguard_configuration_is_attached: failed_attempt_count += 1 - if not wireguard_configuration_is_attached: raise ProfileModificationError('The WireGuard configuration could not be attached.') @@ -54,41 +44,57 @@ class SystemProfile(BaseProfile): return os.path.isfile(f'{self.get_system_config_path()}/wg.conf') def address_security_incident(self): - super().address_security_incident() self.__delete_wireguard_configuration() def delete(self): - try: self.__delete_wireguard_configuration() except ProfileModificationError: raise ProfileDeletionError('The WireGuard configuration could not be deleted.') - if shutil.which('pkexec') is None: raise CommandNotFoundError('pkexec') - process = subprocess.Popen(('pkexec', 'rm', '-d', self.get_system_config_path())) completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8) - if not completed_successfully: raise ProfileDeletionError('The profile could not be deleted.') - super().delete() + def attach_operator_proxy_session(self, operator_proxy_session): + from core.models.OperatorProxySession import OperatorProxySession + operator_proxy_session_file_contents = f'{operator_proxy_session.to_json(indent=4)}\n' + os.makedirs(self.get_config_path(), exist_ok=True) + operator_proxy_session_file_path = self.get_operator_proxy_session_path() + with open(operator_proxy_session_file_path, 'w') as operator_proxy_session_file: + operator_proxy_session_file.write(operator_proxy_session_file_contents) + + def get_operator_proxy_session_path(self): + return f'{self.get_config_path()}/operator_proxy_session.json' + + def get_operator_proxy_session(self): + try: + config_file_contents = open(self.get_operator_proxy_session_path(), 'r').read() + except FileNotFoundError: + return None + try: + data = json.loads(config_file_contents) + except ValueError: + return None + from core.models.OperatorProxySession import OperatorProxySession + return OperatorProxySession.from_dict(data) + + def has_operator_proxy_session(self): + return os.path.isfile(self.get_operator_proxy_session_path()) + def __delete_wireguard_configuration(self): - if self.has_wireguard_configuration(): - if shutil.which('pkexec') is None: raise CommandNotFoundError('pkexec') - process = subprocess.Popen(('pkexec', 'rm', '-d', self.get_wireguard_configuration_path())) completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8) - if not completed_successfully: raise ProfileModificationError('The WireGuard configuration could not be deleted.') @staticmethod def __get_system_config_path(id: int): - return f'{Constants.HV_SYSTEM_PROFILE_CONFIG_PATH}/{str(id)}' + return f'{Constants.HV_SYSTEM_PROFILE_CONFIG_PATH}/{str(id)}' \ No newline at end of file -- 2.45.2 From 866dda72e7069ad572e502ac55c2e1d1bd0bc1ae Mon Sep 17 00:00:00 2001 From: zenaku Date: Sat, 23 May 2026 15:15:15 +0000 Subject: [PATCH 08/51] fix system-profile --- core/models/session/SessionProfile.py | 58 +-------------------------- 1 file changed, 1 insertion(+), 57 deletions(-) diff --git a/core/models/session/SessionProfile.py b/core/models/session/SessionProfile.py index f4d6b3d..03a12bd 100644 --- a/core/models/session/SessionProfile.py +++ b/core/models/session/SessionProfile.py @@ -22,35 +22,24 @@ class SessionProfile(BaseProfile): return self.connection is not None def save(self): - if 'application_version' in self._get_dirty_keys(): - persistent_state_path = f'{self.get_data_path()}/persistent-state' - if os.path.isdir(persistent_state_path): shutil.rmtree(persistent_state_path, ignore_errors=True) - if 'location' in self._get_dirty_keys(): - self.__delete_proxy_configuration() self.__delete_wireguard_configuration() - super().save() def attach_proxy_configuration(self, proxy_configuration): - proxy_configuration_file_contents = f'{proxy_configuration.to_json(indent=4)}\n' os.makedirs(Constants.HV_CONFIG_HOME, exist_ok=True) - proxy_configuration_file_path = self.get_proxy_configuration_path() - with open(proxy_configuration_file_path, 'w') as proxy_configuration_file: proxy_configuration_file.write(proxy_configuration_file_contents) def attach_wireguard_configuration(self, wireguard_configuration): - wireguard_configuration_file_path = self.get_wireguard_configuration_path() - with open(wireguard_configuration_file_path, 'w') as wireguard_configuration_file: wireguard_configuration_file.write(wireguard_configuration) @@ -61,19 +50,15 @@ class SessionProfile(BaseProfile): return f'{self.get_config_path()}/wg.conf' def get_proxy_configuration(self): - try: config_file_contents = open(self.get_proxy_configuration_path(), 'r').read() except FileNotFoundError: return None - try: proxy_configuration = json.loads(config_file_contents) except ValueError: return None - proxy_configuration = ProxyConfiguration.from_dict(proxy_configuration) - return proxy_configuration def has_proxy_configuration(self): @@ -83,67 +68,26 @@ class SessionProfile(BaseProfile): return os.path.isfile(f'{self.get_config_path()}/wg.conf') def address_security_incident(self): - super().address_security_incident() self.__delete_wireguard_configuration() def determine_timezone(self): - time_zone = None - if self.has_connection(): - if self.connection.needs_proxy_configuration(): - if self.has_proxy_configuration(): time_zone = self.get_proxy_configuration().time_zone - elif self.connection.needs_wireguard_configuration(): - if self.has_wireguard_configuration(): time_zone = self.get_wireguard_configuration_metadata('TZ') - if time_zone is None and self.has_location(): time_zone = self.location.time_zone - if time_zone is None: raise UnknownTimeZoneError('The preferred time zone could not be determined.') - return time_zone def __delete_proxy_configuration(self): Path(self.get_proxy_configuration_path()).unlink(missing_ok=True) def __delete_wireguard_configuration(self): - Path(self.get_wireguard_configuration_path()).unlink(missing_ok=True) - def attach_operator_proxy_session(self, operator_proxy_session): - - from core.models.OperatorProxySession import OperatorProxySession - operator_proxy_session_file_contents = f'{operator_proxy_session.to_json(indent=4)}\n' - os.makedirs(self.get_config_path(), exist_ok=True) - - operator_proxy_session_file_path = self.get_operator_proxy_session_path() - - with open(operator_proxy_session_file_path, 'w') as operator_proxy_session_file: - operator_proxy_session_file.write(operator_proxy_session_file_contents) - - def get_operator_proxy_session_path(self): - return f'{self.get_config_path()}/operator_proxy_session.json' - - def get_operator_proxy_session(self): - - try: - config_file_contents = open(self.get_operator_proxy_session_path(), 'r').read() - except FileNotFoundError: - return None - - try: - data = json.loads(config_file_contents) - except ValueError: - return None - - from core.models.OperatorProxySession import OperatorProxySession - return OperatorProxySession.from_dict(data) - - def has_operator_proxy_session(self): - return os.path.isfile(self.get_operator_proxy_session_path()) \ No newline at end of file + Path(self.get_wireguard_configuration_path()).unlink(missing_ok=True) \ No newline at end of file -- 2.45.2 From 0d5fdf2d668e91020e66e7eeb0036853caa4ed99 Mon Sep 17 00:00:00 2001 From: zenaku Date: Mon, 25 May 2026 00:01:52 +0000 Subject: [PATCH 09/51] fix 2xx --- core/services/WebServiceApiService.py | 68 ++++++++------------------- 1 file changed, 20 insertions(+), 48 deletions(-) diff --git a/core/services/WebServiceApiService.py b/core/services/WebServiceApiService.py index 1f56e2a..a82bb1c 100644 --- a/core/services/WebServiceApiService.py +++ b/core/services/WebServiceApiService.py @@ -19,12 +19,10 @@ class WebServiceApiService: @staticmethod def get_applications(proxies: Optional[dict] = None): - from requests.status_codes import codes as status_codes - response = WebServiceApiService.__get('/platforms/linux-x86_64/applications', None, proxies) applications = [] - if response.status_code == status_codes.OK: + if 200 <= response.status_code < 300: for application in response.json()['data']: applications.append(Application(application['code'], application['name'], application['id'])) @@ -33,12 +31,10 @@ class WebServiceApiService: @staticmethod def get_application_versions(code: str, proxies: Optional[dict] = None): - from requests.status_codes import codes as status_codes - response = WebServiceApiService.__get(f'/platforms/linux-x86_64/applications/{code}/application-versions', None, proxies) application_versions = [] - if response.status_code == status_codes.OK: + if 200 <= response.status_code < 300: for application_version in response.json()['data']: application_versions.append(ApplicationVersion(code, application_version['version_number'], application_version['format_revision'], application_version['id'], application_version['download_path'], application_version['released_at'], application_version['file_hash'])) @@ -47,12 +43,10 @@ class WebServiceApiService: @staticmethod def get_client_versions(proxies: Optional[dict] = None): - from requests.status_codes import codes as status_codes - response = WebServiceApiService.__get('/platforms/linux-x86_64/appimage/client-versions', None, proxies) client_versions = [] - if response.status_code == status_codes.OK: + if 200 <= response.status_code < 300: for client_version in response.json()['data']: client_versions.append(ClientVersion(client_version['version_number'], client_version['released_at'], client_version['id'], client_version['download_path'])) @@ -61,12 +55,10 @@ class WebServiceApiService: @staticmethod def get_operators(proxies: Optional[dict] = None): - from requests.status_codes import codes as status_codes - response = WebServiceApiService.__get('/operators', None, proxies) operators = [] - if response.status_code == status_codes.OK: + if 200 <= response.status_code < 300: for operator in response.json()['data']: operators.append(Operator(operator['id'], operator['name'], operator['type'], operator['public_key'], operator['nostr_public_key'], operator['nostr_profile_reference'], operator['nostr_attestation']['event_reference'])) @@ -75,12 +67,10 @@ class WebServiceApiService: @staticmethod def get_locations(proxies: Optional[dict] = None): - from requests.status_codes import codes as status_codes - response = WebServiceApiService.__get('/locations', None, proxies) locations = [] - if response.status_code == status_codes.OK: + if 200 <= response.status_code < 300: for location in response.json()['data']: locations.append(Location(location['country']['code'], location['code'], location['id'], location['country']['name'], location['name'], location['time_zone']['code'], location['operator_id'], location['provider']['name'], location['is_proxy_capable'], location['is_wireguard_capable'])) @@ -89,12 +79,10 @@ class WebServiceApiService: @staticmethod def get_subscription_plans(proxies: Optional[dict] = None): - from requests.status_codes import codes as status_codes - response = WebServiceApiService.__get('/subscription-plans', None, proxies) subscription_plans = [] - if response.status_code == status_codes.OK: + if 200 <= response.status_code < 300: for subscription_plan in response.json()['data']: subscription_plans.append(SubscriptionPlan(subscription_plan['id'], subscription_plan['code'], subscription_plan['wireguard_session_limit'], subscription_plan['duration'], subscription_plan['price'], subscription_plan['features_proxy'], subscription_plan['features_wireguard'])) @@ -103,8 +91,6 @@ class WebServiceApiService: @staticmethod def post_subscription(subscription_plan_id, location_id=None, operator_id=None, proxies: Optional[dict] = None): - from requests.status_codes import codes as status_codes - body = {'subscription_plan_id': subscription_plan_id} if operator_id is not None: @@ -114,24 +100,21 @@ class WebServiceApiService: response = WebServiceApiService.__post('/subscriptions', None, body, proxies) - if response.status_code == status_codes.CREATED: + if 200 <= response.status_code < 300: return Subscription(response.headers['X-Billing-Code'], operator_id=operator_id) - else: - return None + return None @staticmethod def get_subscription(billing_code: str, proxies: Optional[dict] = None): - from requests.status_codes import codes as status_codes - billing_code = billing_code.replace('-', '').upper() billing_code_fragments = re.findall('....?', billing_code) billing_code = '-'.join(billing_code_fragments) response = WebServiceApiService.__get('/subscriptions/current', billing_code, proxies) - if response.status_code == status_codes.OK: + if 200 <= response.status_code < 300: subscription = response.json()['data'] return Subscription( billing_code, @@ -139,17 +122,14 @@ class WebServiceApiService: expires_at=Subscription.from_iso_format(subscription['expires_at']) ) - else: - return None + return None @staticmethod def get_invoice(billing_code: str, proxies: Optional[dict] = None): - from requests.status_codes import codes as status_codes - response = WebServiceApiService.__get('/invoices/current', billing_code, proxies) - if response.status_code == status_codes.OK: + if 200 <= response.status_code < 300: response_data = response.json()['data'] @@ -165,34 +145,28 @@ class WebServiceApiService: return Invoice(billing_code, invoice['status'], invoice['expires_at'], tuple[PaymentMethod](payment_methods)) - else: - return None + return None @staticmethod def get_proxy_configuration(billing_code: str, proxies: Optional[dict] = None): - from requests.status_codes import codes as status_codes - response = WebServiceApiService.__get('/proxy-configurations/current', billing_code, proxies) - if response.status_code == status_codes.OK: + if 200 <= response.status_code < 300: proxy_configuration = response.json()['data'] return ProxyConfiguration(proxy_configuration['ip_address'], proxy_configuration['port'], proxy_configuration['username'], proxy_configuration['password'], proxy_configuration['location']['time_zone']['code']) - else: - return None + return None @staticmethod def post_operator_proxy(billing_code: str, operator_id: int, protocol: str, proxies: Optional[dict] = None): - from requests.status_codes import codes as status_codes - response = WebServiceApiService.__post('/subscriptions/current/operator-proxies', billing_code, { 'operator_id': operator_id, 'protocol': protocol, }, proxies) - if response.status_code == status_codes.OK: + if 200 <= response.status_code < 300: data = response.json()['data'] return OperatorProxySession( data['id'], @@ -203,24 +177,22 @@ class WebServiceApiService: data.get('subscription_url'), data['operator']['id'], data['operator']['name'], + data['operator'].get('domain'), ) - else: - return None + return None @staticmethod def post_wireguard_session(country_code: str, location_code: str, billing_code: str, public_key: str, proxies: Optional[dict] = None): - from requests.status_codes import codes as status_codes - response = WebServiceApiService.__post(f'/countries/{country_code}/locations/{location_code}/wireguard-sessions', billing_code, { 'public_key': public_key, }, proxies) - if response.status_code == status_codes.CREATED: + if 200 <= response.status_code < 300: return response.text - else: - return None + + return None @staticmethod def __get(path, billing_code: Optional[str] = None, proxies: Optional[dict] = None): -- 2.45.2 From 2e7fce9c1c4178a211e26ad9fd96f438140f1f12 Mon Sep 17 00:00:00 2001 From: zenaku Date: Mon, 25 May 2026 00:02:57 +0000 Subject: [PATCH 10/51] add subdomain operator --- core/models/OperatorProxySession.py | 1 + 1 file changed, 1 insertion(+) diff --git a/core/models/OperatorProxySession.py b/core/models/OperatorProxySession.py index a9e5276..a1675ca 100644 --- a/core/models/OperatorProxySession.py +++ b/core/models/OperatorProxySession.py @@ -14,3 +14,4 @@ class OperatorProxySession: subscription_url: Optional[str] operator_id: int operator_name: str + operator_domain: Optional[str] = None \ No newline at end of file -- 2.45.2 From 3077e8d52550073d6d48c1e0eb26ede3813331b2 Mon Sep 17 00:00:00 2001 From: zenaku Date: Mon, 25 May 2026 00:39:16 +0000 Subject: [PATCH 11/51] fix add subdomain --- core/models/OperatorProxySession.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/models/OperatorProxySession.py b/core/models/OperatorProxySession.py index a1675ca..5bdfa6f 100644 --- a/core/models/OperatorProxySession.py +++ b/core/models/OperatorProxySession.py @@ -14,4 +14,6 @@ class OperatorProxySession: subscription_url: Optional[str] operator_id: int operator_name: str - operator_domain: Optional[str] = None \ No newline at end of file + operator_domain: Optional[str] = None + operator_hysteria2_host: Optional[str] = None + operator_vless_host: Optional[str] = None \ No newline at end of file -- 2.45.2 From db8a0f0c74ee97e846b494e6e92e6ec5a8028377 Mon Sep 17 00:00:00 2001 From: zenaku Date: Mon, 25 May 2026 00:40:11 +0000 Subject: [PATCH 12/51] fix 2xx and subdomain --- core/services/WebServiceApiService.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/services/WebServiceApiService.py b/core/services/WebServiceApiService.py index a82bb1c..5579b97 100644 --- a/core/services/WebServiceApiService.py +++ b/core/services/WebServiceApiService.py @@ -178,6 +178,8 @@ class WebServiceApiService: data['operator']['id'], data['operator']['name'], data['operator'].get('domain'), + data['operator'].get('hysteria2_host'), + data['operator'].get('vless_host'), ) return None -- 2.45.2 From c93a8572a175d52ceec357613c4258e929223220 Mon Sep 17 00:00:00 2001 From: zenaku Date: Mon, 25 May 2026 01:06:53 +0000 Subject: [PATCH 13/51] ffi --- core/controllers/encrypted_proxy/HysteriaController.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/core/controllers/encrypted_proxy/HysteriaController.py b/core/controllers/encrypted_proxy/HysteriaController.py index 55e0a19..233c282 100644 --- a/core/controllers/encrypted_proxy/HysteriaController.py +++ b/core/controllers/encrypted_proxy/HysteriaController.py @@ -1,10 +1,7 @@ -from pathlib import Path from core.services.encrypted_proxy.hysteria_service import enable_hysteria, disable_hysteria - class HysteriaController: - def __init__(self, config_dir: Path, socks5_port: int): - self.config_dir = config_dir + def __init__(self, socks5_port: int): self.socks5_port = socks5_port def enable(self, username: str, password: str, @@ -21,10 +18,9 @@ class HysteriaController: username=username, password=password, server_host=server_host, - config_dir=self.config_dir, socks5_port=self.socks5_port, observer=observer, ) def disable(self, observer=None) -> bool: - return disable_hysteria(observer=observer) + return disable_hysteria(observer=observer) \ No newline at end of file -- 2.45.2 From 29bd0e713b756004e8f324c3d7a0f75d8a1d4e67 Mon Sep 17 00:00:00 2001 From: zenaku Date: Mon, 25 May 2026 01:07:30 +0000 Subject: [PATCH 14/51] fix conection --- core/controllers/encrypted_proxy/VlessController.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/core/controllers/encrypted_proxy/VlessController.py b/core/controllers/encrypted_proxy/VlessController.py index ae61140..a969cef 100644 --- a/core/controllers/encrypted_proxy/VlessController.py +++ b/core/controllers/encrypted_proxy/VlessController.py @@ -1,10 +1,7 @@ -from pathlib import Path from core.services.encrypted_proxy.vless_service import enable_vless, disable_vless - class VlessController: - def __init__(self, config_dir: Path, socks5_port: int): - self.config_dir = config_dir + def __init__(self, socks5_port: int): self.socks5_port = socks5_port def enable(self, vless_link: str, username: str, observer=None) -> bool: @@ -19,7 +16,6 @@ class VlessController: return enable_vless( vless_link=vless_link, username=username, - config_dir=self.config_dir, socks5_port=self.socks5_port, observer=observer, ) -- 2.45.2 From 9aa3518fecbf4213227e22c4b851ad3b3b19d3c8 Mon Sep 17 00:00:00 2001 From: zenaku Date: Mon, 25 May 2026 01:08:27 +0000 Subject: [PATCH 15/51] fix connection --- core/services/encrypted_proxy/hysteria_service.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/core/services/encrypted_proxy/hysteria_service.py b/core/services/encrypted_proxy/hysteria_service.py index a425a1b..193e7c7 100644 --- a/core/services/encrypted_proxy/hysteria_service.py +++ b/core/services/encrypted_proxy/hysteria_service.py @@ -1,8 +1,8 @@ +import socket from pathlib import Path from core.Constants import Constants from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner -import socket def _resolve(host: str) -> str: @@ -68,8 +68,7 @@ def build_hysteria_config(username: str, password: str, def enable_hysteria(username: str, password: str, server_host: str, - config_dir: Path, socks5_port: int, - observer=None) -> bool: + socks5_port: int, observer=None) -> bool: real_ip = get_real_ip() runner = SingboxRunner() runner.stop() -- 2.45.2 From 474a6961b36e8a2b28d2d74c1084afa0970fdef8 Mon Sep 17 00:00:00 2001 From: zenaku Date: Mon, 25 May 2026 01:09:38 +0000 Subject: [PATCH 16/51] fix connection --- core/services/encrypted_proxy/vless_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py index 5c0e325..d87eb9a 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -90,7 +90,7 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: } -def enable_vless(vless_link: str, username: str, config_dir: Path, +def enable_vless(vless_link: str, username: str, socks5_port: int, observer=None) -> bool: vless = parse_vless_link(vless_link) real_ip = get_real_ip() -- 2.45.2 From 86621021bcbeba2fa967935b0d9386014a08b78b Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 26 May 2026 16:31:16 -0500 Subject: [PATCH 17/51] add installer --- .env | 11 +- .env.example | 6 + README.md | 56 +- core/Constants.py | 9 +- .../services/encrypted_proxy/vless_service.py | 16 +- core/utils/encrypted_proxy/singbox.py | 150 +++++- install.sh | 235 +++------ lib/input.sh | 32 ++ lib/log.sh | 52 ++ lib/network.sh | 53 ++ lib/steps.sh | 489 ++++++++++++++++++ lib/ui.sh | 114 ++++ requirements.txt | 31 ++ 13 files changed, 1035 insertions(+), 219 deletions(-) create mode 100644 .env.example mode change 100644 => 100755 install.sh create mode 100755 lib/input.sh create mode 100755 lib/log.sh create mode 100755 lib/network.sh create mode 100755 lib/steps.sh create mode 100755 lib/ui.sh create mode 100644 requirements.txt diff --git a/.env b/.env index 904689b..58ed198 100644 --- a/.env +++ b/.env @@ -1,6 +1,7 @@ -# Environment: local | production -APP_ENV=local - -# API URLs +APP_ENV=production SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 -SP_API_BASE_URL_PRODUCTION=https://api.hydraveil.net/api/v1 +SP_API_BASE_URL_PRODUCTION=http://simplifiedprivacy.test/api/v1 + +# SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 +# SP_API_BASE_URL_PRODUCTION=https://api.hydraveil.net/api/v1 +INSTALL_DIR=/home/coltic/Desktop/sp/Gitlab/CORE diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d135e89 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +APP_ENV= +SP_API_BASE_URL_LOCAL= +SP_API_BASE_URL_PRODUCTION= + +# SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 +# SP_API_BASE_URL_PRODUCTION=https://api.hydraveil.net/api/v1 diff --git a/README.md b/README.md index 3eb0336..7a0250f 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,34 @@ -# sp-hydra-veil-core +python3 -c " +from core.services.WebServiceApiService import WebServiceApiService +subscription = WebServiceApiService.post_subscription(2, operator_id=3) +print('billing code:', subscription.billing_code) +" -The `sp-hydra-veil-core` library exposes core logic to higher-level components. +parchear +sudo docker compose exec laravel.test php artisan tinker --execute=" +\$sub = \App\Models\Subscription::orderBy('id', 'desc')->first(); +\$sub->expires_at = '2027-12-31 23:59:59'; +\$sub->duration = 720; +\$sub->payment_reference = 'bypass_' . uniqid(); +\$sub->save(); +echo 'OK' . PHP_EOL; +" +conectar +python3 -c " +from core.services.WebServiceApiService import WebServiceApiService +from core.controllers.encrypted_proxy.VlessController import VlessController +from core.observers.EncryptedProxyObserver import EncryptedProxyObserver -## Build Instructions +observer = EncryptedProxyObserver() +observer.subscribe('connected', lambda e: print('connected:', e.subject)) +observer.subscribe('error', lambda e: print('error:', e.subject)) +observer.subscribe('disconnected', lambda e: print('disconnected:', e.subject)) -### Presumptions +session = WebServiceApiService.post_operator_proxy('617N-LPKB-MVPT-YRQM', 3, 'vless') +print('session:', session) -* Your system is configured to use the Simplified Privacy package registry [1]. +controller = VlessController(1080) +controller.enable(session.links[0], session.username, observer) +" -### Prerequisites -* `build` -* `twine` - -To install them, activate your `venv`, if necessary, and run: - -```bash -pip install build twine -``` - -### Build Steps - -* Activate your `venv`, if necessary, and run: - -```bash -rm -rf ./dist/* -python3 -m build -python3 -m twine upload --repository forgejo ./dist/* -``` - ---- - -[1] https://forgejo.org/docs/v14.0/user/packages/pypi/#configuring-the-package-registry diff --git a/core/Constants.py b/core/Constants.py index f12f6cc..8c2d82e 100644 --- a/core/Constants.py +++ b/core/Constants.py @@ -25,10 +25,11 @@ class Constants: HV_CLIENT_VERSION_NUMBER: Final[str] = os.environ.get('HV_CLIENT_VERSION_NUMBER') HOME: Final[str] = os.path.expanduser('~') SYSTEM_CONFIG_PATH: Final[str] = '/etc' - CACHE_HOME: Final[str] = os.environ.get('XDG_CACHE_HOME', os.path.join(HOME, '.cache')) - CONFIG_HOME: Final[str] = os.environ.get('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) - DATA_HOME: Final[str] = os.environ.get('XDG_DATA_HOME', os.path.join(HOME, '.local/share')) - STATE_HOME: Final[str] = os.environ.get('XDG_STATE_HOME', os.path.join(HOME, '.local/state')) + INSTALL_DIR: Final[str] = os.environ.get('INSTALL_DIR', os.path.expanduser('~')) + CACHE_HOME: Final[str] = os.environ.get('XDG_CACHE_HOME', f'{INSTALL_DIR}/.cache') + CONFIG_HOME: Final[str] = os.environ.get('XDG_CONFIG_HOME', f'{INSTALL_DIR}/.config') + DATA_HOME: Final[str] = os.environ.get('XDG_DATA_HOME', f'{INSTALL_DIR}/data') + STATE_HOME: Final[str] = os.environ.get('XDG_STATE_HOME', f'{INSTALL_DIR}/.state') HV_SYSTEM_CONFIG_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/hydra-veil' HV_CACHE_HOME: Final[str] = f'{CACHE_HOME}/hydra-veil' HV_CONFIG_HOME: Final[str] = f'{CONFIG_HOME}/hydra-veil' diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py index d87eb9a..03cbc40 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -3,6 +3,7 @@ from pathlib import Path from core.Constants import Constants from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner +import socket def parse_vless_link(link: str) -> dict: @@ -24,13 +25,15 @@ def parse_vless_link(link: str) -> dict: "port": int(port), "path": unquote(params.get("path", "/vless")), "sni": sni, - "ws_host": ws_host, + "ws_host": ws_host, "security": params.get("security", "tls"), "network": params.get("type", "ws"), } def build_vless_config(vless: dict, socks5_port: int) -> dict: + server_ip = socket.gethostbyname(vless["host"]) + return { "dns": { "servers": [{"tag": "local", "type": "udp", "server": "9.9.9.9"}], @@ -60,18 +63,18 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: { "type": "vless", "tag": "proxy", - "server": vless["sni"], - "server_port": 443, + "server": server_ip, + "server_port": vless["port"], "uuid": vless["uuid"], "tls": { - "enabled": True, + "enabled": vless["security"] == "tls", "server_name": vless["sni"], "insecure": False, }, "transport": { "type": "ws", "path": vless["path"], - "headers": {"Host": vless["sni"]}, + "headers": {"Host": vless["ws_host"]}, }, }, ], @@ -79,6 +82,7 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: "rules": [ {"protocol": "dns", "action": "hijack-dns"}, {"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"}, + {"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"}, {"ip_is_private": True, "outbound": "direct"}, {"ip_version": 6, "outbound": "block"}, {"inbound": ["tun-in", "socks-in"], "outbound": "proxy"}, @@ -109,7 +113,7 @@ def enable_vless(vless_link: str, username: str, if observer: observer.notify("error", "sing-box not active after start") return False - proxy_ip = verify_ip(socks5_port) + proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0) if observer: observer.notify("connected", { "real_ip": real_ip, diff --git a/core/utils/encrypted_proxy/singbox.py b/core/utils/encrypted_proxy/singbox.py index 5950b41..1969c17 100644 --- a/core/utils/encrypted_proxy/singbox.py +++ b/core/utils/encrypted_proxy/singbox.py @@ -1,35 +1,145 @@ import json +import os import subprocess import time from pathlib import Path +from core.Constants import Constants + class SingboxRunner: - WRAPPER = "/usr/local/bin/hydraveil-singbox" + """ + Gestiona el ciclo de vida de sing-box via wrapper sudo. + + Flujo: + write_config() → start() → is_running() → stop() + + El wrapper es el único binario con NOPASSWD en sudoers. + Nunca se llama sing-box ni ip directamente con sudo desde Python. + """ + + _WRAPPER = Constants.SINGBOX_WRAPPER + _PID_FILE = Path(Constants.SINGBOX_PID_FILE) + _LOG_FILE = Path(Constants.SINGBOX_LOG_FILE) + _CONFIG_DIR = Path(Constants.SINGBOX_CONFIG_DIR) + + _START_TIMEOUT_S = 10.0 + _START_POLL_S = 0.5 + def start(self, config_path: Path) -> bool: + """ + Para cualquier instancia previa y lanza sing-box via wrapper. + Retorna True solo cuando el proceso está confirmado vivo. + """ self.stop() - time.sleep(1) - self._process = subprocess.Popen( - ["sudo", self.WRAPPER, str(config_path)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - time.sleep(2) - return self.is_running() - def stop(self) -> str: - subprocess.run(["sudo", self.WRAPPER, "", "stop"], capture_output=True) - subprocess.run(["sudo", "ip", "link", "delete", "tun0"], - capture_output=True) - self._process = None - return "stopped" + self._assert_config_safe(config_path) + + self._LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + + try: + subprocess.Popen( + ['sudo', self._WRAPPER, str(config_path), 'start'], + stdout=subprocess.DEVNULL, + stderr=open(self._LOG_FILE, 'a'), + stdin=subprocess.DEVNULL, + env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, + ) + except FileNotFoundError: + raise RuntimeError( + f'Wrapper no encontrado: {self._WRAPPER}\n' + 'Ejecutá el installer primero.' + ) + + deadline = time.monotonic() + self._START_TIMEOUT_S + while time.monotonic() < deadline: + time.sleep(self._START_POLL_S) + if self.is_running(): + return True + + error = self.get_last_error() + raise RuntimeError( + f'sing-box no levantó en {self._START_TIMEOUT_S}s.\n' + f'Último error:\n{error}' + ) + + def stop(self) -> None: + + try: + subprocess.run( + ['sudo', self._WRAPPER, '', 'stop'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, + timeout=15, + ) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + self._emergency_kill() def is_running(self) -> bool: - r = subprocess.run(["pgrep", "-f", "sing-box"], capture_output=True) - return r.returncode == 0 + + if not self._PID_FILE.exists(): + return False + try: + pid = int(self._PID_FILE.read_text().strip()) + if pid <= 0: + raise ValueError + os.kill(pid, 0) + return True + except (ValueError, ProcessLookupError): + self._PID_FILE.unlink(missing_ok=True) + return False + except PermissionError: + + return True + + def get_last_error(self) -> str: + try: + lines = self._LOG_FILE.read_text(errors='replace').splitlines() + return '\n'.join(lines[-80:]) + except OSError: + return '' def write_config(self, config_path: Path, config: dict) -> None: - config_path.parent.mkdir(parents=True, exist_ok=True) - with open(config_path, "w") as f: - json.dump(config, f, indent=2) \ No newline at end of file + + self._assert_config_safe(config_path) + + config_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + tmp = config_path.with_suffix('.tmp') + + try: + with open(tmp, 'w') as f: + json.dump(config, f, indent=2) + os.chmod(tmp, 0o600) + tmp.rename(config_path) + except Exception: + tmp.unlink(missing_ok=True) + raise + + # ── Privado ─────────────────────────────────────────────────────────────── + + def _assert_config_safe(self, config_path: Path) -> None: + + try: + config_path.resolve().relative_to(self._CONFIG_DIR.resolve()) + except ValueError: + raise ValueError( + f'Config fuera del directorio permitido.\n' + f' Config: {config_path}\n' + f' Permitido: {self._CONFIG_DIR}' + ) + + def _emergency_kill(self) -> None: + + if not self._PID_FILE.exists(): + return + try: + pid = int(self._PID_FILE.read_text().strip()) + if pid > 0: + os.kill(pid, 9) + except (ValueError, ProcessLookupError, PermissionError, OSError): + pass + finally: + self._PID_FILE.unlink(missing_ok=True) \ No newline at end of file diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 399b90b..1bd0de5 --- a/install.sh +++ b/install.sh @@ -1,173 +1,96 @@ #!/bin/bash -set -e +# install.sh — hydra-veil core installer -REPO_URL="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-core" -BRANCH="core-laravel-proxyTEST" -INSTALL_DIR="$HOME/sp-hydra-veil-core" -SINGBOX_VERSION="1.13.5" -SINGBOX_BIN="/usr/bin/sing-box" -WRAPPER_PATH="/usr/local/bin/hydraveil-singbox" -SUDOERS_PATH="/etc/sudoers.d/hydraveil" -HV_DATA_HOME="$HOME/.local/share/hydra-veil" +set -euo pipefail -echo "=== sp-hydra-veil-core installer ===" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="$SCRIPT_DIR/lib" -# 1. Clone repo -echo "" -echo "[1/6] Cloning repository (branch: $BRANCH)..." -if [ -d "$INSTALL_DIR" ]; then - echo "Directory already exists, removing..." - rm -rf "$INSTALL_DIR" -fi -git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" -cd "$INSTALL_DIR" - -# 2. .env -echo "" -echo "[2/6] Creating .env..." -cat > "$INSTALL_DIR/.env" << 'DOTENV' -SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 -SP_API_BASE_URL_PRODUCTION=https://fake.simplifiedprivacy.org/api/v1 -APP_ENV=production -DOTENV -echo ".env created." - -# 3. venv + dependencies -echo "" -echo "[3/6] Setting up virtual environment..." -python3 -m venv "$INSTALL_DIR/.venv" -source "$INSTALL_DIR/.venv/bin/activate" - -pip install --quiet --upgrade pip - -pip install \ - python-dotenv \ - "cryptography~=46.0.3" \ - "dataclasses-json~=0.6.7" \ - "marshmallow~=3.26.1" \ - "psutil~=7.1.3" \ - "pysocks~=1.7.1" \ - "python-dateutil~=2.9.0.post0" \ - "requests~=2.32.5" \ - "annotated-types==0.7.0" \ - "certifi==2026.4.22" \ - "charset-normalizer==3.4.7" \ - "click==8.3.3" \ - "cytoolz==1.1.0" \ - "eth-hash==0.8.0" \ - "eth-typing==6.0.0" \ - "eth-utils==6.0.0" \ - "idna==3.13" \ - "packaging==26.2" \ - "pathspec==1.1.1" \ - "platformdirs==4.9.6" \ - "py-ecc==8.0.0" \ - "pydantic==2.13.3" \ - "pydantic_core==2.46.3" \ - "pydeps==3.0.6" \ - "pytokens==0.4.1" \ - "stdlib-list==0.12.0" \ - "toolz==1.1.0" \ - "typing-inspect==0.9.0" \ - "typing-inspection==0.4.2" \ - "typing_extensions==4.15.0" \ - "urllib3==2.6.3" - -pip install -e "$INSTALL_DIR" --no-deps -echo "Dependencies installed." - -# 4. sing-box -echo "" -echo "[4/6] Checking sing-box..." - -# Find sing-box in any common location -FOUND_SINGBOX="" -for candidate in /usr/bin/sing-box /usr/local/bin/sing-box /bin/sing-box /usr/local/sbin/sing-box; do - if [ -x "$candidate" ]; then - FOUND_SINGBOX="$candidate" - break +for _lib in ui log input network steps; do + _path="$LIB_DIR/${_lib}.sh" + if [ ! -f "$_path" ]; then + echo "[ERROR] Missing lib: $_path" >&2 + exit 1 fi + source "$_path" done -# Also check PATH -if [ -z "$FOUND_SINGBOX" ]; then - FOUND_SINGBOX=$(command -v sing-box 2>/dev/null || true) -fi +for arg in "$@"; do + case "$arg" in + --no-log) LOG_ENABLED="false" ;; + --local) FORCE_ENV="local" ;; + esac +done -if [ -n "$FOUND_SINGBOX" ]; then - echo "sing-box found at: $FOUND_SINGBOX" - echo "Version: $($FOUND_SINGBOX version | head -1)" - # If not at /usr/bin/sing-box, create symlink - if [ "$FOUND_SINGBOX" != "$SINGBOX_BIN" ]; then - echo "Creating symlink: $FOUND_SINGBOX -> $SINGBOX_BIN" - sudo ln -sf "$FOUND_SINGBOX" "$SINGBOX_BIN" - fi -else - echo "sing-box not found, installing v$SINGBOX_VERSION..." - wget -q "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-amd64.tar.gz" -O /tmp/sing-box.tar.gz - tar -xzf /tmp/sing-box.tar.gz -C /tmp - sudo cp "/tmp/sing-box-${SINGBOX_VERSION}-linux-amd64/sing-box" "$SINGBOX_BIN" - sudo chmod 755 "$SINGBOX_BIN" - rm -rf /tmp/sing-box.tar.gz "/tmp/sing-box-${SINGBOX_VERSION}-linux-amd64" - echo "sing-box installed: $($SINGBOX_BIN version | head -1)" -fi +_log_init -# Final check -if [ ! -x "$SINGBOX_BIN" ]; then - echo "ERROR: sing-box not found at $SINGBOX_BIN after install. Aborting." - exit 1 -fi -echo "sing-box OK at $SINGBOX_BIN" +clear +print_header "HYDRA-VEIL CORE INSTALLER" "v1.0.0" +printf " ${DIM}%s${RESET}\n" "Simplified Privacy" +printf " ${DIM}%s${RESET}\n" "$(date '+%Y-%m-%d %H:%M:%S') — $(uname -n)" +print_divider + +print_section "Pre-flight" +log_info "User: ${USER}" +log_info "Home: ${HOME}" +log_info "Install dir: ${HV_CORE_HOME}" +require_internet +print_divider -# 5. wrapper + sudoers + HV_DATA_HOME echo "" -echo "[5/6] Installing wrapper..." - -sudo tee "$WRAPPER_PATH" > /dev/null << WRAPPER -#!/bin/bash -CONFIG=\$1 -ACTION=\$2 - -if [[ "\$ACTION" == "stop" ]]; then - pkill -9 -f sing-box +label_info "hydra-veil core se instalará en:" +printf " ${BCYAN}%s${RESET}\n\n" "$HV_CORE_HOME" +label_warn "sudo es requerido para sing-box, wrapper y sudoers." +echo "" +if ! ask_confirm "Continuar con la instalación?"; then + log_warn "Instalación cancelada." exit 0 fi - -if [[ "\$CONFIG" != /tmp/*.json ]] && \\ - [[ "\$CONFIG" != /home/*/.local/share/hydra-veil/*.json ]]; then - echo "Error: config path not allowed" - exit 1 -fi - -exec $SINGBOX_BIN run -c "\$CONFIG" -WRAPPER - -sudo chmod 755 "$WRAPPER_PATH" - -# Verify wrapper points to correct sing-box -if grep -q "$SINGBOX_BIN" "$WRAPPER_PATH"; then - echo "Wrapper OK — points to $SINGBOX_BIN" -else - echo "ERROR: wrapper does not reference $SINGBOX_BIN" - exit 1 -fi - -# 6. sudoers echo "" -echo "[6/6] Configuring sudoers..." -if [ ! -f "$SUDOERS_PATH" ]; then - echo "$USER ALL=(ALL) NOPASSWD: $WRAPPER_PATH" | sudo tee "$SUDOERS_PATH" > /dev/null - sudo chmod 440 "$SUDOERS_PATH" - echo "Sudoers configured." -else - echo "Sudoers already exists, skipping." -fi +print_divider -# Create HV_DATA_HOME directory -mkdir -p "$HV_DATA_HOME" -echo "HV_DATA_HOME created: $HV_DATA_HOME" +step_clone_repo +step_create_env +step_python_env +step_singbox +step_wrapper +step_sudoers echo "" -echo "=== Installation complete ===" -echo "Activate the environment with:" -echo " source $INSTALL_DIR/.venv/bin/activate" \ No newline at end of file +print_divider +printf "${BGREEN}" +cat << 'EOF' + _ + _ _|_| _ + | \_ _| | _/ | + | \__|___|__/ | + | | _ _ | | + | || | | || | + \_ || | | || _/ + \_||_| |_||_/ + ______| |______ + _/ \_ + _/ S I M P L I F I E D \_ + _/ P R I V A C Y \_ + | _____ _____ | + | _/ |_ _| \_ | + |_/ |_ _| \_| + \_____/ + +EOF +printf "${RESET}" +print_divider +echo "" +label_ok "hydra-veil core instalado correctamente" +label_ok "Todos los steps completados" +echo "" +label_info "Activar entorno:" +printf " ${CYAN}source %s/.venv/bin/activate${RESET}\n" "$HV_CORE_HOME" +echo "" +label_info "Wrapper disponible en:" +printf " ${CYAN}sudo /usr/local/bin/hydraveil-singbox start${RESET}\n" +echo "" +log_file_path +echo "" +print_divider +echo "" \ No newline at end of file diff --git a/lib/input.sh b/lib/input.sh new file mode 100755 index 0000000..bd80153 --- /dev/null +++ b/lib/input.sh @@ -0,0 +1,32 @@ +#!/bin/bash +ask_input() { + local label="$1" + local default="${2:-}" + local prompt_default="" + [ -n "$default" ] && prompt_default=" ${DIM}[${default}]${RESET}" + + printf " ${BCYAN}?${RESET} ${BWHITE}%s${RESET}%b " "$label" "$prompt_default" + read -r INPUT_RESULT + [ -z "$INPUT_RESULT" ] && INPUT_RESULT="$default" +} + +ask_confirm() { + local label="$1" + local default="${2:-y}" + local hint + if [ "$default" = "y" ]; then hint="${BGREEN}Y${RESET}${DIM}/n${RESET}"; else hint="${DIM}y/${RESET}${BRED}N${RESET}"; fi + + printf " ${BYELLOW}?${RESET} ${BWHITE}%s${RESET} [%b] " "$label" "$hint" + read -r _confirm + _confirm="${_confirm:-$default}" + case "${_confirm,,}" in + y|yes) return 0 ;; + *) return 1 ;; + esac +} + +ask_pause() { + local msg="${1:-Press ENTER to continue...}" + printf " ${DIM}%s${RESET}" "$msg" + read -r +} diff --git a/lib/log.sh b/lib/log.sh new file mode 100755 index 0000000..6609658 --- /dev/null +++ b/lib/log.sh @@ -0,0 +1,52 @@ +#!/bin/bash +LOG_FILE="${LOG_FILE:-/tmp/hydra-veil-core.log}" +LOG_ENABLED="${LOG_ENABLED:-true}" + +_log_init() { + if [ "$LOG_ENABLED" = "true" ]; then + mkdir -p "$(dirname "$LOG_FILE")" + echo "──────────────────────────────────────────" >> "$LOG_FILE" + echo " LOG START: $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE" + echo "──────────────────────────────────────────" >> "$LOG_FILE" + fi +} + +_log_write() { + local level="$1" + local msg="$2" + if [ "$LOG_ENABLED" = "true" ]; then + printf "[%s] [%s] %s\n" "$(date '+%H:%M:%S')" "$level" "$msg" >> "$LOG_FILE" + fi +} + +log_info() { + label_info "$1" + _log_write "INFO " "$1" +} + +log_ok() { + label_ok "$1" + _log_write "OK " "$1" +} + +log_warn() { + label_warn "$1" + _log_write "WARN " "$1" +} + +log_error() { + label_error "$1" + _log_write "ERROR" "$1" +} + +log_step() { + local msg="$1" + local current="$2" + local total="$3" + label_step "$msg" "$current" "$total" + _log_write "STEP " "[$current/$total] $msg" +} + +log_file_path() { + printf " ${DIM}Log file: ${CYAN}%s${RESET}\n" "$LOG_FILE" +} diff --git a/lib/network.sh b/lib/network.sh new file mode 100755 index 0000000..776ca4f --- /dev/null +++ b/lib/network.sh @@ -0,0 +1,53 @@ +#!/bin/bash +NET_CHECK_HOST="${NET_CHECK_HOST:-8.8.8.8}" +NET_CHECK_TIMEOUT="${NET_CHECK_TIMEOUT:-3}" + +check_internet() { + local host="${1:-$NET_CHECK_HOST}" + if ping -c 1 -W "$NET_CHECK_TIMEOUT" "$host" &>/dev/null; then + label_connected "Internet reachable ${DIM}(${host})${RESET}" + _log_write "NET " "Connected: $host" + return 0 + else + label_disconnect "No internet connection ${DIM}(${host})${RESET}" + _log_write "NET " "Disconnected: $host" + return 1 + fi +} + +check_host() { + local host="$1" + local label="${2:-$host}" + if ping -c 1 -W "$NET_CHECK_TIMEOUT" "$host" &>/dev/null 2>&1; then + label_connected "$label" + return 0 + else + label_disconnect "$label" + return 1 + fi +} + +check_url() { + local url="$1" + local label="${2:-$url}" + local http_code + http_code=$(curl -s -o /dev/null -w "%{http_code}" \ + --max-time "$NET_CHECK_TIMEOUT" "$url" 2>/dev/null || echo "000") + + if [[ "$http_code" =~ ^[23] ]]; then + label_connected "$label ${DIM}(HTTP ${http_code})${RESET}" + _log_write "NET " "URL OK [$http_code]: $url" + return 0 + else + label_disconnect "$label ${DIM}(HTTP ${http_code})${RESET}" + _log_write "NET " "URL FAIL [$http_code]: $url" + return 1 + fi +} + +require_internet() { + if ! check_internet; then + log_error "Internet connection required. Aborting." + exit 1 + fi +} diff --git a/lib/steps.sh b/lib/steps.sh new file mode 100755 index 0000000..0d91ce2 --- /dev/null +++ b/lib/steps.sh @@ -0,0 +1,489 @@ +#!/bin/bash +# lib/steps.sh — Install steps for hydra-veil core +# Requiere: lib/ui.sh, lib/log.sh, lib/input.sh, lib/network.sh + +# ─── Ruta canónica fija — todo vive aquí ────────────────────────────────────── +HV_CORE_HOME="$HOME/.local/share/hydra-veil/core" + +# Subdirectorios +HV_REPO_DIR="$HV_CORE_HOME/repo" +HV_VENV_DIR="$HV_CORE_HOME/.venv" +HV_DATA_DIR="$HV_CORE_HOME/data/hydra-veil" +HV_LOG_DIR="$HV_CORE_HOME/logs/hydra-veil" +HV_RUN_DIR="$HV_CORE_HOME/run" +HV_CONFIG_DIR="$HV_DATA_DIR/configs" + +# sing-box +SINGBOX_VERSION="1.13.5" +SINGBOX_BIN="/usr/bin/sing-box" +SINGBOX_PID_FILE="$HV_RUN_DIR/singbox.pid" +SINGBOX_LOG_FILE="$HV_LOG_DIR/singbox.log" + +# Sistema +WRAPPER_PATH="/usr/local/bin/hydraveil-singbox" +SUDOERS_PATH="/etc/sudoers.d/hydraveil" + +REPO_URL="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-core" +BRANCH="core-laravel-proxyTEST" + +TOTAL_STEPS=6 + +# ─── Step 1: Repo ───────────────────────────────────────────────────────────── +step_clone_repo() { + print_section "Repository" + log_step "Cloning repository" 1 $TOTAL_STEPS + + log_info "Destino: ${HV_REPO_DIR}" + + if [ -d "$HV_REPO_DIR/.git" ]; then + log_warn "Repo ya existe en: $HV_REPO_DIR" + if ask_confirm "Eliminar y re-clonar?"; then + start_spinner "Eliminando repo anterior..." + rm -rf "$HV_REPO_DIR" + stop_spinner "Eliminado" + else + log_skip "Re-clone omitido — usando repo existente" + return 0 + fi + fi + + mkdir -p "$HV_CORE_HOME" + chmod 700 "$HV_CORE_HOME" + + start_spinner "Clonando ${REPO_URL}..." + if git clone --branch "$BRANCH" "$REPO_URL" "$HV_REPO_DIR" &>/dev/null; then + stop_spinner "Repositorio clonado → ${HV_REPO_DIR}" + else + stop_spinner + log_error "git clone falló" + exit 1 + fi + + chmod 700 "$HV_REPO_DIR" + log_ok "Permisos aplicados" +} + +# ─── Step 2: .env ───────────────────────────────────────────────────────────── +step_create_env() { + print_section ".env Configuration" + log_step "Building .env from .env.example" 2 $TOTAL_STEPS + + local example_file="$HV_REPO_DIR/.env.example" + local env_file="$HV_REPO_DIR/.env" + + if [ ! -f "$example_file" ]; then + log_error ".env.example no encontrado: $example_file" + exit 1 + fi + + if [ -f "$env_file" ]; then + log_warn ".env ya existe" + if ! ask_confirm "Sobreescribir .env existente?"; then + label_skip ".env sin cambios — omitiendo" + return 0 + fi + fi + + echo "" + printf " ${DIM}Completá cada variable. Enter para dejar vacío.${RESET}\n" + print_divider + + local -a env_lines=() + + while IFS= read -r line || [ -n "$line" ]; do + + # Comentarios y líneas vacías — pasar directo + if [[ -z "$line" || "$line" == \#* ]]; then + env_lines+=("$line") + continue + fi + + local key default_val value + + if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then + key="${BASH_REMATCH[1]}" + default_val="${BASH_REMATCH[2]}" + else + env_lines+=("$line") + continue + fi + + if [ "$key" = "APP_ENV" ]; then + echo "" + printf " ${BCYAN}APP_ENV${RESET} ${DIM}— entorno de la aplicación${RESET}\n" + printf " ${BWHITE}1)${RESET} ${GREEN}local${RESET} ${BWHITE}2)${RESET} ${CYAN}production${RESET}\n" + printf " ${BWHITE}Opción${RESET} ${DIM}[1/2, default=2]:${RESET} " + read -r _choice < /dev/tty + case "$_choice" in + 1) value="local" ;; + *) value="production" ;; + esac + log_ok "APP_ENV=${value}" + + else + local hint="" + [ -n "$default_val" ] && hint="${DIM}(default: ${default_val})${RESET}" + echo "" + printf " ${BWHITE}%s${RESET} %b\n" "$key" "$hint" + printf " ${BCYAN}›${RESET} " + read -r value < /dev/tty + [ -z "$value" ] && value="$default_val" + [ -n "$value" ] && log_ok "${key}=${value}" || label_skip "${key} — vacío" + fi + + env_lines+=("${key}=${value}") + + done < "$example_file" + + # Escribir .env + printf '%s\n' "${env_lines[@]}" > "$env_file" + chmod 600 "$env_file" + + # Agregar paths del installer automáticamente + cat >> "$env_file" << EOF + +# ── Generado por el installer ────────────────────────────────────────────── +HV_CORE_HOME=${HV_CORE_HOME} +INSTALL_DIR=${HV_REPO_DIR} +CORE_DIR=${HV_CORE_HOME} +EOF + + log_ok ".env escrito → ${env_file}" + echo "" + + # Preview ocultando secrets + printf " ${DIM}Preview:${RESET}\n" + while IFS= read -r ln; do + if [[ -z "$ln" || "$ln" == \#* ]]; then + printf " ${DIM}%s${RESET}\n" "$ln" + else + local pk pv + pk="${ln%%=*}" + pv="${ln#*=}" + echo "$pk" | grep -qiE '(secret|token|key|pass)' && pv="****" + printf " ${DIM}%s${RESET}=${CYAN}%s${RESET}\n" "$pk" "$pv" + fi + done < "$env_file" + echo "" +} + +# ─── Step 3: Python venv ────────────────────────────────────────────────────── +step_python_env() { + print_section "Python Environment" + log_step "Setting up virtualenv + dependencies" 3 $TOTAL_STEPS + + if ! command -v python3 &>/dev/null; then + log_error "python3 no encontrado. Instalalo primero." + exit 1 + fi + log_info "Python: $(python3 --version 2>&1)" + + # Crear venv en HV_VENV_DIR (separado del repo) + start_spinner "Creando virtual environment..." + python3 -m venv "$HV_VENV_DIR" + stop_spinner "Virtual environment creado → ${HV_VENV_DIR}" + + source "$HV_VENV_DIR/bin/activate" + + start_spinner "Actualizando pip..." + pip install --quiet --upgrade pip + stop_spinner "pip actualizado" + + local req_file="$HV_REPO_DIR/requirements.txt" + + if [ ! -f "$req_file" ]; then + log_warn "requirements.txt no encontrado — instalando dependencias base" + start_spinner "Instalando dependencias..." + pip install --quiet \ + python-dotenv \ + "cryptography~=46.0.3" \ + "dataclasses-json~=0.6.7" \ + "psutil~=7.1.3" \ + "pysocks~=1.7.1" \ + "requests~=2.32.5" \ + "pydantic==2.13.3" + stop_spinner "Dependencias instaladas" + return 0 + fi + + # Instalar todo de una vez (no paquete por paquete) + # pip resuelve dependencias mejor en un solo lote + log_info "Instalando desde requirements.txt..." + start_spinner "Instalando paquetes..." + if pip install --quiet -r "$req_file"; then + stop_spinner "Paquetes instalados" + else + stop_spinner + log_warn "pip reportó errores — intentando instalar de a uno para identificar el fallo" + local failed=() + while IFS= read -r pkg || [ -n "$pkg" ]; do + pkg="${pkg%%#*}" + pkg="${pkg// /}" + [[ -z "$pkg" ]] && continue + pip install --quiet "$pkg" 2>/dev/null || failed+=("$pkg") + done < "$req_file" + [ "${#failed[@]}" -gt 0 ] && log_warn "Fallaron: ${failed[*]}" + fi + + # Instalar el paquete en modo editable + start_spinner "Instalando paquete (editable)..." + pip install --quiet -e "$HV_REPO_DIR" --no-deps + stop_spinner "Paquete instalado en modo editable" +} + +# ─── Step 4: sing-box ───────────────────────────────────────────────────────── +step_singbox() { + print_section "sing-box" + log_step "Checking sing-box binary" 4 $TOTAL_STEPS + + local found="" + for candidate in /usr/bin/sing-box /usr/local/bin/sing-box /bin/sing-box; do + [ -x "$candidate" ] && found="$candidate" && break + done + [ -z "$found" ] && found=$(command -v sing-box 2>/dev/null || true) + + if [ -n "$found" ]; then + local ver + ver=$("$found" version 2>/dev/null | head -1 || echo "unknown") + log_ok "Encontrado: ${found} (${ver})" + if [ "$found" != "$SINGBOX_BIN" ]; then + sudo ln -sf "$found" "$SINGBOX_BIN" + log_ok "Symlink → ${SINGBOX_BIN}" + fi + else + log_info "sing-box no encontrado — instalando v${SINGBOX_VERSION}..." + local url="https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-amd64.tar.gz" + local tmp_tar="/tmp/sing-box-$$.tar.gz" + local tmp_dir="/tmp/sing-box-$$" + + start_spinner "Descargando sing-box v${SINGBOX_VERSION}..." + if wget -q --timeout=60 "$url" -O "$tmp_tar"; then + stop_spinner "Descarga completa" + else + stop_spinner + rm -f "$tmp_tar" + log_error "Descarga falló: ${url}" + exit 1 + fi + + start_spinner "Extrayendo..." + mkdir -p "$tmp_dir" + tar -xzf "$tmp_tar" -C "$tmp_dir" --strip-components=1 + stop_spinner "Extraído" + + sudo cp "$tmp_dir/sing-box" "$SINGBOX_BIN" + sudo chmod 755 "$SINGBOX_BIN" + rm -rf "$tmp_tar" "$tmp_dir" + log_ok "sing-box instalado: $($SINGBOX_BIN version | head -1)" + fi + + # setcap — sing-box crea TUN sin necesitar root completo + if command -v setcap &>/dev/null; then + sudo setcap cap_net_admin,cap_net_raw+ep "$SINGBOX_BIN" + log_ok "setcap cap_net_admin,cap_net_raw+ep → ${SINGBOX_BIN}" + else + log_warn "setcap no disponible — instalá libcap2-bin para hardening completo" + log_warn "sing-box necesitará sudo completo para TUN hasta que se instale" + fi + + log_ok "sing-box listo → ${SINGBOX_BIN}" +} + +# ─── Step 5: Wrapper ────────────────────────────────────────────────────────── +step_wrapper() { + print_section "Security Wrapper" + log_step "Installing hydraveil-singbox wrapper" 5 $TOTAL_STEPS + + [ -f "$WRAPPER_PATH" ] && sudo rm -f "$WRAPPER_PATH" && log_ok "Wrapper anterior eliminado" + + sudo tee "$WRAPPER_PATH" > /dev/null << WRAPPER +#!/bin/bash +# hydraveil-singbox — wrapper de privilegios para sing-box +# ───────────────────────────────────────────────────────── +# Generado por el installer. NO editar manualmente. +# Solo este binario tiene NOPASSWD en sudoers. +# +# Uso: +# sudo hydraveil-singbox start +# sudo hydraveil-singbox "" stop +set -euo pipefail + +CONFIG="\${1:-}" +ACTION="\${2:-}" + +# ── Paths fijos (escritos al instalar, nunca buscados en runtime) ───────────── +SINGBOX_BIN="${SINGBOX_BIN}" +PID_FILE="${SINGBOX_PID_FILE}" +LOG_FILE="${SINGBOX_LOG_FILE}" +CONFIG_DIR="${HV_CONFIG_DIR}" +STOP_TIMEOUT=8 + +# ── Validar acción ──────────────────────────────────────────────────────────── +if [[ "\$ACTION" != "start" && "\$ACTION" != "stop" ]]; then + echo "Error: acción inválida '\$ACTION'. Uso: start|stop" >&2 + exit 1 +fi + +# ── Helpers ─────────────────────────────────────────────────────────────────── +_pid_running() { + local pid="\$1" + [[ "\$pid" =~ ^[0-9]+$ ]] && kill -0 "\$pid" 2>/dev/null +} + +_delete_tun() { + if ip link show tun0 &>/dev/null 2>&1; then + ip link delete tun0 2>/dev/null || true + echo "[wrapper] tun0 eliminada" >> "\$LOG_FILE" 2>/dev/null || true + fi +} + +_kill_by_pidfile() { + [ -f "\$PID_FILE" ] || return 0 + + local pid + pid=\$(cat "\$PID_FILE" 2>/dev/null || echo "") + + if ! _pid_running "\$pid"; then + rm -f "\$PID_FILE" + return 0 + fi + + # SIGTERM → esperar → SIGKILL si no murió + kill -TERM "\$pid" 2>/dev/null || true + echo "[wrapper] SIGTERM → PID \$pid" >> "\$LOG_FILE" 2>/dev/null || true + + local elapsed=0 + while _pid_running "\$pid" && (( elapsed < STOP_TIMEOUT * 2 )); do + sleep 0.5 + (( elapsed++ )) || true + done + + if _pid_running "\$pid"; then + kill -KILL "\$pid" 2>/dev/null || true + echo "[wrapper] SIGKILL → PID \$pid (no respondió a SIGTERM)" >> "\$LOG_FILE" 2>/dev/null || true + sleep 0.3 + fi + + rm -f "\$PID_FILE" +} + +# ── stop ────────────────────────────────────────────────────────────────────── +if [[ "\$ACTION" == "stop" ]]; then + _kill_by_pidfile + _delete_tun + echo "[wrapper] sing-box detenido — \$(date '+%Y-%m-%d %H:%M:%S')" >> "\$LOG_FILE" 2>/dev/null || true + exit 0 +fi + +# ── start ───────────────────────────────────────────────────────────────────── + +# 1. Config requerido +if [[ -z "\$CONFIG" ]]; then + echo "Error: config requerido para start" >&2 + exit 1 +fi + +# 2. Existe y es legible +if [[ ! -f "\$CONFIG" ]]; then + echo "Error: config no encontrado: \$CONFIG" >&2 + exit 1 +fi +if [[ ! -r "\$CONFIG" ]]; then + echo "Error: config no legible: \$CONFIG" >&2 + exit 1 +fi + +# 3. Path dentro del directorio permitido (evita path traversal) +_real_config=\$(realpath "\$CONFIG") +_real_config_dir=\$(realpath "\$CONFIG_DIR") +if [[ "\$_real_config" != "\$_real_config_dir"/* ]]; then + echo "Error: config fuera del directorio permitido" >&2 + echo " Config: \$_real_config" >&2 + echo " Permitido: \$_real_config_dir" >&2 + exit 1 +fi + +# 4. JSON válido +if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "\$CONFIG" 2>/dev/null; then + echo "Error: JSON inválido: \$CONFIG" >&2 + exit 1 +fi + +# 5. sing-box ejecutable +if [[ ! -x "\$SINGBOX_BIN" ]]; then + echo "Error: sing-box no encontrado: \$SINGBOX_BIN" >&2 + exit 1 +fi + +# 6. Parar instancia previa +_kill_by_pidfile +_delete_tun + +# 7. Directorios +mkdir -p "\$(dirname "\$PID_FILE")" && chmod 700 "\$(dirname "\$PID_FILE")" +mkdir -p "\$(dirname "\$LOG_FILE")" && chmod 700 "\$(dirname "\$LOG_FILE")" + +# 8. Lanzar sing-box +"\$SINGBOX_BIN" run -c "\$CONFIG" >> "\$LOG_FILE" 2>&1 & +_new_pid=\$! + +# 9. Verificar arranque inmediato +sleep 0.3 +if ! _pid_running "\$_new_pid"; then + echo "Error: sing-box terminó inmediatamente. Ver: \$LOG_FILE" >&2 + exit 1 +fi + +# 10. PID file +echo "\$_new_pid" > "\$PID_FILE" +chmod 600 "\$PID_FILE" + +echo "[wrapper] sing-box iniciado — PID \$_new_pid — \$(date '+%Y-%m-%d %H:%M:%S')" >> "\$LOG_FILE" +echo "[wrapper] config: \$_real_config" >> "\$LOG_FILE" +exit 0 +WRAPPER + + sudo chmod 755 "$WRAPPER_PATH" + log_ok "Wrapper instalado → ${WRAPPER_PATH}" +} + +# ─── Step 6: sudoers + directorios ──────────────────────────────────────────── +step_sudoers() { + print_section "Permissions" + log_step "Configurando sudoers + directorios" 6 $TOTAL_STEPS + + local _tmp_sudoers + _tmp_sudoers=$(mktemp) + + cat > "$_tmp_sudoers" << EOF +# hydraveil — generado por installer $(date '+%Y-%m-%d') +# NO editar manualmente +Defaults!${WRAPPER_PATH} !requiretty +${USER} ALL=(root) NOPASSWD: ${WRAPPER_PATH} +EOF + + # Validar con visudo ANTES de instalar — nunca instalar un sudoers roto + if ! sudo visudo -c -f "$_tmp_sudoers" &>/dev/null; then + rm -f "$_tmp_sudoers" + log_error "sudoers inválido — abortando para evitar lockout" + exit 1 + fi + + sudo cp "$_tmp_sudoers" "$SUDOERS_PATH" + sudo chmod 440 "$SUDOERS_PATH" + rm -f "$_tmp_sudoers" + log_ok "sudoers validado e instalado → ${SUDOERS_PATH}" + + # Directorios con permisos correctos + local -a _dirs=( + "$HV_CONFIG_DIR" + "$HV_RUN_DIR" + "$HV_LOG_DIR" + "$HV_DATA_DIR" + ) + for _dir in "${_dirs[@]}"; do + mkdir -p "$_dir" + chmod 700 "$_dir" + log_ok "Directorio → ${_dir}" + done +} \ No newline at end of file diff --git a/lib/ui.sh b/lib/ui.sh new file mode 100755 index 0000000..bb1ed54 --- /dev/null +++ b/lib/ui.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +RESET="\033[0m" +BOLD="\033[1m" +DIM="\033[2m" + +BLACK="\033[0;30m" +RED="\033[0;31m" +GREEN="\033[0;32m" +YELLOW="\033[0;33m" +BLUE="\033[0;34m" +CYAN="\033[0;36m" +WHITE="\033[0;37m" + +BRED="\033[1;31m" +BGREEN="\033[1;32m" +BYELLOW="\033[1;33m" +BBLUE="\033[1;34m" +BCYAN="\033[1;36m" +BWHITE="\033[1;37m" + +print_header() { + local title="${1:-INSTALLER}" + local version="${2:-}" + local width=60 + local line + printf -v line '%*s' "$width" '' + line="${line// /─}" + + echo "" + printf "${BCYAN}${line}${RESET}\n" + printf "${BCYAN} %-56s${RESET}\n" "" + printf "${BCYAN} ${BWHITE}%-54s${BCYAN} ${RESET}\n" "$title ${DIM}${version}${RESET}" + printf "${BCYAN} %-56s${RESET}\n" "" + printf "${BCYAN}${line}${RESET}\n" + echo "" +} + +print_section() { + local label="$1" + echo "" + printf "${DIM}${CYAN}┌─${RESET} ${BWHITE}${label}${RESET}\n" +} + +print_divider() { + printf "${DIM}────────────────────────────────────────────────────────────${RESET}\n" +} + +label_ok() { printf " ${BGREEN}[ OK ]${RESET} %s\n" "$1"; } +label_warn() { printf " ${BYELLOW}[ WARN ]${RESET} %s\n" "$1"; } +label_error() { printf " ${BRED}[ ERROR]${RESET} %s\n" "$1"; } +label_info() { printf " ${BCYAN}[ INFO ]${RESET} %s\n" "$1"; } +label_skip() { printf " ${DIM}[ SKIP ]${RESET} %s\n" "$1"; } +label_simple() { printf " ${DIM}[SIMPLE]${RESET} %s\n" "$1"; } +label_connected() { printf " ${BGREEN}[CONNECTED ]${RESET} %s\n" "$1"; } +label_disconnect() { printf " ${BRED}[DISCONNECTED]${RESET} %s\n" "$1"; } +label_step() { printf " ${BBLUE}[ %-2s/%-2s ]${RESET} %s\n" "$2" "$3" "$1"; } + +progress_bar() { + local current=$1 + local total=$2 + local label="${3:-}" + local width=40 + local filled=$(( current * width / total )) + local empty=$(( width - filled )) + local pct=$(( current * 100 / total )) + + local bar_filled bar_empty + printf -v bar_filled '%*s' "$filled" '' + printf -v bar_empty '%*s' "$empty" '' + bar_filled="${bar_filled// /#}" + bar_empty="${bar_empty// /·}" + + printf "\r ${DIM}[${RESET}${BGREEN}${bar_filled}${RESET}${DIM}${bar_empty}${RESET}${DIM}]${RESET} ${BWHITE}%3d%%${RESET} ${DIM}%s${RESET} " \ + "$pct" "$label" +} + +progress_simulate() { + local steps="${1:-20}" + local label="${2:-loading}" + for i in $(seq 1 "$steps"); do + progress_bar "$i" "$steps" "$label" + sleep 0.04 + done + echo "" +} + +SPINNER_PID="" + +start_spinner() { + local msg="${1:-Processing...}" + local frames=('─' '\\' '│' '/') + ( + local i=0 + while true; do + printf "\r ${BCYAN}${frames[$i]}${RESET} ${DIM}%s${RESET} " "$msg" + i=$(( (i + 1) % 4 )) + sleep 0.1 + done + ) & + SPINNER_PID=$! + disown "$SPINNER_PID" 2>/dev/null +} + +stop_spinner() { + local result_msg="${1:-}" + if [ -n "$SPINNER_PID" ]; then + kill "$SPINNER_PID" 2>/dev/null + wait "$SPINNER_PID" 2>/dev/null + SPINNER_PID="" + fi + printf "\r%*s\r" 60 "" + [ -n "$result_msg" ] && label_ok "$result_msg" +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..01a6b02 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,31 @@ +python-dotenv +cryptography~=46.0.3 +dataclasses-json~=0.6.7 +marshmallow~=3.26.1 +psutil~=7.1.3 +pysocks~=1.7.1 +python-dateutil~=2.9.0.post0 +requests~=2.32.5 +annotated-types==0.7.0 +certifi==2026.4.22 +charset-normalizer==3.4.7 +click==8.3.3 +cytoolz==1.1.0 +eth-hash==0.8.0 +eth-typing==6.0.0 +eth-utils==6.0.0 +idna==3.13 +packaging==26.2 +pathspec==1.1.1 +platformdirs==4.9.6 +py-ecc==8.0.0 +pydantic==2.13.3 +pydantic_core==2.46.3 +pydeps==3.0.6 +pytokens==0.4.1 +stdlib-list==0.12.0 +toolz==1.1.0 +typing-inspect==0.9.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.6.3 \ No newline at end of file -- 2.45.2 From c5c0e9837ad4bb7cb64adf1ec38bf7366439eee4 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 26 May 2026 21:40:33 +0000 Subject: [PATCH 18/51] add .env --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 31df78a..b7e962d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ prototype_client.py .venv __pycache__ dist +.env \ No newline at end of file -- 2.45.2 From 6849f1a46fc47c0342caed1bd629b882d29213d5 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 26 May 2026 21:41:10 +0000 Subject: [PATCH 19/51] Eliminar .env --- .env | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index 58ed198..0000000 --- a/.env +++ /dev/null @@ -1,7 +0,0 @@ -APP_ENV=production -SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 -SP_API_BASE_URL_PRODUCTION=http://simplifiedprivacy.test/api/v1 - -# SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 -# SP_API_BASE_URL_PRODUCTION=https://api.hydraveil.net/api/v1 -INSTALL_DIR=/home/coltic/Desktop/sp/Gitlab/CORE -- 2.45.2 From f38aa3b6f16a4e54ff5b8ed24cf3d4ffeea5d6b4 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 26 May 2026 18:29:24 -0500 Subject: [PATCH 20/51] 26-05-16-6:29 --- {core => app}/Constants.py | 0 {core => app}/Errors.py | 0 {core => app}/Helpers.py | 0 {core => app}/__init__.py | 0 .../controllers/ApplicationController.py | 0 .../ApplicationVersionController.py | 0 {core => app}/controllers/ClientController.py | 0 .../controllers/ClientVersionController.py | 0 .../controllers/ConfigurationController.py | 0 .../controllers/ConnectionController.py | 0 .../controllers/InvoiceController.py | 0 .../controllers/LocationController.py | 0 .../controllers/OperatorController.py | 0 {core => app}/controllers/PolicyController.py | 0 .../controllers/ProfileController.py | 0 .../controllers/SessionStateController.py | 0 .../controllers/SubscriptionController.py | 0 .../controllers/SubscriptionPlanController.py | 0 .../controllers/SystemStateController.py | 0 .../encrypted_proxy/DisableController.py | 0 .../encrypted_proxy/HysteriaController.py | 0 .../encrypted_proxy/VlessController.py | 0 .../controllers/encrypted_proxy/__init__.py | 0 .../tickets/FailedVerificationController.py | 0 .../tickets/TicketPayController.py | 0 .../tickets/TicketPrepController.py | 0 .../tickets/TicketSyncController.py | 0 .../tickets/UseTicketController.py | 0 {core => app}/errors/exceptions.py | 0 {core => app}/errors/get_error_msg.py | 0 {core => app}/errors/logger.py | 0 {core => app}/models/BaseConnection.py | 0 {core => app}/models/BasePolicy.py | 0 {core => app}/models/BaseProfile.py | 0 {core => app}/models/ClientVersion.py | 0 {core => app}/models/Configuration.py | 0 {core => app}/models/Event.py | 0 {core => app}/models/Location.py | 0 {core => app}/models/Model.py | 0 {core => app}/models/Operator.py | 0 {core => app}/models/OperatorProxySession.py | 0 {core => app}/models/Subscription.py | 0 {core => app}/models/SubscriptionPlan.py | 0 {core => app}/models/invoice/Invoice.py | 0 {core => app}/models/invoice/PaymentMethod.py | 0 {core => app}/models/invoice/TicketInvoice.py | 0 .../models/policy/CapabilityPolicy.py | 0 .../models/policy/PrivilegePolicy.py | 0 {core => app}/models/session/Application.py | 0 .../models/session/ApplicationVersion.py | 0 .../models/session/NetworkPortNumbers.py | 0 .../models/session/ProxyConfiguration.py | 0 .../models/session/SessionConnection.py | 0 .../models/session/SessionProfile.py | 0 {core => app}/models/session/SessionState.py | 0 .../models/system/SystemConnection.py | 0 {core => app}/models/system/SystemProfile.py | 0 {core => app}/models/system/SystemState.py | 0 .../observers/ApplicationVersionObserver.py | 0 {core => app}/observers/BaseObserver.py | 0 {core => app}/observers/ClientObserver.py | 0 {core => app}/observers/ConnectionObserver.py | 0 .../observers/EncryptedProxyObserver.py | 0 {core => app}/observers/InvoiceObserver.py | 0 {core => app}/observers/ProfileObserver.py | 0 {core => app}/observers/TicketObserver.py | 0 .../services/WebServiceApiService.py | 0 .../services/crypto/TicketCustomer.py | 0 .../services/crypto/make_commitments.py | 0 .../services/encrypted_proxy/__init__.py | 0 .../encrypted_proxy/disable_service.py | 0 .../encrypted_proxy/hysteria_service.py | 4 +- .../services/encrypted_proxy/vless_service.py | 4 +- .../is_the_key_to_blame.py | 0 .../prep_with_previously_saved_blind_sigs.py | 0 .../test_if_new_key_works.py | 0 .../helpers/does_ticket_file_exist.py | 0 .../get_how_many_profiles_were_ordered.py | 0 .../services/helpers/get_plan_data.py | 0 .../services/helpers/get_value_from_config.py | 0 .../services/helpers/get_which_billing_key.py | 0 .../services/helpers/save_sync_results.py | 0 .../helpers/valid_profile_quantity.py | 0 .../helpers/validate_number_format.py | 0 .../networking/evaluate_networking_problem.py | 0 .../services/networking/extract_domain.py | 0 .../networking/get_connection_type.py | 0 .../networking/get_data_from_server.py | 0 .../services/networking/internet_test.py | 0 .../services/networking/is_dns_problem.py | 0 .../services/networking/is_tor_working.py | 0 {core => app}/services/networking/make_url.py | 0 .../networking/regular_get_request.py | 0 .../networking/regular_post_request.py | 0 .../networking/send_data_to_server.py | 0 {core => app}/services/networking/use_tor.py | 0 .../services/payment_phase/check_if_paid.py | 0 .../payment_phase/extract_payment_details.py | 0 .../save_and_send_intitial_billing.py | 0 .../payment_phase/save_billing_choices.py | 0 .../services/prepare_tickets/get_pub_key.py | 0 .../get_public_key_by_config.py | 0 .../make_sure_pub_key_exists.py | 0 .../prepare_tickets/save_ALL_blind_sigs.py | 0 .../prepare_tickets/send_blind_commitments.py | 0 .../prepare_tickets/setup_ticket_tracker.py | 0 .../ticket_prep_orchestrator.py | 0 .../prepare_tickets/ticket_tracker.py | 0 .../prepare_tickets/unblind_all_tickets.py | 0 .../validate_blind_signatures.py | 0 .../services/using_tickets/send_unblinded.py | 0 .../using_tickets/use_ticket_orchestrator.py | 0 .../utils/basic_operations/does_file_exist.py | 0 .../utils/basic_operations/filter_data.py | 0 .../utils/basic_operations/get_json_keys.py | 0 .../write_or_read_from_json.py | 0 .../write_string_to_text_file.py | 0 .../utils/confirm_its_a_valid_key_choice.py | 0 {core => app}/utils/convert_time.py | 0 .../utils/encrypted_proxy/__init__.py | 0 {core => app}/utils/encrypted_proxy/net.py | 0 .../utils/encrypted_proxy/singbox.py | 84 ++++-- {core => app}/utils/get_data.py | 0 {core => app}/utils/get_raw_string.py | 0 {core => app}/utils/save_data.py | 0 lib/steps.sh | 57 +++- uninstall.sh | 265 ++++++++++++++++++ 127 files changed, 383 insertions(+), 31 deletions(-) rename {core => app}/Constants.py (100%) rename {core => app}/Errors.py (100%) rename {core => app}/Helpers.py (100%) rename {core => app}/__init__.py (100%) rename {core => app}/controllers/ApplicationController.py (100%) rename {core => app}/controllers/ApplicationVersionController.py (100%) rename {core => app}/controllers/ClientController.py (100%) rename {core => app}/controllers/ClientVersionController.py (100%) rename {core => app}/controllers/ConfigurationController.py (100%) rename {core => app}/controllers/ConnectionController.py (100%) rename {core => app}/controllers/InvoiceController.py (100%) rename {core => app}/controllers/LocationController.py (100%) rename {core => app}/controllers/OperatorController.py (100%) rename {core => app}/controllers/PolicyController.py (100%) rename {core => app}/controllers/ProfileController.py (100%) rename {core => app}/controllers/SessionStateController.py (100%) rename {core => app}/controllers/SubscriptionController.py (100%) rename {core => app}/controllers/SubscriptionPlanController.py (100%) rename {core => app}/controllers/SystemStateController.py (100%) rename {core => app}/controllers/encrypted_proxy/DisableController.py (100%) rename {core => app}/controllers/encrypted_proxy/HysteriaController.py (100%) rename {core => app}/controllers/encrypted_proxy/VlessController.py (100%) rename {core => app}/controllers/encrypted_proxy/__init__.py (100%) rename {core => app}/controllers/tickets/FailedVerificationController.py (100%) rename {core => app}/controllers/tickets/TicketPayController.py (100%) rename {core => app}/controllers/tickets/TicketPrepController.py (100%) rename {core => app}/controllers/tickets/TicketSyncController.py (100%) rename {core => app}/controllers/tickets/UseTicketController.py (100%) rename {core => app}/errors/exceptions.py (100%) rename {core => app}/errors/get_error_msg.py (100%) rename {core => app}/errors/logger.py (100%) rename {core => app}/models/BaseConnection.py (100%) rename {core => app}/models/BasePolicy.py (100%) rename {core => app}/models/BaseProfile.py (100%) rename {core => app}/models/ClientVersion.py (100%) rename {core => app}/models/Configuration.py (100%) rename {core => app}/models/Event.py (100%) rename {core => app}/models/Location.py (100%) rename {core => app}/models/Model.py (100%) rename {core => app}/models/Operator.py (100%) rename {core => app}/models/OperatorProxySession.py (100%) rename {core => app}/models/Subscription.py (100%) rename {core => app}/models/SubscriptionPlan.py (100%) rename {core => app}/models/invoice/Invoice.py (100%) rename {core => app}/models/invoice/PaymentMethod.py (100%) rename {core => app}/models/invoice/TicketInvoice.py (100%) rename {core => app}/models/policy/CapabilityPolicy.py (100%) rename {core => app}/models/policy/PrivilegePolicy.py (100%) rename {core => app}/models/session/Application.py (100%) rename {core => app}/models/session/ApplicationVersion.py (100%) rename {core => app}/models/session/NetworkPortNumbers.py (100%) rename {core => app}/models/session/ProxyConfiguration.py (100%) rename {core => app}/models/session/SessionConnection.py (100%) rename {core => app}/models/session/SessionProfile.py (100%) rename {core => app}/models/session/SessionState.py (100%) rename {core => app}/models/system/SystemConnection.py (100%) rename {core => app}/models/system/SystemProfile.py (100%) rename {core => app}/models/system/SystemState.py (100%) rename {core => app}/observers/ApplicationVersionObserver.py (100%) rename {core => app}/observers/BaseObserver.py (100%) rename {core => app}/observers/ClientObserver.py (100%) rename {core => app}/observers/ConnectionObserver.py (100%) rename {core => app}/observers/EncryptedProxyObserver.py (100%) rename {core => app}/observers/InvoiceObserver.py (100%) rename {core => app}/observers/ProfileObserver.py (100%) rename {core => app}/observers/TicketObserver.py (100%) rename {core => app}/services/WebServiceApiService.py (100%) rename {core => app}/services/crypto/TicketCustomer.py (100%) rename {core => app}/services/crypto/make_commitments.py (100%) rename {core => app}/services/encrypted_proxy/__init__.py (100%) rename {core => app}/services/encrypted_proxy/disable_service.py (100%) rename {core => app}/services/encrypted_proxy/hysteria_service.py (97%) rename {core => app}/services/encrypted_proxy/vless_service.py (97%) rename {core => app}/services/failed_verification/is_the_key_to_blame.py (100%) rename {core => app}/services/failed_verification/prep_with_previously_saved_blind_sigs.py (100%) rename {core => app}/services/failed_verification/test_if_new_key_works.py (100%) rename {core => app}/services/helpers/does_ticket_file_exist.py (100%) rename {core => app}/services/helpers/get_how_many_profiles_were_ordered.py (100%) rename {core => app}/services/helpers/get_plan_data.py (100%) rename {core => app}/services/helpers/get_value_from_config.py (100%) rename {core => app}/services/helpers/get_which_billing_key.py (100%) rename {core => app}/services/helpers/save_sync_results.py (100%) rename {core => app}/services/helpers/valid_profile_quantity.py (100%) rename {core => app}/services/helpers/validate_number_format.py (100%) rename {core => app}/services/networking/evaluate_networking_problem.py (100%) rename {core => app}/services/networking/extract_domain.py (100%) rename {core => app}/services/networking/get_connection_type.py (100%) rename {core => app}/services/networking/get_data_from_server.py (100%) rename {core => app}/services/networking/internet_test.py (100%) rename {core => app}/services/networking/is_dns_problem.py (100%) rename {core => app}/services/networking/is_tor_working.py (100%) rename {core => app}/services/networking/make_url.py (100%) rename {core => app}/services/networking/regular_get_request.py (100%) rename {core => app}/services/networking/regular_post_request.py (100%) rename {core => app}/services/networking/send_data_to_server.py (100%) rename {core => app}/services/networking/use_tor.py (100%) rename {core => app}/services/payment_phase/check_if_paid.py (100%) rename {core => app}/services/payment_phase/extract_payment_details.py (100%) rename {core => app}/services/payment_phase/save_and_send_intitial_billing.py (100%) rename {core => app}/services/payment_phase/save_billing_choices.py (100%) rename {core => app}/services/prepare_tickets/get_pub_key.py (100%) rename {core => app}/services/prepare_tickets/get_public_key_by_config.py (100%) rename {core => app}/services/prepare_tickets/make_sure_pub_key_exists.py (100%) rename {core => app}/services/prepare_tickets/save_ALL_blind_sigs.py (100%) rename {core => app}/services/prepare_tickets/send_blind_commitments.py (100%) rename {core => app}/services/prepare_tickets/setup_ticket_tracker.py (100%) rename {core => app}/services/prepare_tickets/ticket_prep_orchestrator.py (100%) rename {core => app}/services/prepare_tickets/ticket_tracker.py (100%) rename {core => app}/services/prepare_tickets/unblind_all_tickets.py (100%) rename {core => app}/services/prepare_tickets/validate_blind_signatures.py (100%) rename {core => app}/services/using_tickets/send_unblinded.py (100%) rename {core => app}/services/using_tickets/use_ticket_orchestrator.py (100%) rename {core => app}/utils/basic_operations/does_file_exist.py (100%) rename {core => app}/utils/basic_operations/filter_data.py (100%) rename {core => app}/utils/basic_operations/get_json_keys.py (100%) rename {core => app}/utils/basic_operations/write_or_read_from_json.py (100%) rename {core => app}/utils/basic_operations/write_string_to_text_file.py (100%) rename {core => app}/utils/confirm_its_a_valid_key_choice.py (100%) rename {core => app}/utils/convert_time.py (100%) rename {core => app}/utils/encrypted_proxy/__init__.py (100%) rename {core => app}/utils/encrypted_proxy/net.py (100%) rename {core => app}/utils/encrypted_proxy/singbox.py (61%) rename {core => app}/utils/get_data.py (100%) rename {core => app}/utils/get_raw_string.py (100%) rename {core => app}/utils/save_data.py (100%) create mode 100755 uninstall.sh diff --git a/core/Constants.py b/app/Constants.py similarity index 100% rename from core/Constants.py rename to app/Constants.py diff --git a/core/Errors.py b/app/Errors.py similarity index 100% rename from core/Errors.py rename to app/Errors.py diff --git a/core/Helpers.py b/app/Helpers.py similarity index 100% rename from core/Helpers.py rename to app/Helpers.py diff --git a/core/__init__.py b/app/__init__.py similarity index 100% rename from core/__init__.py rename to app/__init__.py diff --git a/core/controllers/ApplicationController.py b/app/controllers/ApplicationController.py similarity index 100% rename from core/controllers/ApplicationController.py rename to app/controllers/ApplicationController.py diff --git a/core/controllers/ApplicationVersionController.py b/app/controllers/ApplicationVersionController.py similarity index 100% rename from core/controllers/ApplicationVersionController.py rename to app/controllers/ApplicationVersionController.py diff --git a/core/controllers/ClientController.py b/app/controllers/ClientController.py similarity index 100% rename from core/controllers/ClientController.py rename to app/controllers/ClientController.py diff --git a/core/controllers/ClientVersionController.py b/app/controllers/ClientVersionController.py similarity index 100% rename from core/controllers/ClientVersionController.py rename to app/controllers/ClientVersionController.py diff --git a/core/controllers/ConfigurationController.py b/app/controllers/ConfigurationController.py similarity index 100% rename from core/controllers/ConfigurationController.py rename to app/controllers/ConfigurationController.py diff --git a/core/controllers/ConnectionController.py b/app/controllers/ConnectionController.py similarity index 100% rename from core/controllers/ConnectionController.py rename to app/controllers/ConnectionController.py diff --git a/core/controllers/InvoiceController.py b/app/controllers/InvoiceController.py similarity index 100% rename from core/controllers/InvoiceController.py rename to app/controllers/InvoiceController.py diff --git a/core/controllers/LocationController.py b/app/controllers/LocationController.py similarity index 100% rename from core/controllers/LocationController.py rename to app/controllers/LocationController.py diff --git a/core/controllers/OperatorController.py b/app/controllers/OperatorController.py similarity index 100% rename from core/controllers/OperatorController.py rename to app/controllers/OperatorController.py diff --git a/core/controllers/PolicyController.py b/app/controllers/PolicyController.py similarity index 100% rename from core/controllers/PolicyController.py rename to app/controllers/PolicyController.py diff --git a/core/controllers/ProfileController.py b/app/controllers/ProfileController.py similarity index 100% rename from core/controllers/ProfileController.py rename to app/controllers/ProfileController.py diff --git a/core/controllers/SessionStateController.py b/app/controllers/SessionStateController.py similarity index 100% rename from core/controllers/SessionStateController.py rename to app/controllers/SessionStateController.py diff --git a/core/controllers/SubscriptionController.py b/app/controllers/SubscriptionController.py similarity index 100% rename from core/controllers/SubscriptionController.py rename to app/controllers/SubscriptionController.py diff --git a/core/controllers/SubscriptionPlanController.py b/app/controllers/SubscriptionPlanController.py similarity index 100% rename from core/controllers/SubscriptionPlanController.py rename to app/controllers/SubscriptionPlanController.py diff --git a/core/controllers/SystemStateController.py b/app/controllers/SystemStateController.py similarity index 100% rename from core/controllers/SystemStateController.py rename to app/controllers/SystemStateController.py diff --git a/core/controllers/encrypted_proxy/DisableController.py b/app/controllers/encrypted_proxy/DisableController.py similarity index 100% rename from core/controllers/encrypted_proxy/DisableController.py rename to app/controllers/encrypted_proxy/DisableController.py diff --git a/core/controllers/encrypted_proxy/HysteriaController.py b/app/controllers/encrypted_proxy/HysteriaController.py similarity index 100% rename from core/controllers/encrypted_proxy/HysteriaController.py rename to app/controllers/encrypted_proxy/HysteriaController.py diff --git a/core/controllers/encrypted_proxy/VlessController.py b/app/controllers/encrypted_proxy/VlessController.py similarity index 100% rename from core/controllers/encrypted_proxy/VlessController.py rename to app/controllers/encrypted_proxy/VlessController.py diff --git a/core/controllers/encrypted_proxy/__init__.py b/app/controllers/encrypted_proxy/__init__.py similarity index 100% rename from core/controllers/encrypted_proxy/__init__.py rename to app/controllers/encrypted_proxy/__init__.py diff --git a/core/controllers/tickets/FailedVerificationController.py b/app/controllers/tickets/FailedVerificationController.py similarity index 100% rename from core/controllers/tickets/FailedVerificationController.py rename to app/controllers/tickets/FailedVerificationController.py diff --git a/core/controllers/tickets/TicketPayController.py b/app/controllers/tickets/TicketPayController.py similarity index 100% rename from core/controllers/tickets/TicketPayController.py rename to app/controllers/tickets/TicketPayController.py diff --git a/core/controllers/tickets/TicketPrepController.py b/app/controllers/tickets/TicketPrepController.py similarity index 100% rename from core/controllers/tickets/TicketPrepController.py rename to app/controllers/tickets/TicketPrepController.py diff --git a/core/controllers/tickets/TicketSyncController.py b/app/controllers/tickets/TicketSyncController.py similarity index 100% rename from core/controllers/tickets/TicketSyncController.py rename to app/controllers/tickets/TicketSyncController.py diff --git a/core/controllers/tickets/UseTicketController.py b/app/controllers/tickets/UseTicketController.py similarity index 100% rename from core/controllers/tickets/UseTicketController.py rename to app/controllers/tickets/UseTicketController.py diff --git a/core/errors/exceptions.py b/app/errors/exceptions.py similarity index 100% rename from core/errors/exceptions.py rename to app/errors/exceptions.py diff --git a/core/errors/get_error_msg.py b/app/errors/get_error_msg.py similarity index 100% rename from core/errors/get_error_msg.py rename to app/errors/get_error_msg.py diff --git a/core/errors/logger.py b/app/errors/logger.py similarity index 100% rename from core/errors/logger.py rename to app/errors/logger.py diff --git a/core/models/BaseConnection.py b/app/models/BaseConnection.py similarity index 100% rename from core/models/BaseConnection.py rename to app/models/BaseConnection.py diff --git a/core/models/BasePolicy.py b/app/models/BasePolicy.py similarity index 100% rename from core/models/BasePolicy.py rename to app/models/BasePolicy.py diff --git a/core/models/BaseProfile.py b/app/models/BaseProfile.py similarity index 100% rename from core/models/BaseProfile.py rename to app/models/BaseProfile.py diff --git a/core/models/ClientVersion.py b/app/models/ClientVersion.py similarity index 100% rename from core/models/ClientVersion.py rename to app/models/ClientVersion.py diff --git a/core/models/Configuration.py b/app/models/Configuration.py similarity index 100% rename from core/models/Configuration.py rename to app/models/Configuration.py diff --git a/core/models/Event.py b/app/models/Event.py similarity index 100% rename from core/models/Event.py rename to app/models/Event.py diff --git a/core/models/Location.py b/app/models/Location.py similarity index 100% rename from core/models/Location.py rename to app/models/Location.py diff --git a/core/models/Model.py b/app/models/Model.py similarity index 100% rename from core/models/Model.py rename to app/models/Model.py diff --git a/core/models/Operator.py b/app/models/Operator.py similarity index 100% rename from core/models/Operator.py rename to app/models/Operator.py diff --git a/core/models/OperatorProxySession.py b/app/models/OperatorProxySession.py similarity index 100% rename from core/models/OperatorProxySession.py rename to app/models/OperatorProxySession.py diff --git a/core/models/Subscription.py b/app/models/Subscription.py similarity index 100% rename from core/models/Subscription.py rename to app/models/Subscription.py diff --git a/core/models/SubscriptionPlan.py b/app/models/SubscriptionPlan.py similarity index 100% rename from core/models/SubscriptionPlan.py rename to app/models/SubscriptionPlan.py diff --git a/core/models/invoice/Invoice.py b/app/models/invoice/Invoice.py similarity index 100% rename from core/models/invoice/Invoice.py rename to app/models/invoice/Invoice.py diff --git a/core/models/invoice/PaymentMethod.py b/app/models/invoice/PaymentMethod.py similarity index 100% rename from core/models/invoice/PaymentMethod.py rename to app/models/invoice/PaymentMethod.py diff --git a/core/models/invoice/TicketInvoice.py b/app/models/invoice/TicketInvoice.py similarity index 100% rename from core/models/invoice/TicketInvoice.py rename to app/models/invoice/TicketInvoice.py diff --git a/core/models/policy/CapabilityPolicy.py b/app/models/policy/CapabilityPolicy.py similarity index 100% rename from core/models/policy/CapabilityPolicy.py rename to app/models/policy/CapabilityPolicy.py diff --git a/core/models/policy/PrivilegePolicy.py b/app/models/policy/PrivilegePolicy.py similarity index 100% rename from core/models/policy/PrivilegePolicy.py rename to app/models/policy/PrivilegePolicy.py diff --git a/core/models/session/Application.py b/app/models/session/Application.py similarity index 100% rename from core/models/session/Application.py rename to app/models/session/Application.py diff --git a/core/models/session/ApplicationVersion.py b/app/models/session/ApplicationVersion.py similarity index 100% rename from core/models/session/ApplicationVersion.py rename to app/models/session/ApplicationVersion.py diff --git a/core/models/session/NetworkPortNumbers.py b/app/models/session/NetworkPortNumbers.py similarity index 100% rename from core/models/session/NetworkPortNumbers.py rename to app/models/session/NetworkPortNumbers.py diff --git a/core/models/session/ProxyConfiguration.py b/app/models/session/ProxyConfiguration.py similarity index 100% rename from core/models/session/ProxyConfiguration.py rename to app/models/session/ProxyConfiguration.py diff --git a/core/models/session/SessionConnection.py b/app/models/session/SessionConnection.py similarity index 100% rename from core/models/session/SessionConnection.py rename to app/models/session/SessionConnection.py diff --git a/core/models/session/SessionProfile.py b/app/models/session/SessionProfile.py similarity index 100% rename from core/models/session/SessionProfile.py rename to app/models/session/SessionProfile.py diff --git a/core/models/session/SessionState.py b/app/models/session/SessionState.py similarity index 100% rename from core/models/session/SessionState.py rename to app/models/session/SessionState.py diff --git a/core/models/system/SystemConnection.py b/app/models/system/SystemConnection.py similarity index 100% rename from core/models/system/SystemConnection.py rename to app/models/system/SystemConnection.py diff --git a/core/models/system/SystemProfile.py b/app/models/system/SystemProfile.py similarity index 100% rename from core/models/system/SystemProfile.py rename to app/models/system/SystemProfile.py diff --git a/core/models/system/SystemState.py b/app/models/system/SystemState.py similarity index 100% rename from core/models/system/SystemState.py rename to app/models/system/SystemState.py diff --git a/core/observers/ApplicationVersionObserver.py b/app/observers/ApplicationVersionObserver.py similarity index 100% rename from core/observers/ApplicationVersionObserver.py rename to app/observers/ApplicationVersionObserver.py diff --git a/core/observers/BaseObserver.py b/app/observers/BaseObserver.py similarity index 100% rename from core/observers/BaseObserver.py rename to app/observers/BaseObserver.py diff --git a/core/observers/ClientObserver.py b/app/observers/ClientObserver.py similarity index 100% rename from core/observers/ClientObserver.py rename to app/observers/ClientObserver.py diff --git a/core/observers/ConnectionObserver.py b/app/observers/ConnectionObserver.py similarity index 100% rename from core/observers/ConnectionObserver.py rename to app/observers/ConnectionObserver.py diff --git a/core/observers/EncryptedProxyObserver.py b/app/observers/EncryptedProxyObserver.py similarity index 100% rename from core/observers/EncryptedProxyObserver.py rename to app/observers/EncryptedProxyObserver.py diff --git a/core/observers/InvoiceObserver.py b/app/observers/InvoiceObserver.py similarity index 100% rename from core/observers/InvoiceObserver.py rename to app/observers/InvoiceObserver.py diff --git a/core/observers/ProfileObserver.py b/app/observers/ProfileObserver.py similarity index 100% rename from core/observers/ProfileObserver.py rename to app/observers/ProfileObserver.py diff --git a/core/observers/TicketObserver.py b/app/observers/TicketObserver.py similarity index 100% rename from core/observers/TicketObserver.py rename to app/observers/TicketObserver.py diff --git a/core/services/WebServiceApiService.py b/app/services/WebServiceApiService.py similarity index 100% rename from core/services/WebServiceApiService.py rename to app/services/WebServiceApiService.py diff --git a/core/services/crypto/TicketCustomer.py b/app/services/crypto/TicketCustomer.py similarity index 100% rename from core/services/crypto/TicketCustomer.py rename to app/services/crypto/TicketCustomer.py diff --git a/core/services/crypto/make_commitments.py b/app/services/crypto/make_commitments.py similarity index 100% rename from core/services/crypto/make_commitments.py rename to app/services/crypto/make_commitments.py diff --git a/core/services/encrypted_proxy/__init__.py b/app/services/encrypted_proxy/__init__.py similarity index 100% rename from core/services/encrypted_proxy/__init__.py rename to app/services/encrypted_proxy/__init__.py diff --git a/core/services/encrypted_proxy/disable_service.py b/app/services/encrypted_proxy/disable_service.py similarity index 100% rename from core/services/encrypted_proxy/disable_service.py rename to app/services/encrypted_proxy/disable_service.py diff --git a/core/services/encrypted_proxy/hysteria_service.py b/app/services/encrypted_proxy/hysteria_service.py similarity index 97% rename from core/services/encrypted_proxy/hysteria_service.py rename to app/services/encrypted_proxy/hysteria_service.py index 193e7c7..7695070 100644 --- a/core/services/encrypted_proxy/hysteria_service.py +++ b/app/services/encrypted_proxy/hysteria_service.py @@ -72,7 +72,7 @@ def enable_hysteria(username: str, password: str, server_host: str, real_ip = get_real_ip() runner = SingboxRunner() runner.stop() - config_path = Path(Constants.HV_DATA_HOME) / f"{username}-sing-box.json" + config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json" config = build_hysteria_config(username, password, server_host, socks5_port) try: runner.write_config(config_path, config) @@ -99,4 +99,4 @@ def disable_hysteria(observer=None) -> bool: SingboxRunner().stop() if observer: observer.notify("disconnected", {}) - return True \ No newline at end of file + return True diff --git a/core/services/encrypted_proxy/vless_service.py b/app/services/encrypted_proxy/vless_service.py similarity index 97% rename from core/services/encrypted_proxy/vless_service.py rename to app/services/encrypted_proxy/vless_service.py index 03cbc40..da45a73 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/app/services/encrypted_proxy/vless_service.py @@ -100,7 +100,7 @@ def enable_vless(vless_link: str, username: str, real_ip = get_real_ip() runner = SingboxRunner() runner.stop() - config_path = Path(Constants.HV_DATA_HOME) / f"{username}-sing-box.json" + config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json" config = build_vless_config(vless, socks5_port) try: runner.write_config(config_path, config) @@ -127,4 +127,4 @@ def disable_vless(observer=None) -> bool: SingboxRunner().stop() if observer: observer.notify("disconnected", {}) - return True \ No newline at end of file + return True diff --git a/core/services/failed_verification/is_the_key_to_blame.py b/app/services/failed_verification/is_the_key_to_blame.py similarity index 100% rename from core/services/failed_verification/is_the_key_to_blame.py rename to app/services/failed_verification/is_the_key_to_blame.py diff --git a/core/services/failed_verification/prep_with_previously_saved_blind_sigs.py b/app/services/failed_verification/prep_with_previously_saved_blind_sigs.py similarity index 100% rename from core/services/failed_verification/prep_with_previously_saved_blind_sigs.py rename to app/services/failed_verification/prep_with_previously_saved_blind_sigs.py diff --git a/core/services/failed_verification/test_if_new_key_works.py b/app/services/failed_verification/test_if_new_key_works.py similarity index 100% rename from core/services/failed_verification/test_if_new_key_works.py rename to app/services/failed_verification/test_if_new_key_works.py diff --git a/core/services/helpers/does_ticket_file_exist.py b/app/services/helpers/does_ticket_file_exist.py similarity index 100% rename from core/services/helpers/does_ticket_file_exist.py rename to app/services/helpers/does_ticket_file_exist.py diff --git a/core/services/helpers/get_how_many_profiles_were_ordered.py b/app/services/helpers/get_how_many_profiles_were_ordered.py similarity index 100% rename from core/services/helpers/get_how_many_profiles_were_ordered.py rename to app/services/helpers/get_how_many_profiles_were_ordered.py diff --git a/core/services/helpers/get_plan_data.py b/app/services/helpers/get_plan_data.py similarity index 100% rename from core/services/helpers/get_plan_data.py rename to app/services/helpers/get_plan_data.py diff --git a/core/services/helpers/get_value_from_config.py b/app/services/helpers/get_value_from_config.py similarity index 100% rename from core/services/helpers/get_value_from_config.py rename to app/services/helpers/get_value_from_config.py diff --git a/core/services/helpers/get_which_billing_key.py b/app/services/helpers/get_which_billing_key.py similarity index 100% rename from core/services/helpers/get_which_billing_key.py rename to app/services/helpers/get_which_billing_key.py diff --git a/core/services/helpers/save_sync_results.py b/app/services/helpers/save_sync_results.py similarity index 100% rename from core/services/helpers/save_sync_results.py rename to app/services/helpers/save_sync_results.py diff --git a/core/services/helpers/valid_profile_quantity.py b/app/services/helpers/valid_profile_quantity.py similarity index 100% rename from core/services/helpers/valid_profile_quantity.py rename to app/services/helpers/valid_profile_quantity.py diff --git a/core/services/helpers/validate_number_format.py b/app/services/helpers/validate_number_format.py similarity index 100% rename from core/services/helpers/validate_number_format.py rename to app/services/helpers/validate_number_format.py diff --git a/core/services/networking/evaluate_networking_problem.py b/app/services/networking/evaluate_networking_problem.py similarity index 100% rename from core/services/networking/evaluate_networking_problem.py rename to app/services/networking/evaluate_networking_problem.py diff --git a/core/services/networking/extract_domain.py b/app/services/networking/extract_domain.py similarity index 100% rename from core/services/networking/extract_domain.py rename to app/services/networking/extract_domain.py diff --git a/core/services/networking/get_connection_type.py b/app/services/networking/get_connection_type.py similarity index 100% rename from core/services/networking/get_connection_type.py rename to app/services/networking/get_connection_type.py diff --git a/core/services/networking/get_data_from_server.py b/app/services/networking/get_data_from_server.py similarity index 100% rename from core/services/networking/get_data_from_server.py rename to app/services/networking/get_data_from_server.py diff --git a/core/services/networking/internet_test.py b/app/services/networking/internet_test.py similarity index 100% rename from core/services/networking/internet_test.py rename to app/services/networking/internet_test.py diff --git a/core/services/networking/is_dns_problem.py b/app/services/networking/is_dns_problem.py similarity index 100% rename from core/services/networking/is_dns_problem.py rename to app/services/networking/is_dns_problem.py diff --git a/core/services/networking/is_tor_working.py b/app/services/networking/is_tor_working.py similarity index 100% rename from core/services/networking/is_tor_working.py rename to app/services/networking/is_tor_working.py diff --git a/core/services/networking/make_url.py b/app/services/networking/make_url.py similarity index 100% rename from core/services/networking/make_url.py rename to app/services/networking/make_url.py diff --git a/core/services/networking/regular_get_request.py b/app/services/networking/regular_get_request.py similarity index 100% rename from core/services/networking/regular_get_request.py rename to app/services/networking/regular_get_request.py diff --git a/core/services/networking/regular_post_request.py b/app/services/networking/regular_post_request.py similarity index 100% rename from core/services/networking/regular_post_request.py rename to app/services/networking/regular_post_request.py diff --git a/core/services/networking/send_data_to_server.py b/app/services/networking/send_data_to_server.py similarity index 100% rename from core/services/networking/send_data_to_server.py rename to app/services/networking/send_data_to_server.py diff --git a/core/services/networking/use_tor.py b/app/services/networking/use_tor.py similarity index 100% rename from core/services/networking/use_tor.py rename to app/services/networking/use_tor.py diff --git a/core/services/payment_phase/check_if_paid.py b/app/services/payment_phase/check_if_paid.py similarity index 100% rename from core/services/payment_phase/check_if_paid.py rename to app/services/payment_phase/check_if_paid.py diff --git a/core/services/payment_phase/extract_payment_details.py b/app/services/payment_phase/extract_payment_details.py similarity index 100% rename from core/services/payment_phase/extract_payment_details.py rename to app/services/payment_phase/extract_payment_details.py diff --git a/core/services/payment_phase/save_and_send_intitial_billing.py b/app/services/payment_phase/save_and_send_intitial_billing.py similarity index 100% rename from core/services/payment_phase/save_and_send_intitial_billing.py rename to app/services/payment_phase/save_and_send_intitial_billing.py diff --git a/core/services/payment_phase/save_billing_choices.py b/app/services/payment_phase/save_billing_choices.py similarity index 100% rename from core/services/payment_phase/save_billing_choices.py rename to app/services/payment_phase/save_billing_choices.py diff --git a/core/services/prepare_tickets/get_pub_key.py b/app/services/prepare_tickets/get_pub_key.py similarity index 100% rename from core/services/prepare_tickets/get_pub_key.py rename to app/services/prepare_tickets/get_pub_key.py diff --git a/core/services/prepare_tickets/get_public_key_by_config.py b/app/services/prepare_tickets/get_public_key_by_config.py similarity index 100% rename from core/services/prepare_tickets/get_public_key_by_config.py rename to app/services/prepare_tickets/get_public_key_by_config.py diff --git a/core/services/prepare_tickets/make_sure_pub_key_exists.py b/app/services/prepare_tickets/make_sure_pub_key_exists.py similarity index 100% rename from core/services/prepare_tickets/make_sure_pub_key_exists.py rename to app/services/prepare_tickets/make_sure_pub_key_exists.py diff --git a/core/services/prepare_tickets/save_ALL_blind_sigs.py b/app/services/prepare_tickets/save_ALL_blind_sigs.py similarity index 100% rename from core/services/prepare_tickets/save_ALL_blind_sigs.py rename to app/services/prepare_tickets/save_ALL_blind_sigs.py diff --git a/core/services/prepare_tickets/send_blind_commitments.py b/app/services/prepare_tickets/send_blind_commitments.py similarity index 100% rename from core/services/prepare_tickets/send_blind_commitments.py rename to app/services/prepare_tickets/send_blind_commitments.py diff --git a/core/services/prepare_tickets/setup_ticket_tracker.py b/app/services/prepare_tickets/setup_ticket_tracker.py similarity index 100% rename from core/services/prepare_tickets/setup_ticket_tracker.py rename to app/services/prepare_tickets/setup_ticket_tracker.py diff --git a/core/services/prepare_tickets/ticket_prep_orchestrator.py b/app/services/prepare_tickets/ticket_prep_orchestrator.py similarity index 100% rename from core/services/prepare_tickets/ticket_prep_orchestrator.py rename to app/services/prepare_tickets/ticket_prep_orchestrator.py diff --git a/core/services/prepare_tickets/ticket_tracker.py b/app/services/prepare_tickets/ticket_tracker.py similarity index 100% rename from core/services/prepare_tickets/ticket_tracker.py rename to app/services/prepare_tickets/ticket_tracker.py diff --git a/core/services/prepare_tickets/unblind_all_tickets.py b/app/services/prepare_tickets/unblind_all_tickets.py similarity index 100% rename from core/services/prepare_tickets/unblind_all_tickets.py rename to app/services/prepare_tickets/unblind_all_tickets.py diff --git a/core/services/prepare_tickets/validate_blind_signatures.py b/app/services/prepare_tickets/validate_blind_signatures.py similarity index 100% rename from core/services/prepare_tickets/validate_blind_signatures.py rename to app/services/prepare_tickets/validate_blind_signatures.py diff --git a/core/services/using_tickets/send_unblinded.py b/app/services/using_tickets/send_unblinded.py similarity index 100% rename from core/services/using_tickets/send_unblinded.py rename to app/services/using_tickets/send_unblinded.py diff --git a/core/services/using_tickets/use_ticket_orchestrator.py b/app/services/using_tickets/use_ticket_orchestrator.py similarity index 100% rename from core/services/using_tickets/use_ticket_orchestrator.py rename to app/services/using_tickets/use_ticket_orchestrator.py diff --git a/core/utils/basic_operations/does_file_exist.py b/app/utils/basic_operations/does_file_exist.py similarity index 100% rename from core/utils/basic_operations/does_file_exist.py rename to app/utils/basic_operations/does_file_exist.py diff --git a/core/utils/basic_operations/filter_data.py b/app/utils/basic_operations/filter_data.py similarity index 100% rename from core/utils/basic_operations/filter_data.py rename to app/utils/basic_operations/filter_data.py diff --git a/core/utils/basic_operations/get_json_keys.py b/app/utils/basic_operations/get_json_keys.py similarity index 100% rename from core/utils/basic_operations/get_json_keys.py rename to app/utils/basic_operations/get_json_keys.py diff --git a/core/utils/basic_operations/write_or_read_from_json.py b/app/utils/basic_operations/write_or_read_from_json.py similarity index 100% rename from core/utils/basic_operations/write_or_read_from_json.py rename to app/utils/basic_operations/write_or_read_from_json.py diff --git a/core/utils/basic_operations/write_string_to_text_file.py b/app/utils/basic_operations/write_string_to_text_file.py similarity index 100% rename from core/utils/basic_operations/write_string_to_text_file.py rename to app/utils/basic_operations/write_string_to_text_file.py diff --git a/core/utils/confirm_its_a_valid_key_choice.py b/app/utils/confirm_its_a_valid_key_choice.py similarity index 100% rename from core/utils/confirm_its_a_valid_key_choice.py rename to app/utils/confirm_its_a_valid_key_choice.py diff --git a/core/utils/convert_time.py b/app/utils/convert_time.py similarity index 100% rename from core/utils/convert_time.py rename to app/utils/convert_time.py diff --git a/core/utils/encrypted_proxy/__init__.py b/app/utils/encrypted_proxy/__init__.py similarity index 100% rename from core/utils/encrypted_proxy/__init__.py rename to app/utils/encrypted_proxy/__init__.py diff --git a/core/utils/encrypted_proxy/net.py b/app/utils/encrypted_proxy/net.py similarity index 100% rename from core/utils/encrypted_proxy/net.py rename to app/utils/encrypted_proxy/net.py diff --git a/core/utils/encrypted_proxy/singbox.py b/app/utils/encrypted_proxy/singbox.py similarity index 61% rename from core/utils/encrypted_proxy/singbox.py rename to app/utils/encrypted_proxy/singbox.py index 1969c17..e315ac3 100644 --- a/core/utils/encrypted_proxy/singbox.py +++ b/app/utils/encrypted_proxy/singbox.py @@ -23,9 +23,10 @@ class SingboxRunner: _LOG_FILE = Path(Constants.SINGBOX_LOG_FILE) _CONFIG_DIR = Path(Constants.SINGBOX_CONFIG_DIR) - _START_TIMEOUT_S = 10.0 - _START_POLL_S = 0.5 + _START_TIMEOUT_S = 10.0 + _START_POLL_S = 0.5 + # ── Público ─────────────────────────────────────────────────────────────── def start(self, config_path: Path) -> bool: """ @@ -33,16 +34,14 @@ class SingboxRunner: Retorna True solo cuando el proceso está confirmado vivo. """ self.stop() - self._assert_config_safe(config_path) - - self._LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + self._ensure_log_file() try: subprocess.Popen( ['sudo', self._WRAPPER, str(config_path), 'start'], stdout=subprocess.DEVNULL, - stderr=open(self._LOG_FILE, 'a'), + stderr=subprocess.DEVNULL, # el wrapper escribe el log como root stdin=subprocess.DEVNULL, env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, ) @@ -52,20 +51,23 @@ class SingboxRunner: 'Ejecutá el installer primero.' ) + # Esperar confirmación por PID file, no por tiempo fijo deadline = time.monotonic() + self._START_TIMEOUT_S while time.monotonic() < deadline: time.sleep(self._START_POLL_S) if self.is_running(): return True - error = self.get_last_error() raise RuntimeError( f'sing-box no levantó en {self._START_TIMEOUT_S}s.\n' - f'Último error:\n{error}' + f'Último error:\n{self.get_last_error()}' ) def stop(self) -> None: - + """ + Para sing-box limpiamente via wrapper. + Nunca lanza excepción — stop debe ser siempre seguro de llamar. + """ try: subprocess.run( ['sudo', self._WRAPPER, '', 'stop'], @@ -73,13 +75,17 @@ class SingboxRunner: stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, - timeout=15, + timeout=15, ) except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + # Wrapper no responde — kill directo por PID file como último recurso self._emergency_kill() def is_running(self) -> bool: - + """ + Verifica por PID file + señal 0. + PermissionError significa que el proceso VIVE (corre como root). + """ if not self._PID_FILE.exists(): return False try: @@ -89,26 +95,45 @@ class SingboxRunner: os.kill(pid, 0) return True except (ValueError, ProcessLookupError): + # PID corrupto o proceso muerto — limpiar self._PID_FILE.unlink(missing_ok=True) return False except PermissionError: - + # Proceso vivo pero de root — PermissionError es señal de vida return True def get_last_error(self) -> str: + """ + Lee las últimas 80 líneas del log via sudo para evitar + problemas de permisos (el log lo escribe root via wrapper). + """ try: - lines = self._LOG_FILE.read_text(errors='replace').splitlines() - return '\n'.join(lines[-80:]) - except OSError: - return '' + result = subprocess.run( + ['sudo', self._WRAPPER, '', 'log'], + capture_output=True, + text=True, + timeout=5, + stdin=subprocess.DEVNULL, + env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, + ) + return result.stdout.strip() + except Exception: + # Fallback: leer directo si tenemos permisos + try: + lines = self._LOG_FILE.read_text(errors='replace').splitlines() + return '\n'.join(lines[-80:]) + except OSError: + return '' def write_config(self, config_path: Path, config: dict) -> None: - + """ + Escribe el JSON de forma atómica con permisos 0o600. + Write en .tmp + rename — nunca deja un config a medias. + """ self._assert_config_safe(config_path) - config_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - tmp = config_path.with_suffix('.tmp') + tmp = config_path.with_suffix('.tmp') try: with open(tmp, 'w') as f: json.dump(config, f, indent=2) @@ -120,8 +145,20 @@ class SingboxRunner: # ── Privado ─────────────────────────────────────────────────────────────── - def _assert_config_safe(self, config_path: Path) -> None: + def _ensure_log_file(self) -> None: + """ + Crea el log file con permisos del usuario actual si no existe. + Debe llamarse ANTES de que el wrapper lo tome como root. + """ + self._LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + if not self._LOG_FILE.exists(): + self._LOG_FILE.touch(mode=0o600) + def _assert_config_safe(self, config_path: Path) -> None: + """ + Verifica que el config está dentro del directorio permitido. + Lanza ValueError si no — nunca continúa con path inseguro. + """ try: config_path.resolve().relative_to(self._CONFIG_DIR.resolve()) except ValueError: @@ -132,7 +169,10 @@ class SingboxRunner: ) def _emergency_kill(self) -> None: - + """ + Kill directo por PID file cuando el wrapper no responde. + Último recurso — no usa sudo. + """ if not self._PID_FILE.exists(): return try: @@ -142,4 +182,4 @@ class SingboxRunner: except (ValueError, ProcessLookupError, PermissionError, OSError): pass finally: - self._PID_FILE.unlink(missing_ok=True) \ No newline at end of file + self._PID_FILE.unlink(missing_ok=True) diff --git a/core/utils/get_data.py b/app/utils/get_data.py similarity index 100% rename from core/utils/get_data.py rename to app/utils/get_data.py diff --git a/core/utils/get_raw_string.py b/app/utils/get_raw_string.py similarity index 100% rename from core/utils/get_raw_string.py rename to app/utils/get_raw_string.py diff --git a/core/utils/save_data.py b/app/utils/save_data.py similarity index 100% rename from core/utils/save_data.py rename to app/utils/save_data.py diff --git a/lib/steps.sh b/lib/steps.sh index 0d91ce2..fb93727 100755 --- a/lib/steps.sh +++ b/lib/steps.sh @@ -6,7 +6,7 @@ HV_CORE_HOME="$HOME/.local/share/hydra-veil/core" # Subdirectorios -HV_REPO_DIR="$HV_CORE_HOME/repo" +HV_REPO_DIR="$HV_CORE_HOME" HV_VENV_DIR="$HV_CORE_HOME/.venv" HV_DATA_DIR="$HV_CORE_HOME/data/hydra-veil" HV_LOG_DIR="$HV_CORE_HOME/logs/hydra-veil" @@ -28,6 +28,38 @@ BRANCH="core-laravel-proxyTEST" TOTAL_STEPS=6 +# ─── Helper: crear log file con permisos del usuario ───────────────────────── +# El wrapper corre como root y appendea con >>. Si root crea el archivo primero, +# Python (que corre como usuario) no puede leerlo. Esta función lo crea con +# permisos del usuario ANTES de que el wrapper lo toque por primera vez. +_ensure_log_file() { + local log_file="$1" + local log_dir + log_dir="$(dirname "$log_file")" + + # Crear directorio si no existe (permisos de usuario) + if [ ! -d "$log_dir" ]; then + mkdir -p "$log_dir" + chmod 700 "$log_dir" + fi + + # Crear el archivo solo si no existe — nunca sobreescribir + if [ ! -f "$log_file" ]; then + touch "$log_file" + chmod 600 "$log_file" + fi + + # Verificar que el usuario actual puede leerlo + if [ ! -r "$log_file" ]; then + log_warn "Log file existe pero no es legible por el usuario actual: $log_file" + log_warn "Intentando corregir permisos..." + chmod 600 "$log_file" 2>/dev/null || { + log_error "No se pudieron corregir los permisos de: $log_file" + return 1 + } + fi +} + # ─── Step 1: Repo ───────────────────────────────────────────────────────────── step_clone_repo() { print_section "Repository" @@ -306,6 +338,7 @@ step_wrapper() { # Uso: # sudo hydraveil-singbox start # sudo hydraveil-singbox "" stop +# sudo hydraveil-singbox "" log set -euo pipefail CONFIG="\${1:-}" @@ -319,8 +352,8 @@ CONFIG_DIR="${HV_CONFIG_DIR}" STOP_TIMEOUT=8 # ── Validar acción ──────────────────────────────────────────────────────────── -if [[ "\$ACTION" != "start" && "\$ACTION" != "stop" ]]; then - echo "Error: acción inválida '\$ACTION'. Uso: start|stop" >&2 +if [[ "\$ACTION" != "start" && "\$ACTION" != "stop" && "\$ACTION" != "log" ]]; then + echo "Error: acción inválida '\$ACTION'. Uso: start|stop|log" >&2 exit 1 fi @@ -367,6 +400,12 @@ _kill_by_pidfile() { rm -f "\$PID_FILE" } +# ── log ─────────────────────────────────────────────────────────────────────── +if [[ "\$ACTION" == "log" ]]; then + [ -f "\$LOG_FILE" ] && tail -80 "\$LOG_FILE" || echo "Sin logs disponibles" + exit 0 +fi + # ── stop ────────────────────────────────────────────────────────────────────── if [[ "\$ACTION" == "stop" ]]; then _kill_by_pidfile @@ -419,9 +458,11 @@ fi _kill_by_pidfile _delete_tun -# 7. Directorios +# 7. Directorios — solo mkdir/chmod, nunca tocar el log file existente mkdir -p "\$(dirname "\$PID_FILE")" && chmod 700 "\$(dirname "\$PID_FILE")" mkdir -p "\$(dirname "\$LOG_FILE")" && chmod 700 "\$(dirname "\$LOG_FILE")" +# NOTA: el log file fue creado por el installer con permisos del usuario. +# El wrapper solo appendea (>>) — nunca lo recrea ni cambia sus permisos. # 8. Lanzar sing-box "\$SINGBOX_BIN" run -c "\$CONFIG" >> "\$LOG_FILE" 2>&1 & @@ -486,4 +527,10 @@ EOF chmod 700 "$_dir" log_ok "Directorio → ${_dir}" done -} \ No newline at end of file + + # ── Crear log file con permisos del usuario ANTES de que root lo toque ──── + # Crítico: si root crea el archivo primero (via wrapper), Python no puede + # leerlo. _ensure_log_file() lo crea como usuario con chmod 600 ahora. + _ensure_log_file "$SINGBOX_LOG_FILE" + log_ok "Log file preparado con permisos de usuario → ${SINGBOX_LOG_FILE}" +} diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..c5a9c43 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,265 @@ +#!/bin/bash +# hv-clean.sh — Limpieza completa de hydra-veil para reinstalación limpia +# Elimina: wrapper, sudoers, dirs de runtime (logs/run/pid) +# Conserva: repo, venv, configs (a menos que se pase --full) + +set -euo pipefail + +# ─── Colores ────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +DIM='\033[2m' +BOLD='\033[1m' +RESET='\033[0m' + +ok() { printf " ${GREEN}✔${RESET} %s\n" "$*"; } +warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$*"; } +info() { printf " ${CYAN}→${RESET} %s\n" "$*"; } +err() { printf " ${RED}✖${RESET} %s\n" "$*" >&2; } +hdr() { echo ""; printf "${BOLD}${CYAN}── %s ${RESET}\n" "$*"; echo ""; } + +# ─── Paths (deben coincidir con steps.sh) ──────────────────────────────────── +HV_CORE_HOME="$HOME/.local/share/hydra-veil/core" +HV_LOG_DIR="$HV_CORE_HOME/logs/hydra-veil" +HV_RUN_DIR="$HV_CORE_HOME/run" +HV_DATA_DIR="$HV_CORE_HOME/data/hydra-veil" +HV_CONFIG_DIR="$HV_DATA_DIR/configs" +HV_VENV_DIR="$HV_CORE_HOME/.venv" + +SINGBOX_BIN="/usr/bin/sing-box" +SINGBOX_PID_FILE="$HV_RUN_DIR/singbox.pid" +SINGBOX_LOG_FILE="$HV_LOG_DIR/singbox.log" + +WRAPPER_PATH="/usr/local/bin/hydraveil-singbox" +SUDOERS_PATH="/etc/sudoers.d/hydraveil" + +# ─── Flags ──────────────────────────────────────────────────────────────────── +FULL=false +YES=false + +for arg in "$@"; do + case "$arg" in + --full|-f) FULL=true ;; + --yes|-y) YES=true ;; + --help|-h) + echo "" + echo " Uso: $0 [opciones]" + echo "" + echo " Sin flags — elimina wrapper, sudoers, logs, pid, run" + echo " conserva repo, venv y configs" + echo "" + echo " --full — todo lo anterior + repo completo, venv y configs" + echo " --yes — no pide confirmación" + echo "" + exit 0 + ;; + *) + err "Flag desconocido: $arg" + exit 1 + ;; + esac +done + +# ─── Banner ─────────────────────────────────────────────────────────────────── +echo "" +printf "${BOLD}${RED} hydra-veil — limpieza para reinstalación${RESET}\n" +echo "" + +if $FULL; then + warn "Modo --full: se eliminará TODO incluyendo repo, venv y configs" +else + info "Modo normal: conserva repo, venv y configs" +fi + +echo "" + +# ─── Confirmación ───────────────────────────────────────────────────────────── +if ! $YES; then + printf " ${YELLOW}¿Continuar?${RESET} ${DIM}[s/N]:${RESET} " + read -r _confirm < /dev/tty + case "$_confirm" in + s|S|y|Y) : ;; + *) + echo "" + info "Cancelado." + exit 0 + ;; + esac + echo "" +fi + +# ─── 1. Detener sing-box si está corriendo ──────────────────────────────────── +hdr "1. Proceso sing-box" + +_stop_singbox() { + local pid="" + + # Intentar via pidfile primero + if [ -f "$SINGBOX_PID_FILE" ]; then + pid=$(cat "$SINGBOX_PID_FILE" 2>/dev/null || echo "") + fi + + # Si no hay pidfile, buscar por nombre + if [[ -z "$pid" || ! "$pid" =~ ^[0-9]+$ ]]; then + pid=$(pgrep -x sing-box 2>/dev/null | head -1 || echo "") + fi + + if [[ -n "$pid" && "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then + info "Deteniendo sing-box PID $pid..." + kill -TERM "$pid" 2>/dev/null || true + local elapsed=0 + while kill -0 "$pid" 2>/dev/null && (( elapsed < 16 )); do + sleep 0.5 + (( elapsed++ )) || true + done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || true + sleep 0.3 + ok "sing-box eliminado (SIGKILL)" + else + ok "sing-box detenido (SIGTERM)" + fi + else + info "sing-box no está corriendo" + fi +} + +# Intentar via wrapper si existe, sino directo +if [ -x "$WRAPPER_PATH" ]; then + sudo "$WRAPPER_PATH" "" stop 2>/dev/null && ok "Detenido via wrapper" || _stop_singbox +else + _stop_singbox +fi + +# Eliminar interfaz tun0 si existe +if ip link show tun0 &>/dev/null 2>&1; then + sudo ip link delete tun0 2>/dev/null && ok "tun0 eliminada" || warn "No se pudo eliminar tun0" +else + info "tun0 no existe" +fi + +# ─── 2. Wrapper ─────────────────────────────────────────────────────────────── +hdr "2. Wrapper" + +if [ -f "$WRAPPER_PATH" ]; then + sudo rm -f "$WRAPPER_PATH" + ok "Eliminado: $WRAPPER_PATH" +else + info "No existe: $WRAPPER_PATH" +fi + +# ─── 3. Sudoers ─────────────────────────────────────────────────────────────── +hdr "3. Sudoers" + +if [ -f "$SUDOERS_PATH" ]; then + sudo rm -f "$SUDOERS_PATH" + ok "Eliminado: $SUDOERS_PATH" +else + info "No existe: $SUDOERS_PATH" +fi + +# Verificar que no haya entradas huérfanas en sudoers.d +_orphans=$(sudo grep -rl "hydraveil" /etc/sudoers.d/ 2>/dev/null || true) +if [ -n "$_orphans" ]; then + warn "Entradas huérfanas de hydraveil encontradas:" + echo "$_orphans" | while read -r f; do + warn " $f" + sudo rm -f "$f" && ok " Eliminado: $f" + done +else + info "Sin entradas huérfanas en sudoers.d" +fi + +# ─── 4. PID file y runtime ──────────────────────────────────────────────────── +hdr "4. Runtime (pid / run)" + +if [ -f "$SINGBOX_PID_FILE" ]; then + rm -f "$SINGBOX_PID_FILE" + ok "Eliminado PID file: $SINGBOX_PID_FILE" +else + info "Sin PID file" +fi + +if [ -d "$HV_RUN_DIR" ]; then + rm -rf "$HV_RUN_DIR" + ok "Eliminado: $HV_RUN_DIR" +else + info "No existe: $HV_RUN_DIR" +fi + +# ─── 5. Logs ────────────────────────────────────────────────────────────────── +hdr "5. Logs" + +if [ -d "$HV_LOG_DIR" ]; then + rm -rf "$HV_LOG_DIR" + ok "Eliminado: $HV_LOG_DIR" +else + info "No existe: $HV_LOG_DIR" +fi + +# ─── 6. Limpieza completa (--full) ──────────────────────────────────────────── +if $FULL; then + hdr "6. Limpieza completa (--full)" + + # Configs + if [ -d "$HV_CONFIG_DIR" ]; then + rm -rf "$HV_CONFIG_DIR" + ok "Eliminado: $HV_CONFIG_DIR" + else + info "No existe: $HV_CONFIG_DIR" + fi + + # Data dir completo + if [ -d "$HV_DATA_DIR" ]; then + rm -rf "$HV_DATA_DIR" + ok "Eliminado: $HV_DATA_DIR" + else + info "No existe: $HV_DATA_DIR" + fi + + # Venv + if [ -d "$HV_VENV_DIR" ]; then + rm -rf "$HV_VENV_DIR" + ok "Eliminado venv: $HV_VENV_DIR" + else + info "No existe: $HV_VENV_DIR" + fi + + # Repo completo + if [ -d "$HV_CORE_HOME/.git" ]; then + rm -rf "$HV_CORE_HOME" + ok "Eliminado repo: $HV_CORE_HOME" + elif [ -d "$HV_CORE_HOME" ]; then + rm -rf "$HV_CORE_HOME" + ok "Eliminado directorio: $HV_CORE_HOME" + else + info "No existe: $HV_CORE_HOME" + fi + + # sing-box binary (solo si se instaló por el installer, no si era preexistente) + if [ -L "$SINGBOX_BIN" ]; then + sudo rm -f "$SINGBOX_BIN" + ok "Eliminado symlink sing-box: $SINGBOX_BIN" + else + info "sing-box no es symlink — conservado: $SINGBOX_BIN" + fi +fi + +# ─── Resumen ────────────────────────────────────────────────────────────────── +echo "" +printf "${BOLD}${GREEN} ✔ Limpieza completa.${RESET}" + +if $FULL; then + printf " ${DIM}(modo --full)${RESET}\n" +else + printf "\n" + echo "" + printf " ${DIM}Conservados: repo, venv, configs${RESET}\n" + printf " ${DIM}Para eliminar todo: %s --full${RESET}\n" "$0" +fi + +echo "" +info "Podés correr el installer nuevamente sin conflictos." +echo "" -- 2.45.2 From 693c3404ade2531d2469a2c5a7c2b83d657173b4 Mon Sep 17 00:00:00 2001 From: zenaku Date: Fri, 29 May 2026 13:05:19 +0000 Subject: [PATCH 21/51] Actualizar pyproject.toml --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d691de0..ed6412f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ dependencies = [ "cryptography ~= 46.0.3", "dataclasses-json ~= 0.6.7", "marshmallow ~= 3.26.1", - "psutil ~= 7.1.3", "pysocks ~= 1.7.1", "python-dateutil ~= 2.9.0.post0", "requests ~= 2.32.5", -- 2.45.2 From 047c5b2ed0b554c7f8a1adfea1eb9d9e83b8f49c Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 07:19:17 -0500 Subject: [PATCH 22/51] all support, core,cli,gui,essentiials --- README.md | 72 ++- app/Constants.py | 50 -- app/utils/encrypted_proxy/net.py | 44 -- core/Constants.py | 70 +++ {app => core}/Errors.py | 0 {app => core}/Helpers.py | 0 {app => core}/__init__.py | 0 .../controllers/ApplicationController.py | 0 .../ApplicationVersionController.py | 0 {app => core}/controllers/ClientController.py | 0 .../controllers/ClientVersionController.py | 0 .../controllers/ConfigurationController.py | 0 .../controllers/ConnectionController.py | 0 .../controllers/InvoiceController.py | 0 .../controllers/LocationController.py | 0 .../controllers/OperatorController.py | 0 {app => core}/controllers/PolicyController.py | 0 .../controllers/ProfileController.py | 0 .../controllers/SessionStateController.py | 0 .../controllers/SubscriptionController.py | 0 .../controllers/SubscriptionPlanController.py | 0 .../controllers/SystemStateController.py | 0 .../encrypted_proxy/DisableController.py | 0 .../encrypted_proxy/HysteriaController.py | 0 .../encrypted_proxy/VlessController.py | 0 .../controllers/encrypted_proxy/__init__.py | 0 .../tickets/FailedVerificationController.py | 0 .../tickets/TicketPayController.py | 0 .../tickets/TicketPrepController.py | 0 .../tickets/TicketSyncController.py | 0 .../tickets/UseTicketController.py | 0 {app => core}/errors/exceptions.py | 0 {app => core}/errors/get_error_msg.py | 0 {app => core}/errors/logger.py | 0 {app => core}/models/BaseConnection.py | 0 {app => core}/models/BasePolicy.py | 0 {app => core}/models/BaseProfile.py | 0 {app => core}/models/ClientVersion.py | 0 {app => core}/models/Configuration.py | 0 {app => core}/models/Event.py | 0 {app => core}/models/Location.py | 0 {app => core}/models/Model.py | 0 {app => core}/models/Operator.py | 0 {app => core}/models/OperatorProxySession.py | 0 {app => core}/models/Subscription.py | 0 {app => core}/models/SubscriptionPlan.py | 0 {app => core}/models/invoice/Invoice.py | 0 {app => core}/models/invoice/PaymentMethod.py | 0 {app => core}/models/invoice/TicketInvoice.py | 0 .../models/policy/CapabilityPolicy.py | 0 .../models/policy/PrivilegePolicy.py | 0 {app => core}/models/session/Application.py | 0 .../models/session/ApplicationVersion.py | 0 .../models/session/NetworkPortNumbers.py | 0 .../models/session/ProxyConfiguration.py | 0 .../models/session/SessionConnection.py | 0 .../models/session/SessionProfile.py | 0 {app => core}/models/session/SessionState.py | 0 .../models/system/SystemConnection.py | 0 {app => core}/models/system/SystemProfile.py | 0 {app => core}/models/system/SystemState.py | 0 .../observers/ApplicationVersionObserver.py | 0 {app => core}/observers/BaseObserver.py | 0 {app => core}/observers/ClientObserver.py | 0 {app => core}/observers/ConnectionObserver.py | 0 .../observers/EncryptedProxyObserver.py | 0 {app => core}/observers/InvoiceObserver.py | 0 {app => core}/observers/ProfileObserver.py | 0 {app => core}/observers/TicketObserver.py | 0 .../services/WebServiceApiService.py | 0 .../services/crypto/TicketCustomer.py | 0 .../services/crypto/make_commitments.py | 0 .../services/encrypted_proxy/__init__.py | 0 .../encrypted_proxy/disable_service.py | 0 .../encrypted_proxy/hysteria_service.py | 10 +- .../services/encrypted_proxy/vless_service.py | 22 +- .../is_the_key_to_blame.py | 0 .../prep_with_previously_saved_blind_sigs.py | 0 .../test_if_new_key_works.py | 0 .../helpers/does_ticket_file_exist.py | 0 .../get_how_many_profiles_were_ordered.py | 0 .../services/helpers/get_plan_data.py | 0 .../services/helpers/get_value_from_config.py | 0 .../services/helpers/get_which_billing_key.py | 0 .../services/helpers/save_sync_results.py | 0 .../helpers/valid_profile_quantity.py | 0 .../helpers/validate_number_format.py | 0 .../networking/evaluate_networking_problem.py | 0 .../services/networking/extract_domain.py | 0 .../networking/get_connection_type.py | 0 .../networking/get_data_from_server.py | 0 .../services/networking/internet_test.py | 0 .../services/networking/is_dns_problem.py | 0 .../services/networking/is_tor_working.py | 0 {app => core}/services/networking/make_url.py | 0 .../networking/regular_get_request.py | 0 .../networking/regular_post_request.py | 0 .../networking/send_data_to_server.py | 0 {app => core}/services/networking/use_tor.py | 0 .../services/payment_phase/check_if_paid.py | 0 .../payment_phase/extract_payment_details.py | 0 .../save_and_send_intitial_billing.py | 0 .../payment_phase/save_billing_choices.py | 0 .../services/prepare_tickets/get_pub_key.py | 0 .../get_public_key_by_config.py | 0 .../make_sure_pub_key_exists.py | 0 .../prepare_tickets/save_ALL_blind_sigs.py | 0 .../prepare_tickets/send_blind_commitments.py | 0 .../prepare_tickets/setup_ticket_tracker.py | 0 .../ticket_prep_orchestrator.py | 0 .../prepare_tickets/ticket_tracker.py | 0 .../prepare_tickets/unblind_all_tickets.py | 0 .../validate_blind_signatures.py | 0 .../services/using_tickets/send_unblinded.py | 0 .../using_tickets/use_ticket_orchestrator.py | 0 .../utils/basic_operations/does_file_exist.py | 0 .../utils/basic_operations/filter_data.py | 0 .../utils/basic_operations/get_json_keys.py | 0 .../write_or_read_from_json.py | 0 .../write_string_to_text_file.py | 0 .../utils/confirm_its_a_valid_key_choice.py | 0 {app => core}/utils/convert_time.py | 0 .../utils/encrypted_proxy/__init__.py | 0 core/utils/encrypted_proxy/net.py | 75 +++ .../utils/encrypted_proxy/singbox.py | 34 +- {app => core}/utils/get_data.py | 0 {app => core}/utils/get_raw_string.py | 0 {app => core}/utils/save_data.py | 0 data/hydra-veil/storage.db | Bin 0 -> 86016 bytes data/hydra-veil/ticket_data/logs/errors.log | 0 install.sh | 26 +- lib/steps.sh | 576 ++---------------- lib/steps/preflight.sh | 46 ++ lib/steps/python_env.sh | 69 +++ lib/steps/repos.sh | 96 +++ lib/steps/singbox.sh | 96 +++ lib/steps/sudoers.sh | 69 +++ lib/steps/wrapper.sh | 140 +++++ logs/hydra-veil/singbox.log | 2 + 139 files changed, 850 insertions(+), 647 deletions(-) delete mode 100644 app/Constants.py delete mode 100644 app/utils/encrypted_proxy/net.py create mode 100644 core/Constants.py rename {app => core}/Errors.py (100%) rename {app => core}/Helpers.py (100%) rename {app => core}/__init__.py (100%) rename {app => core}/controllers/ApplicationController.py (100%) rename {app => core}/controllers/ApplicationVersionController.py (100%) rename {app => core}/controllers/ClientController.py (100%) rename {app => core}/controllers/ClientVersionController.py (100%) rename {app => core}/controllers/ConfigurationController.py (100%) rename {app => core}/controllers/ConnectionController.py (100%) rename {app => core}/controllers/InvoiceController.py (100%) rename {app => core}/controllers/LocationController.py (100%) rename {app => core}/controllers/OperatorController.py (100%) rename {app => core}/controllers/PolicyController.py (100%) rename {app => core}/controllers/ProfileController.py (100%) rename {app => core}/controllers/SessionStateController.py (100%) rename {app => core}/controllers/SubscriptionController.py (100%) rename {app => core}/controllers/SubscriptionPlanController.py (100%) rename {app => core}/controllers/SystemStateController.py (100%) rename {app => core}/controllers/encrypted_proxy/DisableController.py (100%) rename {app => core}/controllers/encrypted_proxy/HysteriaController.py (100%) rename {app => core}/controllers/encrypted_proxy/VlessController.py (100%) rename {app => core}/controllers/encrypted_proxy/__init__.py (100%) rename {app => core}/controllers/tickets/FailedVerificationController.py (100%) rename {app => core}/controllers/tickets/TicketPayController.py (100%) rename {app => core}/controllers/tickets/TicketPrepController.py (100%) rename {app => core}/controllers/tickets/TicketSyncController.py (100%) rename {app => core}/controllers/tickets/UseTicketController.py (100%) rename {app => core}/errors/exceptions.py (100%) rename {app => core}/errors/get_error_msg.py (100%) rename {app => core}/errors/logger.py (100%) rename {app => core}/models/BaseConnection.py (100%) rename {app => core}/models/BasePolicy.py (100%) rename {app => core}/models/BaseProfile.py (100%) rename {app => core}/models/ClientVersion.py (100%) rename {app => core}/models/Configuration.py (100%) rename {app => core}/models/Event.py (100%) rename {app => core}/models/Location.py (100%) rename {app => core}/models/Model.py (100%) rename {app => core}/models/Operator.py (100%) rename {app => core}/models/OperatorProxySession.py (100%) rename {app => core}/models/Subscription.py (100%) rename {app => core}/models/SubscriptionPlan.py (100%) rename {app => core}/models/invoice/Invoice.py (100%) rename {app => core}/models/invoice/PaymentMethod.py (100%) rename {app => core}/models/invoice/TicketInvoice.py (100%) rename {app => core}/models/policy/CapabilityPolicy.py (100%) rename {app => core}/models/policy/PrivilegePolicy.py (100%) rename {app => core}/models/session/Application.py (100%) rename {app => core}/models/session/ApplicationVersion.py (100%) rename {app => core}/models/session/NetworkPortNumbers.py (100%) rename {app => core}/models/session/ProxyConfiguration.py (100%) rename {app => core}/models/session/SessionConnection.py (100%) rename {app => core}/models/session/SessionProfile.py (100%) rename {app => core}/models/session/SessionState.py (100%) rename {app => core}/models/system/SystemConnection.py (100%) rename {app => core}/models/system/SystemProfile.py (100%) rename {app => core}/models/system/SystemState.py (100%) rename {app => core}/observers/ApplicationVersionObserver.py (100%) rename {app => core}/observers/BaseObserver.py (100%) rename {app => core}/observers/ClientObserver.py (100%) rename {app => core}/observers/ConnectionObserver.py (100%) rename {app => core}/observers/EncryptedProxyObserver.py (100%) rename {app => core}/observers/InvoiceObserver.py (100%) rename {app => core}/observers/ProfileObserver.py (100%) rename {app => core}/observers/TicketObserver.py (100%) rename {app => core}/services/WebServiceApiService.py (100%) rename {app => core}/services/crypto/TicketCustomer.py (100%) rename {app => core}/services/crypto/make_commitments.py (100%) rename {app => core}/services/encrypted_proxy/__init__.py (100%) rename {app => core}/services/encrypted_proxy/disable_service.py (100%) rename {app => core}/services/encrypted_proxy/hysteria_service.py (91%) rename {app => core}/services/encrypted_proxy/vless_service.py (88%) rename {app => core}/services/failed_verification/is_the_key_to_blame.py (100%) rename {app => core}/services/failed_verification/prep_with_previously_saved_blind_sigs.py (100%) rename {app => core}/services/failed_verification/test_if_new_key_works.py (100%) rename {app => core}/services/helpers/does_ticket_file_exist.py (100%) rename {app => core}/services/helpers/get_how_many_profiles_were_ordered.py (100%) rename {app => core}/services/helpers/get_plan_data.py (100%) rename {app => core}/services/helpers/get_value_from_config.py (100%) rename {app => core}/services/helpers/get_which_billing_key.py (100%) rename {app => core}/services/helpers/save_sync_results.py (100%) rename {app => core}/services/helpers/valid_profile_quantity.py (100%) rename {app => core}/services/helpers/validate_number_format.py (100%) rename {app => core}/services/networking/evaluate_networking_problem.py (100%) rename {app => core}/services/networking/extract_domain.py (100%) rename {app => core}/services/networking/get_connection_type.py (100%) rename {app => core}/services/networking/get_data_from_server.py (100%) rename {app => core}/services/networking/internet_test.py (100%) rename {app => core}/services/networking/is_dns_problem.py (100%) rename {app => core}/services/networking/is_tor_working.py (100%) rename {app => core}/services/networking/make_url.py (100%) rename {app => core}/services/networking/regular_get_request.py (100%) rename {app => core}/services/networking/regular_post_request.py (100%) rename {app => core}/services/networking/send_data_to_server.py (100%) rename {app => core}/services/networking/use_tor.py (100%) rename {app => core}/services/payment_phase/check_if_paid.py (100%) rename {app => core}/services/payment_phase/extract_payment_details.py (100%) rename {app => core}/services/payment_phase/save_and_send_intitial_billing.py (100%) rename {app => core}/services/payment_phase/save_billing_choices.py (100%) rename {app => core}/services/prepare_tickets/get_pub_key.py (100%) rename {app => core}/services/prepare_tickets/get_public_key_by_config.py (100%) rename {app => core}/services/prepare_tickets/make_sure_pub_key_exists.py (100%) rename {app => core}/services/prepare_tickets/save_ALL_blind_sigs.py (100%) rename {app => core}/services/prepare_tickets/send_blind_commitments.py (100%) rename {app => core}/services/prepare_tickets/setup_ticket_tracker.py (100%) rename {app => core}/services/prepare_tickets/ticket_prep_orchestrator.py (100%) rename {app => core}/services/prepare_tickets/ticket_tracker.py (100%) rename {app => core}/services/prepare_tickets/unblind_all_tickets.py (100%) rename {app => core}/services/prepare_tickets/validate_blind_signatures.py (100%) rename {app => core}/services/using_tickets/send_unblinded.py (100%) rename {app => core}/services/using_tickets/use_ticket_orchestrator.py (100%) rename {app => core}/utils/basic_operations/does_file_exist.py (100%) rename {app => core}/utils/basic_operations/filter_data.py (100%) rename {app => core}/utils/basic_operations/get_json_keys.py (100%) rename {app => core}/utils/basic_operations/write_or_read_from_json.py (100%) rename {app => core}/utils/basic_operations/write_string_to_text_file.py (100%) rename {app => core}/utils/confirm_its_a_valid_key_choice.py (100%) rename {app => core}/utils/convert_time.py (100%) rename {app => core}/utils/encrypted_proxy/__init__.py (100%) create mode 100644 core/utils/encrypted_proxy/net.py rename {app => core}/utils/encrypted_proxy/singbox.py (85%) rename {app => core}/utils/get_data.py (100%) rename {app => core}/utils/get_raw_string.py (100%) rename {app => core}/utils/save_data.py (100%) create mode 100644 data/hydra-veil/storage.db create mode 100644 data/hydra-veil/ticket_data/logs/errors.log create mode 100644 lib/steps/preflight.sh create mode 100644 lib/steps/python_env.sh create mode 100644 lib/steps/repos.sh create mode 100644 lib/steps/singbox.sh create mode 100644 lib/steps/sudoers.sh create mode 100644 lib/steps/wrapper.sh create mode 100644 logs/hydra-veil/singbox.log diff --git a/README.md b/README.md index 7a0250f..23e1148 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,29 @@ +# hydra-veil — usage + +## 1. Create billing code / Crear billing code + +```python python3 -c " from core.services.WebServiceApiService import WebServiceApiService -subscription = WebServiceApiService.post_subscription(2, operator_id=3) +subscription = WebServiceApiService.post_subscription(2, operator_id=) print('billing code:', subscription.billing_code) " +``` -parchear +> Copy the printed `billing_code` — you'll need it in the connect step. +> Copiá el `billing_code` impreso, lo vas a necesitar en el paso de conexión. + +--- +consultar estado de invoive +python3 -c " +from core.services.WebServiceApiService import WebServiceApiService + +invoice = WebServiceApiService.get_invoice('BILLING_CODE_HERE') +print('status:', invoice.status) +" +## 2. Patch subscription / Parchear suscripción + +```bash sudo docker compose exec laravel.test php artisan tinker --execute=" \$sub = \App\Models\Subscription::orderBy('id', 'desc')->first(); \$sub->expires_at = '2027-12-31 23:59:59'; @@ -13,7 +32,15 @@ sudo docker compose exec laravel.test php artisan tinker --execute=" \$sub->save(); echo 'OK' . PHP_EOL; " -conectar +``` + +--- + +## 3. Connect / Conectar + +### VLESS + +```python python3 -c " from core.services.WebServiceApiService import WebServiceApiService from core.controllers.encrypted_proxy.VlessController import VlessController @@ -24,11 +51,48 @@ observer.subscribe('connected', lambda e: print('connected:', e.subject)) observer.subscribe('error', lambda e: print('error:', e.subject)) observer.subscribe('disconnected', lambda e: print('disconnected:', e.subject)) -session = WebServiceApiService.post_operator_proxy('617N-LPKB-MVPT-YRQM', 3, 'vless') +session = WebServiceApiService.post_operator_proxy('', , 'vless') print('session:', session) controller = VlessController(1080) controller.enable(session.links[0], session.username, observer) " +``` +### Hysteria2 +```python +python3 -c " +from core.services.WebServiceApiService import WebServiceApiService +from core.controllers.encrypted_proxy.HysteriaController import HysteriaController +from core.observers.EncryptedProxyObserver import EncryptedProxyObserver + +observer = EncryptedProxyObserver() +observer.subscribe('connected', lambda e: print('connected:', e.subject)) +observer.subscribe('error', lambda e: print('error:', e.subject)) +observer.subscribe('disconnected', lambda e: print('disconnected:', e.subject)) + +session = WebServiceApiService.post_operator_proxy('', , 'hysteria2') +print('session:', session) + +controller = HysteriaController(1080) +controller.enable(session.username, session.password, session.operator_hysteria2_host, observer) +" +``` + +--- + +## 4. Disconnect / Desconectar + +```python +python3 -c " +from core.controllers.encrypted_proxy.HysteriaController import HysteriaController +from core.observers.EncryptedProxyObserver import EncryptedProxyObserver + +observer = EncryptedProxyObserver() +observer.subscribe('disconnected', lambda e: print('disconnected')) + +controller = HysteriaController(1080) +controller.disable(observer) +" +``` diff --git a/app/Constants.py b/app/Constants.py deleted file mode 100644 index 8c2d82e..0000000 --- a/app/Constants.py +++ /dev/null @@ -1,50 +0,0 @@ -from dataclasses import dataclass -from typing import Final -from dotenv import load_dotenv -import os - -load_dotenv(os.path.join(os.path.dirname(__file__), '../.env')) - -_env = os.environ.get('APP_ENV', 'production') -_sp_api_base_url = ( - os.environ.get('SP_API_BASE_URL_LOCAL', 'http://simplifiedprivacy.test/api/v1') - if _env == 'local' - else os.environ.get('SP_API_BASE_URL_PRODUCTION', 'https://api.hydraveil.net/api/v1') -) - -@dataclass(frozen=True) -class Constants: - TICKET_API_BASE_URL: Final[str] = os.environ.get( - "TICKET_API_BASE_URL", "https://ticket.hydraveil.net" - ) - SP_API_BASE_URL: Final[str] = _sp_api_base_url - PING_URL: Final[str] = os.environ.get('PING_URL', f'{_sp_api_base_url}/health') - CONNECTION_RETRY_INTERVAL: Final[int] = int(os.environ.get('CONNECTION_RETRY_INTERVAL', '5')) - MAX_CONNECTION_ATTEMPTS: Final[int] = int(os.environ.get('MAX_CONNECTION_ATTEMPTS', '2')) - HV_CLIENT_PATH: Final[str] = os.environ.get('HV_CLIENT_PATH') - HV_CLIENT_VERSION_NUMBER: Final[str] = os.environ.get('HV_CLIENT_VERSION_NUMBER') - HOME: Final[str] = os.path.expanduser('~') - SYSTEM_CONFIG_PATH: Final[str] = '/etc' - INSTALL_DIR: Final[str] = os.environ.get('INSTALL_DIR', os.path.expanduser('~')) - CACHE_HOME: Final[str] = os.environ.get('XDG_CACHE_HOME', f'{INSTALL_DIR}/.cache') - CONFIG_HOME: Final[str] = os.environ.get('XDG_CONFIG_HOME', f'{INSTALL_DIR}/.config') - DATA_HOME: Final[str] = os.environ.get('XDG_DATA_HOME', f'{INSTALL_DIR}/data') - STATE_HOME: Final[str] = os.environ.get('XDG_STATE_HOME', f'{INSTALL_DIR}/.state') - HV_SYSTEM_CONFIG_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/hydra-veil' - HV_CACHE_HOME: Final[str] = f'{CACHE_HOME}/hydra-veil' - HV_CONFIG_HOME: Final[str] = f'{CONFIG_HOME}/hydra-veil' - HV_DATA_HOME: Final[str] = f'{DATA_HOME}/hydra-veil' - HV_STATE_HOME: Final[str] = f'{STATE_HOME}/hydra-veil' - HV_SYSTEM_PROFILE_CONFIG_PATH: Final[str] = f'{HV_SYSTEM_CONFIG_PATH}/profiles' - HV_PROFILE_CONFIG_HOME: Final[str] = f'{HV_CONFIG_HOME}/profiles' - HV_PROFILE_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/profiles' - HV_TICKETING_CONFIG_HOME: Final[str] = f"{HV_CONFIG_HOME}/ticketing" - HV_TICKETING_DATA_HOME: Final[str] = f"{HV_DATA_HOME}/ticket_data" - HV_APPLICATION_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/applications' - HV_INCIDENT_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/incidents' - HV_RUNTIME_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/runtime' - HV_STORAGE_DATABASE_PATH: Final[str] = f'{HV_DATA_HOME}/storage.db' - HV_CAPABILITY_POLICY_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/apparmor.d/hydra-veil' - HV_PRIVILEGE_POLICY_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/sudoers.d/hydra-veil' - HV_SESSION_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/sessions' - HV_TOR_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/tor' \ No newline at end of file diff --git a/app/utils/encrypted_proxy/net.py b/app/utils/encrypted_proxy/net.py deleted file mode 100644 index 167c10d..0000000 --- a/app/utils/encrypted_proxy/net.py +++ /dev/null @@ -1,44 +0,0 @@ -import socket -import subprocess -import time - - -def resolve(host: str) -> str | None: - try: - return socket.gethostbyname(host) - except Exception: - return None - - -def get_real_ip(timeout: int = 10) -> str: - try: - r = subprocess.run( - ["curl", "-s", "--max-time", str(timeout), "https://ifconfig.me/ip"], - capture_output=True, timeout=timeout + 2, - ) - return r.stdout.decode().strip() or "unknown (empty)" - except Exception as e: - return f"unknown ({e})" - - -def get_proxied_ip(socks5_port: int, timeout: int = 10) -> str: - try: - r = subprocess.run( - ["curl", "-s", "--max-time", str(timeout), - "-x", f"socks5h://127.0.0.1:{socks5_port}", - "https://ifconfig.me/ip"], - capture_output=True, timeout=timeout + 2, - ) - return r.stdout.decode().strip() or "unknown (empty)" - except Exception as e: - return f"unknown ({e})" - - -def verify_ip(socks5_port: int, retries: int = 3, delay: float = 2.0) -> str: - for attempt in range(1, retries + 1): - ip = get_proxied_ip(socks5_port) - if not ip.startswith("unknown"): - return ip - if attempt < retries: - time.sleep(delay) - return "unknown" \ No newline at end of file diff --git a/core/Constants.py b/core/Constants.py new file mode 100644 index 0000000..f45ffdd --- /dev/null +++ b/core/Constants.py @@ -0,0 +1,70 @@ +from dataclasses import dataclass +from typing import Final +from dotenv import load_dotenv +import os + +load_dotenv(os.path.join(os.path.dirname(__file__), '../.env')) + +_env = os.environ.get('APP_ENV', 'production') + +_sp_api_base_url = ( + os.environ.get('SP_API_BASE_URL_LOCAL', 'http://simplifiedprivacy.test/api/v1') + if _env == 'local' + else os.environ.get('SP_API_BASE_URL_PRODUCTION', 'https://api.hydraveil.net/api/v1') +) + + +@dataclass(frozen=True) +class Constants: + TICKET_API_BASE_URL: Final[str] = os.environ.get( + "TICKET_API_BASE_URL", "https://ticket.hydraveil.net" + ) + SP_API_BASE_URL: Final[str] = _sp_api_base_url + PING_URL: Final[str] = os.environ.get('PING_URL', f'{_sp_api_base_url}/health') + CONNECTION_RETRY_INTERVAL: Final[int] = int(os.environ.get('CONNECTION_RETRY_INTERVAL', '5')) + MAX_CONNECTION_ATTEMPTS: Final[int] = int(os.environ.get('MAX_CONNECTION_ATTEMPTS', '2')) + HV_CLIENT_PATH: Final[str] = os.environ.get('HV_CLIENT_PATH') + HV_CLIENT_VERSION_NUMBER: Final[str] = os.environ.get('HV_CLIENT_VERSION_NUMBER') + + # ── Paths base ──────────────────────────────────────────────────────────── + HOME: Final[str] = os.path.expanduser('~') + SYSTEM_CONFIG_PATH: Final[str] = '/etc' + INSTALL_DIR: Final[str] = os.environ.get('INSTALL_DIR', os.path.expanduser('~')) + + # ── XDG ─────────────────────────────────────────────────────────────────── + CACHE_HOME: Final[str] = os.environ.get('XDG_CACHE_HOME', f'{INSTALL_DIR}/.cache') + CONFIG_HOME: Final[str] = os.environ.get('XDG_CONFIG_HOME', f'{INSTALL_DIR}/.config') + DATA_HOME: Final[str] = os.environ.get('XDG_DATA_HOME', f'{INSTALL_DIR}/data') + STATE_HOME: Final[str] = os.environ.get('XDG_STATE_HOME', f'{INSTALL_DIR}/.state') + + # ── hydra-veil dirs ─────────────────────────────────────────────────────── + HV_SYSTEM_CONFIG_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/hydra-veil' + HV_CACHE_HOME: Final[str] = f'{CACHE_HOME}/hydra-veil' + HV_CONFIG_HOME: Final[str] = f'{CONFIG_HOME}/hydra-veil' + HV_DATA_HOME: Final[str] = f'{DATA_HOME}/hydra-veil' + HV_STATE_HOME: Final[str] = f'{STATE_HOME}/hydra-veil' + + HV_SYSTEM_PROFILE_CONFIG_PATH: Final[str] = f'{HV_SYSTEM_CONFIG_PATH}/profiles' + HV_PROFILE_CONFIG_HOME: Final[str] = f'{HV_CONFIG_HOME}/profiles' + HV_PROFILE_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/profiles' + HV_TICKETING_CONFIG_HOME: Final[str] = f'{HV_CONFIG_HOME}/ticketing' + HV_TICKETING_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/ticket_data' + HV_APPLICATION_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/applications' + HV_INCIDENT_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/incidents' + HV_RUNTIME_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/runtime' + HV_STORAGE_DATABASE_PATH: Final[str] = f'{HV_DATA_HOME}/storage.db' + HV_CAPABILITY_POLICY_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/apparmor.d/hydra-veil' + HV_PRIVILEGE_POLICY_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/sudoers.d/hydra-veil' + HV_SESSION_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/sessions' + HV_TOR_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/tor' + + # ── sing-box ────────────────────────────────────────────────────────────── + SINGBOX_WRAPPER: Final[str] = os.environ.get( + 'SINGBOX_WRAPPER', '/usr/local/bin/hydraveil-singbox' + ) + SINGBOX_BIN: Final[str] = os.environ.get( + 'SINGBOX_BIN', '/usr/bin/sing-box' + ) + SINGBOX_CONFIG_DIR: Final[str] = f'{HV_DATA_HOME}/configs' + SINGBOX_PID_FILE: Final[str] = f'{HV_RUNTIME_DATA_HOME}/singbox.pid' + SINGBOX_LOG_FILE: Final[str] = f'{HV_RUNTIME_DATA_HOME}/singbox.log' diff --git a/app/Errors.py b/core/Errors.py similarity index 100% rename from app/Errors.py rename to core/Errors.py diff --git a/app/Helpers.py b/core/Helpers.py similarity index 100% rename from app/Helpers.py rename to core/Helpers.py diff --git a/app/__init__.py b/core/__init__.py similarity index 100% rename from app/__init__.py rename to core/__init__.py diff --git a/app/controllers/ApplicationController.py b/core/controllers/ApplicationController.py similarity index 100% rename from app/controllers/ApplicationController.py rename to core/controllers/ApplicationController.py diff --git a/app/controllers/ApplicationVersionController.py b/core/controllers/ApplicationVersionController.py similarity index 100% rename from app/controllers/ApplicationVersionController.py rename to core/controllers/ApplicationVersionController.py diff --git a/app/controllers/ClientController.py b/core/controllers/ClientController.py similarity index 100% rename from app/controllers/ClientController.py rename to core/controllers/ClientController.py diff --git a/app/controllers/ClientVersionController.py b/core/controllers/ClientVersionController.py similarity index 100% rename from app/controllers/ClientVersionController.py rename to core/controllers/ClientVersionController.py diff --git a/app/controllers/ConfigurationController.py b/core/controllers/ConfigurationController.py similarity index 100% rename from app/controllers/ConfigurationController.py rename to core/controllers/ConfigurationController.py diff --git a/app/controllers/ConnectionController.py b/core/controllers/ConnectionController.py similarity index 100% rename from app/controllers/ConnectionController.py rename to core/controllers/ConnectionController.py diff --git a/app/controllers/InvoiceController.py b/core/controllers/InvoiceController.py similarity index 100% rename from app/controllers/InvoiceController.py rename to core/controllers/InvoiceController.py diff --git a/app/controllers/LocationController.py b/core/controllers/LocationController.py similarity index 100% rename from app/controllers/LocationController.py rename to core/controllers/LocationController.py diff --git a/app/controllers/OperatorController.py b/core/controllers/OperatorController.py similarity index 100% rename from app/controllers/OperatorController.py rename to core/controllers/OperatorController.py diff --git a/app/controllers/PolicyController.py b/core/controllers/PolicyController.py similarity index 100% rename from app/controllers/PolicyController.py rename to core/controllers/PolicyController.py diff --git a/app/controllers/ProfileController.py b/core/controllers/ProfileController.py similarity index 100% rename from app/controllers/ProfileController.py rename to core/controllers/ProfileController.py diff --git a/app/controllers/SessionStateController.py b/core/controllers/SessionStateController.py similarity index 100% rename from app/controllers/SessionStateController.py rename to core/controllers/SessionStateController.py diff --git a/app/controllers/SubscriptionController.py b/core/controllers/SubscriptionController.py similarity index 100% rename from app/controllers/SubscriptionController.py rename to core/controllers/SubscriptionController.py diff --git a/app/controllers/SubscriptionPlanController.py b/core/controllers/SubscriptionPlanController.py similarity index 100% rename from app/controllers/SubscriptionPlanController.py rename to core/controllers/SubscriptionPlanController.py diff --git a/app/controllers/SystemStateController.py b/core/controllers/SystemStateController.py similarity index 100% rename from app/controllers/SystemStateController.py rename to core/controllers/SystemStateController.py diff --git a/app/controllers/encrypted_proxy/DisableController.py b/core/controllers/encrypted_proxy/DisableController.py similarity index 100% rename from app/controllers/encrypted_proxy/DisableController.py rename to core/controllers/encrypted_proxy/DisableController.py diff --git a/app/controllers/encrypted_proxy/HysteriaController.py b/core/controllers/encrypted_proxy/HysteriaController.py similarity index 100% rename from app/controllers/encrypted_proxy/HysteriaController.py rename to core/controllers/encrypted_proxy/HysteriaController.py diff --git a/app/controllers/encrypted_proxy/VlessController.py b/core/controllers/encrypted_proxy/VlessController.py similarity index 100% rename from app/controllers/encrypted_proxy/VlessController.py rename to core/controllers/encrypted_proxy/VlessController.py diff --git a/app/controllers/encrypted_proxy/__init__.py b/core/controllers/encrypted_proxy/__init__.py similarity index 100% rename from app/controllers/encrypted_proxy/__init__.py rename to core/controllers/encrypted_proxy/__init__.py diff --git a/app/controllers/tickets/FailedVerificationController.py b/core/controllers/tickets/FailedVerificationController.py similarity index 100% rename from app/controllers/tickets/FailedVerificationController.py rename to core/controllers/tickets/FailedVerificationController.py diff --git a/app/controllers/tickets/TicketPayController.py b/core/controllers/tickets/TicketPayController.py similarity index 100% rename from app/controllers/tickets/TicketPayController.py rename to core/controllers/tickets/TicketPayController.py diff --git a/app/controllers/tickets/TicketPrepController.py b/core/controllers/tickets/TicketPrepController.py similarity index 100% rename from app/controllers/tickets/TicketPrepController.py rename to core/controllers/tickets/TicketPrepController.py diff --git a/app/controllers/tickets/TicketSyncController.py b/core/controllers/tickets/TicketSyncController.py similarity index 100% rename from app/controllers/tickets/TicketSyncController.py rename to core/controllers/tickets/TicketSyncController.py diff --git a/app/controllers/tickets/UseTicketController.py b/core/controllers/tickets/UseTicketController.py similarity index 100% rename from app/controllers/tickets/UseTicketController.py rename to core/controllers/tickets/UseTicketController.py diff --git a/app/errors/exceptions.py b/core/errors/exceptions.py similarity index 100% rename from app/errors/exceptions.py rename to core/errors/exceptions.py diff --git a/app/errors/get_error_msg.py b/core/errors/get_error_msg.py similarity index 100% rename from app/errors/get_error_msg.py rename to core/errors/get_error_msg.py diff --git a/app/errors/logger.py b/core/errors/logger.py similarity index 100% rename from app/errors/logger.py rename to core/errors/logger.py diff --git a/app/models/BaseConnection.py b/core/models/BaseConnection.py similarity index 100% rename from app/models/BaseConnection.py rename to core/models/BaseConnection.py diff --git a/app/models/BasePolicy.py b/core/models/BasePolicy.py similarity index 100% rename from app/models/BasePolicy.py rename to core/models/BasePolicy.py diff --git a/app/models/BaseProfile.py b/core/models/BaseProfile.py similarity index 100% rename from app/models/BaseProfile.py rename to core/models/BaseProfile.py diff --git a/app/models/ClientVersion.py b/core/models/ClientVersion.py similarity index 100% rename from app/models/ClientVersion.py rename to core/models/ClientVersion.py diff --git a/app/models/Configuration.py b/core/models/Configuration.py similarity index 100% rename from app/models/Configuration.py rename to core/models/Configuration.py diff --git a/app/models/Event.py b/core/models/Event.py similarity index 100% rename from app/models/Event.py rename to core/models/Event.py diff --git a/app/models/Location.py b/core/models/Location.py similarity index 100% rename from app/models/Location.py rename to core/models/Location.py diff --git a/app/models/Model.py b/core/models/Model.py similarity index 100% rename from app/models/Model.py rename to core/models/Model.py diff --git a/app/models/Operator.py b/core/models/Operator.py similarity index 100% rename from app/models/Operator.py rename to core/models/Operator.py diff --git a/app/models/OperatorProxySession.py b/core/models/OperatorProxySession.py similarity index 100% rename from app/models/OperatorProxySession.py rename to core/models/OperatorProxySession.py diff --git a/app/models/Subscription.py b/core/models/Subscription.py similarity index 100% rename from app/models/Subscription.py rename to core/models/Subscription.py diff --git a/app/models/SubscriptionPlan.py b/core/models/SubscriptionPlan.py similarity index 100% rename from app/models/SubscriptionPlan.py rename to core/models/SubscriptionPlan.py diff --git a/app/models/invoice/Invoice.py b/core/models/invoice/Invoice.py similarity index 100% rename from app/models/invoice/Invoice.py rename to core/models/invoice/Invoice.py diff --git a/app/models/invoice/PaymentMethod.py b/core/models/invoice/PaymentMethod.py similarity index 100% rename from app/models/invoice/PaymentMethod.py rename to core/models/invoice/PaymentMethod.py diff --git a/app/models/invoice/TicketInvoice.py b/core/models/invoice/TicketInvoice.py similarity index 100% rename from app/models/invoice/TicketInvoice.py rename to core/models/invoice/TicketInvoice.py diff --git a/app/models/policy/CapabilityPolicy.py b/core/models/policy/CapabilityPolicy.py similarity index 100% rename from app/models/policy/CapabilityPolicy.py rename to core/models/policy/CapabilityPolicy.py diff --git a/app/models/policy/PrivilegePolicy.py b/core/models/policy/PrivilegePolicy.py similarity index 100% rename from app/models/policy/PrivilegePolicy.py rename to core/models/policy/PrivilegePolicy.py diff --git a/app/models/session/Application.py b/core/models/session/Application.py similarity index 100% rename from app/models/session/Application.py rename to core/models/session/Application.py diff --git a/app/models/session/ApplicationVersion.py b/core/models/session/ApplicationVersion.py similarity index 100% rename from app/models/session/ApplicationVersion.py rename to core/models/session/ApplicationVersion.py diff --git a/app/models/session/NetworkPortNumbers.py b/core/models/session/NetworkPortNumbers.py similarity index 100% rename from app/models/session/NetworkPortNumbers.py rename to core/models/session/NetworkPortNumbers.py diff --git a/app/models/session/ProxyConfiguration.py b/core/models/session/ProxyConfiguration.py similarity index 100% rename from app/models/session/ProxyConfiguration.py rename to core/models/session/ProxyConfiguration.py diff --git a/app/models/session/SessionConnection.py b/core/models/session/SessionConnection.py similarity index 100% rename from app/models/session/SessionConnection.py rename to core/models/session/SessionConnection.py diff --git a/app/models/session/SessionProfile.py b/core/models/session/SessionProfile.py similarity index 100% rename from app/models/session/SessionProfile.py rename to core/models/session/SessionProfile.py diff --git a/app/models/session/SessionState.py b/core/models/session/SessionState.py similarity index 100% rename from app/models/session/SessionState.py rename to core/models/session/SessionState.py diff --git a/app/models/system/SystemConnection.py b/core/models/system/SystemConnection.py similarity index 100% rename from app/models/system/SystemConnection.py rename to core/models/system/SystemConnection.py diff --git a/app/models/system/SystemProfile.py b/core/models/system/SystemProfile.py similarity index 100% rename from app/models/system/SystemProfile.py rename to core/models/system/SystemProfile.py diff --git a/app/models/system/SystemState.py b/core/models/system/SystemState.py similarity index 100% rename from app/models/system/SystemState.py rename to core/models/system/SystemState.py diff --git a/app/observers/ApplicationVersionObserver.py b/core/observers/ApplicationVersionObserver.py similarity index 100% rename from app/observers/ApplicationVersionObserver.py rename to core/observers/ApplicationVersionObserver.py diff --git a/app/observers/BaseObserver.py b/core/observers/BaseObserver.py similarity index 100% rename from app/observers/BaseObserver.py rename to core/observers/BaseObserver.py diff --git a/app/observers/ClientObserver.py b/core/observers/ClientObserver.py similarity index 100% rename from app/observers/ClientObserver.py rename to core/observers/ClientObserver.py diff --git a/app/observers/ConnectionObserver.py b/core/observers/ConnectionObserver.py similarity index 100% rename from app/observers/ConnectionObserver.py rename to core/observers/ConnectionObserver.py diff --git a/app/observers/EncryptedProxyObserver.py b/core/observers/EncryptedProxyObserver.py similarity index 100% rename from app/observers/EncryptedProxyObserver.py rename to core/observers/EncryptedProxyObserver.py diff --git a/app/observers/InvoiceObserver.py b/core/observers/InvoiceObserver.py similarity index 100% rename from app/observers/InvoiceObserver.py rename to core/observers/InvoiceObserver.py diff --git a/app/observers/ProfileObserver.py b/core/observers/ProfileObserver.py similarity index 100% rename from app/observers/ProfileObserver.py rename to core/observers/ProfileObserver.py diff --git a/app/observers/TicketObserver.py b/core/observers/TicketObserver.py similarity index 100% rename from app/observers/TicketObserver.py rename to core/observers/TicketObserver.py diff --git a/app/services/WebServiceApiService.py b/core/services/WebServiceApiService.py similarity index 100% rename from app/services/WebServiceApiService.py rename to core/services/WebServiceApiService.py diff --git a/app/services/crypto/TicketCustomer.py b/core/services/crypto/TicketCustomer.py similarity index 100% rename from app/services/crypto/TicketCustomer.py rename to core/services/crypto/TicketCustomer.py diff --git a/app/services/crypto/make_commitments.py b/core/services/crypto/make_commitments.py similarity index 100% rename from app/services/crypto/make_commitments.py rename to core/services/crypto/make_commitments.py diff --git a/app/services/encrypted_proxy/__init__.py b/core/services/encrypted_proxy/__init__.py similarity index 100% rename from app/services/encrypted_proxy/__init__.py rename to core/services/encrypted_proxy/__init__.py diff --git a/app/services/encrypted_proxy/disable_service.py b/core/services/encrypted_proxy/disable_service.py similarity index 100% rename from app/services/encrypted_proxy/disable_service.py rename to core/services/encrypted_proxy/disable_service.py diff --git a/app/services/encrypted_proxy/hysteria_service.py b/core/services/encrypted_proxy/hysteria_service.py similarity index 91% rename from app/services/encrypted_proxy/hysteria_service.py rename to core/services/encrypted_proxy/hysteria_service.py index 7695070..42bf09a 100644 --- a/app/services/encrypted_proxy/hysteria_service.py +++ b/core/services/encrypted_proxy/hysteria_service.py @@ -1,4 +1,5 @@ import socket +import time from pathlib import Path from core.Constants import Constants from core.utils.encrypted_proxy.net import get_real_ip, verify_ip @@ -12,6 +13,7 @@ def _resolve(host: str) -> str: def build_hysteria_config(username: str, password: str, server_host: str, socks5_port: int) -> dict: server_ip = _resolve(server_host) + return { "dns": { "servers": [{"tag": "local", "type": "udp", "server": "9.9.9.9"}], @@ -55,6 +57,8 @@ def build_hysteria_config(username: str, password: str, "rules": [ {"protocol": "dns", "action": "hijack-dns"}, {"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"}, + # DNS y servidor salen directo — nunca por el TUN + {"ip_cidr": ["9.9.9.9/32"], "outbound": "direct"}, {"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"}, {"ip_is_private": True, "outbound": "direct"}, {"ip_version": 6, "outbound": "block"}, @@ -85,7 +89,11 @@ def enable_hysteria(username: str, password: str, server_host: str, if observer: observer.notify("error", "sing-box not active after start") return False - proxy_ip = verify_ip(socks5_port) + + # Esperar que el proxy esté listo para rutear tráfico + time.sleep(3) + + proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0) if observer: observer.notify("connected", { "real_ip": real_ip, diff --git a/app/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py similarity index 88% rename from app/services/encrypted_proxy/vless_service.py rename to core/services/encrypted_proxy/vless_service.py index da45a73..ac721c5 100644 --- a/app/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -4,6 +4,7 @@ from core.Constants import Constants from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner import socket +import time def parse_vless_link(link: str) -> dict: @@ -25,18 +26,24 @@ def parse_vless_link(link: str) -> dict: "port": int(port), "path": unquote(params.get("path", "/vless")), "sni": sni, - "ws_host": ws_host, + "ws_host": ws_host, "security": params.get("security", "tls"), "network": params.get("type", "ws"), } def build_vless_config(vless: dict, socks5_port: int) -> dict: - server_ip = socket.gethostbyname(vless["host"]) + server_ip = socket.gethostbyname(vless["host"]) return { "dns": { - "servers": [{"tag": "local", "type": "udp", "server": "9.9.9.9"}], + "servers": [ + { + "tag": "local", + "type": "udp", + "server": "9.9.9.9", + }, + ], "final": "local", "strategy": "ipv4_only", }, @@ -59,7 +66,7 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: ], "outbounds": [ {"type": "direct", "tag": "direct"}, - {"type": "block", "tag": "block"}, + {"type": "block", "tag": "block"}, { "type": "vless", "tag": "proxy", @@ -74,7 +81,7 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: "transport": { "type": "ws", "path": vless["path"], - "headers": {"Host": vless["ws_host"]}, + "headers": {"Host": vless["ws_host"]}, }, }, ], @@ -82,6 +89,7 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: "rules": [ {"protocol": "dns", "action": "hijack-dns"}, {"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"}, + {"ip_cidr": ["9.9.9.9/32"], "outbound": "direct"}, {"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"}, {"ip_is_private": True, "outbound": "direct"}, {"ip_version": 6, "outbound": "block"}, @@ -113,6 +121,10 @@ def enable_vless(vless_link: str, username: str, if observer: observer.notify("error", "sing-box not active after start") return False + + # Esperar que el proxy esté listo para rutear tráfico + time.sleep(3) + proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0) if observer: observer.notify("connected", { diff --git a/app/services/failed_verification/is_the_key_to_blame.py b/core/services/failed_verification/is_the_key_to_blame.py similarity index 100% rename from app/services/failed_verification/is_the_key_to_blame.py rename to core/services/failed_verification/is_the_key_to_blame.py diff --git a/app/services/failed_verification/prep_with_previously_saved_blind_sigs.py b/core/services/failed_verification/prep_with_previously_saved_blind_sigs.py similarity index 100% rename from app/services/failed_verification/prep_with_previously_saved_blind_sigs.py rename to core/services/failed_verification/prep_with_previously_saved_blind_sigs.py diff --git a/app/services/failed_verification/test_if_new_key_works.py b/core/services/failed_verification/test_if_new_key_works.py similarity index 100% rename from app/services/failed_verification/test_if_new_key_works.py rename to core/services/failed_verification/test_if_new_key_works.py diff --git a/app/services/helpers/does_ticket_file_exist.py b/core/services/helpers/does_ticket_file_exist.py similarity index 100% rename from app/services/helpers/does_ticket_file_exist.py rename to core/services/helpers/does_ticket_file_exist.py diff --git a/app/services/helpers/get_how_many_profiles_were_ordered.py b/core/services/helpers/get_how_many_profiles_were_ordered.py similarity index 100% rename from app/services/helpers/get_how_many_profiles_were_ordered.py rename to core/services/helpers/get_how_many_profiles_were_ordered.py diff --git a/app/services/helpers/get_plan_data.py b/core/services/helpers/get_plan_data.py similarity index 100% rename from app/services/helpers/get_plan_data.py rename to core/services/helpers/get_plan_data.py diff --git a/app/services/helpers/get_value_from_config.py b/core/services/helpers/get_value_from_config.py similarity index 100% rename from app/services/helpers/get_value_from_config.py rename to core/services/helpers/get_value_from_config.py diff --git a/app/services/helpers/get_which_billing_key.py b/core/services/helpers/get_which_billing_key.py similarity index 100% rename from app/services/helpers/get_which_billing_key.py rename to core/services/helpers/get_which_billing_key.py diff --git a/app/services/helpers/save_sync_results.py b/core/services/helpers/save_sync_results.py similarity index 100% rename from app/services/helpers/save_sync_results.py rename to core/services/helpers/save_sync_results.py diff --git a/app/services/helpers/valid_profile_quantity.py b/core/services/helpers/valid_profile_quantity.py similarity index 100% rename from app/services/helpers/valid_profile_quantity.py rename to core/services/helpers/valid_profile_quantity.py diff --git a/app/services/helpers/validate_number_format.py b/core/services/helpers/validate_number_format.py similarity index 100% rename from app/services/helpers/validate_number_format.py rename to core/services/helpers/validate_number_format.py diff --git a/app/services/networking/evaluate_networking_problem.py b/core/services/networking/evaluate_networking_problem.py similarity index 100% rename from app/services/networking/evaluate_networking_problem.py rename to core/services/networking/evaluate_networking_problem.py diff --git a/app/services/networking/extract_domain.py b/core/services/networking/extract_domain.py similarity index 100% rename from app/services/networking/extract_domain.py rename to core/services/networking/extract_domain.py diff --git a/app/services/networking/get_connection_type.py b/core/services/networking/get_connection_type.py similarity index 100% rename from app/services/networking/get_connection_type.py rename to core/services/networking/get_connection_type.py diff --git a/app/services/networking/get_data_from_server.py b/core/services/networking/get_data_from_server.py similarity index 100% rename from app/services/networking/get_data_from_server.py rename to core/services/networking/get_data_from_server.py diff --git a/app/services/networking/internet_test.py b/core/services/networking/internet_test.py similarity index 100% rename from app/services/networking/internet_test.py rename to core/services/networking/internet_test.py diff --git a/app/services/networking/is_dns_problem.py b/core/services/networking/is_dns_problem.py similarity index 100% rename from app/services/networking/is_dns_problem.py rename to core/services/networking/is_dns_problem.py diff --git a/app/services/networking/is_tor_working.py b/core/services/networking/is_tor_working.py similarity index 100% rename from app/services/networking/is_tor_working.py rename to core/services/networking/is_tor_working.py diff --git a/app/services/networking/make_url.py b/core/services/networking/make_url.py similarity index 100% rename from app/services/networking/make_url.py rename to core/services/networking/make_url.py diff --git a/app/services/networking/regular_get_request.py b/core/services/networking/regular_get_request.py similarity index 100% rename from app/services/networking/regular_get_request.py rename to core/services/networking/regular_get_request.py diff --git a/app/services/networking/regular_post_request.py b/core/services/networking/regular_post_request.py similarity index 100% rename from app/services/networking/regular_post_request.py rename to core/services/networking/regular_post_request.py diff --git a/app/services/networking/send_data_to_server.py b/core/services/networking/send_data_to_server.py similarity index 100% rename from app/services/networking/send_data_to_server.py rename to core/services/networking/send_data_to_server.py diff --git a/app/services/networking/use_tor.py b/core/services/networking/use_tor.py similarity index 100% rename from app/services/networking/use_tor.py rename to core/services/networking/use_tor.py diff --git a/app/services/payment_phase/check_if_paid.py b/core/services/payment_phase/check_if_paid.py similarity index 100% rename from app/services/payment_phase/check_if_paid.py rename to core/services/payment_phase/check_if_paid.py diff --git a/app/services/payment_phase/extract_payment_details.py b/core/services/payment_phase/extract_payment_details.py similarity index 100% rename from app/services/payment_phase/extract_payment_details.py rename to core/services/payment_phase/extract_payment_details.py diff --git a/app/services/payment_phase/save_and_send_intitial_billing.py b/core/services/payment_phase/save_and_send_intitial_billing.py similarity index 100% rename from app/services/payment_phase/save_and_send_intitial_billing.py rename to core/services/payment_phase/save_and_send_intitial_billing.py diff --git a/app/services/payment_phase/save_billing_choices.py b/core/services/payment_phase/save_billing_choices.py similarity index 100% rename from app/services/payment_phase/save_billing_choices.py rename to core/services/payment_phase/save_billing_choices.py diff --git a/app/services/prepare_tickets/get_pub_key.py b/core/services/prepare_tickets/get_pub_key.py similarity index 100% rename from app/services/prepare_tickets/get_pub_key.py rename to core/services/prepare_tickets/get_pub_key.py diff --git a/app/services/prepare_tickets/get_public_key_by_config.py b/core/services/prepare_tickets/get_public_key_by_config.py similarity index 100% rename from app/services/prepare_tickets/get_public_key_by_config.py rename to core/services/prepare_tickets/get_public_key_by_config.py diff --git a/app/services/prepare_tickets/make_sure_pub_key_exists.py b/core/services/prepare_tickets/make_sure_pub_key_exists.py similarity index 100% rename from app/services/prepare_tickets/make_sure_pub_key_exists.py rename to core/services/prepare_tickets/make_sure_pub_key_exists.py diff --git a/app/services/prepare_tickets/save_ALL_blind_sigs.py b/core/services/prepare_tickets/save_ALL_blind_sigs.py similarity index 100% rename from app/services/prepare_tickets/save_ALL_blind_sigs.py rename to core/services/prepare_tickets/save_ALL_blind_sigs.py diff --git a/app/services/prepare_tickets/send_blind_commitments.py b/core/services/prepare_tickets/send_blind_commitments.py similarity index 100% rename from app/services/prepare_tickets/send_blind_commitments.py rename to core/services/prepare_tickets/send_blind_commitments.py diff --git a/app/services/prepare_tickets/setup_ticket_tracker.py b/core/services/prepare_tickets/setup_ticket_tracker.py similarity index 100% rename from app/services/prepare_tickets/setup_ticket_tracker.py rename to core/services/prepare_tickets/setup_ticket_tracker.py diff --git a/app/services/prepare_tickets/ticket_prep_orchestrator.py b/core/services/prepare_tickets/ticket_prep_orchestrator.py similarity index 100% rename from app/services/prepare_tickets/ticket_prep_orchestrator.py rename to core/services/prepare_tickets/ticket_prep_orchestrator.py diff --git a/app/services/prepare_tickets/ticket_tracker.py b/core/services/prepare_tickets/ticket_tracker.py similarity index 100% rename from app/services/prepare_tickets/ticket_tracker.py rename to core/services/prepare_tickets/ticket_tracker.py diff --git a/app/services/prepare_tickets/unblind_all_tickets.py b/core/services/prepare_tickets/unblind_all_tickets.py similarity index 100% rename from app/services/prepare_tickets/unblind_all_tickets.py rename to core/services/prepare_tickets/unblind_all_tickets.py diff --git a/app/services/prepare_tickets/validate_blind_signatures.py b/core/services/prepare_tickets/validate_blind_signatures.py similarity index 100% rename from app/services/prepare_tickets/validate_blind_signatures.py rename to core/services/prepare_tickets/validate_blind_signatures.py diff --git a/app/services/using_tickets/send_unblinded.py b/core/services/using_tickets/send_unblinded.py similarity index 100% rename from app/services/using_tickets/send_unblinded.py rename to core/services/using_tickets/send_unblinded.py diff --git a/app/services/using_tickets/use_ticket_orchestrator.py b/core/services/using_tickets/use_ticket_orchestrator.py similarity index 100% rename from app/services/using_tickets/use_ticket_orchestrator.py rename to core/services/using_tickets/use_ticket_orchestrator.py diff --git a/app/utils/basic_operations/does_file_exist.py b/core/utils/basic_operations/does_file_exist.py similarity index 100% rename from app/utils/basic_operations/does_file_exist.py rename to core/utils/basic_operations/does_file_exist.py diff --git a/app/utils/basic_operations/filter_data.py b/core/utils/basic_operations/filter_data.py similarity index 100% rename from app/utils/basic_operations/filter_data.py rename to core/utils/basic_operations/filter_data.py diff --git a/app/utils/basic_operations/get_json_keys.py b/core/utils/basic_operations/get_json_keys.py similarity index 100% rename from app/utils/basic_operations/get_json_keys.py rename to core/utils/basic_operations/get_json_keys.py diff --git a/app/utils/basic_operations/write_or_read_from_json.py b/core/utils/basic_operations/write_or_read_from_json.py similarity index 100% rename from app/utils/basic_operations/write_or_read_from_json.py rename to core/utils/basic_operations/write_or_read_from_json.py diff --git a/app/utils/basic_operations/write_string_to_text_file.py b/core/utils/basic_operations/write_string_to_text_file.py similarity index 100% rename from app/utils/basic_operations/write_string_to_text_file.py rename to core/utils/basic_operations/write_string_to_text_file.py diff --git a/app/utils/confirm_its_a_valid_key_choice.py b/core/utils/confirm_its_a_valid_key_choice.py similarity index 100% rename from app/utils/confirm_its_a_valid_key_choice.py rename to core/utils/confirm_its_a_valid_key_choice.py diff --git a/app/utils/convert_time.py b/core/utils/convert_time.py similarity index 100% rename from app/utils/convert_time.py rename to core/utils/convert_time.py diff --git a/app/utils/encrypted_proxy/__init__.py b/core/utils/encrypted_proxy/__init__.py similarity index 100% rename from app/utils/encrypted_proxy/__init__.py rename to core/utils/encrypted_proxy/__init__.py diff --git a/core/utils/encrypted_proxy/net.py b/core/utils/encrypted_proxy/net.py new file mode 100644 index 0000000..48f4fd9 --- /dev/null +++ b/core/utils/encrypted_proxy/net.py @@ -0,0 +1,75 @@ +import socket +import subprocess +import time + + +def resolve(host: str) -> str | None: + try: + return socket.gethostbyname(host) + except Exception: + return None + + +def get_real_ip(timeout: int = 10) -> str: + endpoints = [ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + ] + for url in endpoints: + try: + r = subprocess.run( + ["curl", "-s", "--max-time", str(timeout), url], + capture_output=True, + timeout=timeout + 2, + ) + ip = r.stdout.decode().strip() + if ip and not ip.startswith("unknown"): + return ip + except Exception: + continue + return "unknown" + + +def get_proxied_ip(socks5_port: int, timeout: int = 15) -> str: + endpoints = [ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + ] + for url in endpoints: + try: + r = subprocess.run( + [ + "curl", "-s", + "--max-time", str(timeout), + "--connect-timeout", "10", + "-x", f"socks5h://127.0.0.1:{socks5_port}", + url, + ], + capture_output=True, + timeout=timeout + 2, + ) + ip = r.stdout.decode().strip() + stderr = r.stderr.decode().strip() + if ip and not ip.startswith("<") and "." in ip: + return ip + # Log silencioso para debug + if stderr: + print(f"[net] curl stderr ({url}): {stderr[:120]}") + except subprocess.TimeoutExpired: + print(f"[net] timeout via socks5 → {url}") + except Exception as e: + print(f"[net] error via socks5 → {url}: {e}") + return "unknown" + + +def verify_ip(socks5_port: int, retries: int = 5, delay: float = 3.0) -> str: + for attempt in range(1, retries + 1): + print(f"[net] verify_ip intento {attempt}/{retries}...") + ip = get_proxied_ip(socks5_port) + if ip != "unknown": + return ip + if attempt < retries: + time.sleep(delay) + return "unknown" diff --git a/app/utils/encrypted_proxy/singbox.py b/core/utils/encrypted_proxy/singbox.py similarity index 85% rename from app/utils/encrypted_proxy/singbox.py rename to core/utils/encrypted_proxy/singbox.py index e315ac3..76df3c0 100644 --- a/app/utils/encrypted_proxy/singbox.py +++ b/core/utils/encrypted_proxy/singbox.py @@ -23,8 +23,10 @@ class SingboxRunner: _LOG_FILE = Path(Constants.SINGBOX_LOG_FILE) _CONFIG_DIR = Path(Constants.SINGBOX_CONFIG_DIR) - _START_TIMEOUT_S = 10.0 - _START_POLL_S = 0.5 + _START_TIMEOUT_S = 15.0 # tiempo total de espera + _START_POLL_S = 0.5 # intervalo de polling + _START_INITIAL_S = 2.0 # espera inicial antes de empezar a verificar + # (el wrapper necesita ~1s para escribir el PID file) # ── Público ─────────────────────────────────────────────────────────────── @@ -41,7 +43,7 @@ class SingboxRunner: subprocess.Popen( ['sudo', self._WRAPPER, str(config_path), 'start'], stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, # el wrapper escribe el log como root + stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, ) @@ -51,12 +53,14 @@ class SingboxRunner: 'Ejecutá el installer primero.' ) - # Esperar confirmación por PID file, no por tiempo fijo + # Esperar que el wrapper escriba el PID file antes de empezar polling + time.sleep(self._START_INITIAL_S) + deadline = time.monotonic() + self._START_TIMEOUT_S while time.monotonic() < deadline: - time.sleep(self._START_POLL_S) if self.is_running(): return True + time.sleep(self._START_POLL_S) raise RuntimeError( f'sing-box no levantó en {self._START_TIMEOUT_S}s.\n' @@ -78,16 +82,29 @@ class SingboxRunner: timeout=15, ) except (subprocess.TimeoutExpired, FileNotFoundError, OSError): - # Wrapper no responde — kill directo por PID file como último recurso self._emergency_kill() def is_running(self) -> bool: """ Verifica por PID file + señal 0. PermissionError significa que el proceso VIVE (corre como root). + Si el PID file no existe todavía, verifica por pgrep como fallback + para cubrir la ventana entre que el proceso arranca y el wrapper + escribe el PID file. """ if not self._PID_FILE.exists(): - return False + # Fallback: el proceso puede estar vivo pero el PID file + # aún no fue escrito por el wrapper + try: + result = subprocess.run( + ['pgrep', '-x', 'sing-box'], + capture_output=True, + text=True, + ) + return result.returncode == 0 + except OSError: + return False + try: pid = int(self._PID_FILE.read_text().strip()) if pid <= 0: @@ -95,7 +112,6 @@ class SingboxRunner: os.kill(pid, 0) return True except (ValueError, ProcessLookupError): - # PID corrupto o proceso muerto — limpiar self._PID_FILE.unlink(missing_ok=True) return False except PermissionError: @@ -118,7 +134,6 @@ class SingboxRunner: ) return result.stdout.strip() except Exception: - # Fallback: leer directo si tenemos permisos try: lines = self._LOG_FILE.read_text(errors='replace').splitlines() return '\n'.join(lines[-80:]) @@ -183,3 +198,4 @@ class SingboxRunner: pass finally: self._PID_FILE.unlink(missing_ok=True) + diff --git a/app/utils/get_data.py b/core/utils/get_data.py similarity index 100% rename from app/utils/get_data.py rename to core/utils/get_data.py diff --git a/app/utils/get_raw_string.py b/core/utils/get_raw_string.py similarity index 100% rename from app/utils/get_raw_string.py rename to core/utils/get_raw_string.py diff --git a/app/utils/save_data.py b/core/utils/save_data.py similarity index 100% rename from app/utils/save_data.py rename to core/utils/save_data.py diff --git a/data/hydra-veil/storage.db b/data/hydra-veil/storage.db new file mode 100644 index 0000000000000000000000000000000000000000..628bceb98b4a6260af8f320c28be2dac12686cda GIT binary patch literal 86016 zcmeI*PmJ7F9S897c(c3S?5>lv>9m9;15&#Uo1OLlKY>a#QHY{8NP$E}RA}U}pXZ;) z6$$l%IP}P^2YR3q2ZTfsLMj{(>ZNKgZIS4KH-EFUyV+#hP17V_ zD;bafyyy2mzh^sB`@R0)`pearUc(C{r?vLE9J7tFT5P$##9y@{UYnux>Rb^g#A+|~$v4X&>+E!d``o%U0BRY@v zTQ`5{`x#uW32Oz=|u=bh8H!39V;DoT3#^ z_ZTO4s~#yP*{D~ij-Qr9b#yEC! zw9U5~doDBk?Z4lCakW8;y{5k>gJwWz=$c`1e3cs?lZL2Wbx2s;FSF`6ghks52AABK zP|}UlAnv!r_(C)&`1$;#YJ(+u=@HHO+~u=(3ied8C{4ef5{-LHB2JT>++I+J0lf9& zVE`ur(e%seOaP~=4-DXbPH{Kb;~gX=dV4C?2|WR3?-+hFio@bvw};K)B|aoB35h>A z!}iO@ienPRqq`t!6F;W+n%xPgw{{TTwBki^`jzir5*KnC8_f3TZfkqhLFSVi?XJb8 z8OU8559FM^D^2S-dto}@HhAD&bm`m+%6#tn#+@44u7VcjT`N{zkPnkw+Le6y1?e!! z#a+pdUlHbWuc~({{Ei#sG%PbW>gihU%GRMX+Ii!iX0&*vaOjM7-pHpJE%cYqE##hk zmg&E```>aq{|y&qXWG?{8b=2aLKz4~e!;N1D82$c#E5pCVzt6w(*a4x!5P$## zAOHafKmY;|fB*y_0D*@_;3T`w%=8FgpJGL3Z$E(SDRzU~_5S~`|Nru@GOO>c{GQG* zK>z{}fB*y_009U<00I#Bq6N0U#x60rHLh=8+Flv;c@_(rTo5(2630PwVQb5^>ZA}= z8+0sOw@Ms37H8YCyM<<0-kJ=J7j1b}Kk00C46UdsTRTTl+ZsL=-7Jl|P>+`;hbY8W zSSa-*NszQnLEV(p8-jE}G%ko*K^RUy8qFTu=k{iHiCNp=`V9&+?fWfCQb8zak|q{R zWm7yNs>yTXt;v_o_O1gB!OFdnm$E5oUp}wX%o2Ad$MtVguko{QQIZS75gCoM+#1gZ zLNxOk7j;vVzH~0=|Nli_+HgDwKmY;|fB*y_009U<00Izz!2K84xBvg@8=2KN_^nIq_V@A%2%F6H8%K4($@>#`eUTzt znd{%=j;NXkzM3$4{{Lo%f0O?U|G_@ztY1)!$tVt={RzA6Rf!)*TkYW? z2183!8&;WYRk}`SZMI3(8;?3scrggB4mS0lVCI=Yp|XP{;-plvaDxskAt)Cl<%mlq z-b<+l&;PUh#~J=({(lcsh*4GuKmY;|fB*y_009U<00Izz00izPFvoICc8+DZ?CAIZ z`x*Lw|NG=_sjwXa5P$##AOHafKmY;|fB*y_0D*@-MkVZ8fi2Y3RBOjd#qfG+BZ$&kB4|?V)Qv`{dO_K#(fwN;B}#jdAB0V} z>JhQkirR)^n7ZaSV`>fGlO-pz4L7AlG3$-ck~JYxJB04v5{y7L9G|pl z7v(`7QrNDCnqTuA*RqLGPQyeA>weF2h-WJuyB(^PP_NY*j;!03VblGiUN;OZmk4!L zPujj}1iBW{>UZrFxIoRVt_C5?V4CBYzuq6n(1>aH%SE-AUT zrirTJs$$73iHbusgE)$;(;y7R)RIXw^*H zQ%S3$YDqn+N3CwVzEixc3hk7H6szV%J;5{Ui6GU?)OX93p4Re&PORIVa?qmzY)7%? zq;WY2NTbq8h-N00deUh4CaHJyMj-TB>->^Vw9HWJw} zf{JZ5B9aPqQ!58?LsQ!|uh$BC=4|GvOnQmca2-;s=`FeSS?b*h265_{X(h2kw|y&N zmN|VGsO_y=$F9+)kuP=g*DSkI^=YiG`t~$d^qe4uX=)`~RV-Qv8WdenT%u?SJy%Rc zG$c!r1%rl2wrtHRS(;6wsTh(g8MdwtW7U!g@ym79ucc}1)uW1Lrl#IbYxTA!l-)>* zr5^1yFIPNOvdng)=9;a<3OeabtXkzHA!;nS#4t>+QI4%%rRQ}FH|{uw+8vZ8NY%F8 z@M!k6x)~)-CUM;;YksdSM`ohOs!)q>M>mel zz|_s8=1MKC5yZ7f5G`S#t4lg<{0q>fyv+p*p2 zwt9xqae7)UbqNu4t>={$yFz45BECzh0@o?WO4#Xmx*V&DpSoT3_6aJe9k1OTjsO2) zhX0g~0Px`>d8tRGAOHafKmY;|fB*y_009U<00NJlz%iC#Sa$H4&9W@V4W9oq{GT%P z4<-me00Izz00bZa0SG_<0uX=z1Reqbi8;=lJah7yb?z+3Iphin8~Yrf&tXIaVGo O%|7ry!LHA~0`PwuO4t_w literal 0 HcmV?d00001 diff --git a/data/hydra-veil/ticket_data/logs/errors.log b/data/hydra-veil/ticket_data/logs/errors.log new file mode 100644 index 0000000..e69de29 diff --git a/install.sh b/install.sh index 1bd0de5..e90c2aa 100755 --- a/install.sh +++ b/install.sh @@ -1,11 +1,11 @@ #!/bin/bash # install.sh — hydra-veil core installer -set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LIB_DIR="$SCRIPT_DIR/lib" +trap 'echo ""; echo " [ERROR] Installer failed at line $LINENO — check: cat $LOG_FILE" >&2' ERR + for _lib in ui log input network steps; do _path="$LIB_DIR/${_lib}.sh" if [ ! -f "$_path" ]; then @@ -18,14 +18,13 @@ done for arg in "$@"; do case "$arg" in --no-log) LOG_ENABLED="false" ;; - --local) FORCE_ENV="local" ;; esac done _log_init clear -print_header "HYDRA-VEIL CORE INSTALLER" "v1.0.0" +print_header "HYDRA-VEIL CORE INSTALLER" "v2.0.0" printf " ${DIM}%s${RESET}\n" "Simplified Privacy" printf " ${DIM}%s${RESET}\n" "$(date '+%Y-%m-%d %H:%M:%S') — $(uname -n)" print_divider @@ -33,13 +32,13 @@ print_divider print_section "Pre-flight" log_info "User: ${USER}" log_info "Home: ${HOME}" -log_info "Install dir: ${HV_CORE_HOME}" +log_info "Install dir: ${HV_BASE}" require_internet print_divider echo "" -label_info "hydra-veil core se instalará en:" -printf " ${BCYAN}%s${RESET}\n\n" "$HV_CORE_HOME" +label_info "hydra-veil se instalará en:" +printf " ${BCYAN}%s${RESET}\n\n" "$HV_BASE" label_warn "sudo es requerido para sing-box, wrapper y sudoers." echo "" if ! ask_confirm "Continuar con la instalación?"; then @@ -49,8 +48,8 @@ fi echo "" print_divider -step_clone_repo -step_create_env +step_preflight +step_repos step_python_env step_singbox step_wrapper @@ -81,13 +80,16 @@ EOF printf "${RESET}" print_divider echo "" -label_ok "hydra-veil core instalado correctamente" +label_ok "hydra-veil instalado correctamente" label_ok "Todos los steps completados" echo "" label_info "Activar entorno:" -printf " ${CYAN}source %s/.venv/bin/activate${RESET}\n" "$HV_CORE_HOME" +printf " ${CYAN}source %s/core/.venv/bin/activate${RESET}\n" "$HV_BASE" echo "" -label_info "Wrapper disponible en:" +label_info "CLI disponible:" +printf " ${CYAN}python -m cli${RESET}\n" +echo "" +label_info "Wrapper disponible:" printf " ${CYAN}sudo /usr/local/bin/hydraveil-singbox start${RESET}\n" echo "" log_file_path diff --git a/lib/steps.sh b/lib/steps.sh index fb93727..49cc7c8 100755 --- a/lib/steps.sh +++ b/lib/steps.sh @@ -1,536 +1,68 @@ #!/bin/bash -# lib/steps.sh — Install steps for hydra-veil core -# Requiere: lib/ui.sh, lib/log.sh, lib/input.sh, lib/network.sh - -# ─── Ruta canónica fija — todo vive aquí ────────────────────────────────────── -HV_CORE_HOME="$HOME/.local/share/hydra-veil/core" - -# Subdirectorios -HV_REPO_DIR="$HV_CORE_HOME" -HV_VENV_DIR="$HV_CORE_HOME/.venv" -HV_DATA_DIR="$HV_CORE_HOME/data/hydra-veil" -HV_LOG_DIR="$HV_CORE_HOME/logs/hydra-veil" -HV_RUN_DIR="$HV_CORE_HOME/run" -HV_CONFIG_DIR="$HV_DATA_DIR/configs" - -# sing-box -SINGBOX_VERSION="1.13.5" -SINGBOX_BIN="/usr/bin/sing-box" -SINGBOX_PID_FILE="$HV_RUN_DIR/singbox.pid" -SINGBOX_LOG_FILE="$HV_LOG_DIR/singbox.log" - -# Sistema -WRAPPER_PATH="/usr/local/bin/hydraveil-singbox" -SUDOERS_PATH="/etc/sudoers.d/hydraveil" - -REPO_URL="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-core" -BRANCH="core-laravel-proxyTEST" +# lib/steps.sh — orquestador de steps del core installer TOTAL_STEPS=6 -# ─── Helper: crear log file con permisos del usuario ───────────────────────── -# El wrapper corre como root y appendea con >>. Si root crea el archivo primero, -# Python (que corre como usuario) no puede leerlo. Esta función lo crea con -# permisos del usuario ANTES de que el wrapper lo toque por primera vez. -_ensure_log_file() { - local log_file="$1" - local log_dir - log_dir="$(dirname "$log_file")" +# ─── Variables globales ─────────────────────────────────────────────────────── +# Directorio base de hydra-veil +HV_BASE="$HOME/.local/share/hydra-veil" - # Crear directorio si no existe (permisos de usuario) - if [ ! -d "$log_dir" ]; then - mkdir -p "$log_dir" - chmod 700 "$log_dir" - fi +# Componentes +HV_CORE="$HV_BASE/core" +HV_CLI="$HV_BASE/cli" +HV_GUI="$HV_BASE/gui" +HV_ESSENTIALS="$HV_BASE/essentials" - # Crear el archivo solo si no existe — nunca sobreescribir - if [ ! -f "$log_file" ]; then - touch "$log_file" - chmod 600 "$log_file" - fi +# Venv — siempre en el core +HV_VENV="$HV_CORE/.venv" - # Verificar que el usuario actual puede leerlo - if [ ! -r "$log_file" ]; then - log_warn "Log file existe pero no es legible por el usuario actual: $log_file" - log_warn "Intentando corregir permisos..." - chmod 600 "$log_file" 2>/dev/null || { - log_error "No se pudieron corregir los permisos de: $log_file" - return 1 - } - fi -} +# XDG — INSTALL_DIR es $HOME, no el repo +INSTALL_DIR="$HOME" +HV_DATA_DIR="$HOME/data/hydra-veil" +HV_CONFIG_DIR="$HOME/.config/hydra-veil" +HV_CACHE_DIR="$HOME/.cache/hydra-veil" +HV_STATE_DIR="$HOME/.state/hydra-veil" -# ─── Step 1: Repo ───────────────────────────────────────────────────────────── -step_clone_repo() { - print_section "Repository" - log_step "Cloning repository" 1 $TOTAL_STEPS +# sing-box +SINGBOX_BIN="/usr/bin/sing-box" +SINGBOX_WRAPPER="/usr/local/bin/hydraveil-singbox" +SINGBOX_VERSION="1.13.5" +SINGBOX_CONFIG_DIR="$HV_DATA_DIR/configs" +SINGBOX_PID_FILE="$HV_DATA_DIR/runtime/singbox.pid" +SINGBOX_LOG_FILE="$HV_DATA_DIR/runtime/singbox.log" - log_info "Destino: ${HV_REPO_DIR}" +# sudoers +SUDOERS_PATH="/etc/sudoers.d/hydraveil" - if [ -d "$HV_REPO_DIR/.git" ]; then - log_warn "Repo ya existe en: $HV_REPO_DIR" - if ask_confirm "Eliminar y re-clonar?"; then - start_spinner "Eliminando repo anterior..." - rm -rf "$HV_REPO_DIR" - stop_spinner "Eliminado" - else - log_skip "Re-clone omitido — usando repo existente" - return 0 - fi - fi +# Repos +declare -A REPO_URLS=( + ["core"]="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-core " + ["cli"]="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-cli" + ["essentials"]="https://git.simplifiedprivacy.com/Support/sp-essentials" + ["gui"]="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-gui" +) - mkdir -p "$HV_CORE_HOME" - chmod 700 "$HV_CORE_HOME" +declare -A REPO_BRANCHES=( + ["core"]="deploy" + ["cli"]="zenaku" + ["essentials"]="master" + ["gui"]="master" +) - start_spinner "Clonando ${REPO_URL}..." - if git clone --branch "$BRANCH" "$REPO_URL" "$HV_REPO_DIR" &>/dev/null; then - stop_spinner "Repositorio clonado → ${HV_REPO_DIR}" - else - stop_spinner - log_error "git clone falló" +declare -A REPO_DIRS=( + ["cli"]="$HV_CLI" + ["essentials"]="$HV_ESSENTIALS" + ["gui"]="$HV_GUI" +) + +# ─── Importar steps atómicos ────────────────────────────────────────────────── +_STEPS_DIR="$SCRIPT_DIR/lib/steps" + +for _step in preflight repos python_env singbox wrapper sudoers; do + _step_path="${_STEPS_DIR}/${_step}.sh" + if [ ! -f "$_step_path" ]; then + echo "[ERROR] Missing step: $_step_path" >&2 exit 1 fi - - chmod 700 "$HV_REPO_DIR" - log_ok "Permisos aplicados" -} - -# ─── Step 2: .env ───────────────────────────────────────────────────────────── -step_create_env() { - print_section ".env Configuration" - log_step "Building .env from .env.example" 2 $TOTAL_STEPS - - local example_file="$HV_REPO_DIR/.env.example" - local env_file="$HV_REPO_DIR/.env" - - if [ ! -f "$example_file" ]; then - log_error ".env.example no encontrado: $example_file" - exit 1 - fi - - if [ -f "$env_file" ]; then - log_warn ".env ya existe" - if ! ask_confirm "Sobreescribir .env existente?"; then - label_skip ".env sin cambios — omitiendo" - return 0 - fi - fi - - echo "" - printf " ${DIM}Completá cada variable. Enter para dejar vacío.${RESET}\n" - print_divider - - local -a env_lines=() - - while IFS= read -r line || [ -n "$line" ]; do - - # Comentarios y líneas vacías — pasar directo - if [[ -z "$line" || "$line" == \#* ]]; then - env_lines+=("$line") - continue - fi - - local key default_val value - - if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then - key="${BASH_REMATCH[1]}" - default_val="${BASH_REMATCH[2]}" - else - env_lines+=("$line") - continue - fi - - if [ "$key" = "APP_ENV" ]; then - echo "" - printf " ${BCYAN}APP_ENV${RESET} ${DIM}— entorno de la aplicación${RESET}\n" - printf " ${BWHITE}1)${RESET} ${GREEN}local${RESET} ${BWHITE}2)${RESET} ${CYAN}production${RESET}\n" - printf " ${BWHITE}Opción${RESET} ${DIM}[1/2, default=2]:${RESET} " - read -r _choice < /dev/tty - case "$_choice" in - 1) value="local" ;; - *) value="production" ;; - esac - log_ok "APP_ENV=${value}" - - else - local hint="" - [ -n "$default_val" ] && hint="${DIM}(default: ${default_val})${RESET}" - echo "" - printf " ${BWHITE}%s${RESET} %b\n" "$key" "$hint" - printf " ${BCYAN}›${RESET} " - read -r value < /dev/tty - [ -z "$value" ] && value="$default_val" - [ -n "$value" ] && log_ok "${key}=${value}" || label_skip "${key} — vacío" - fi - - env_lines+=("${key}=${value}") - - done < "$example_file" - - # Escribir .env - printf '%s\n' "${env_lines[@]}" > "$env_file" - chmod 600 "$env_file" - - # Agregar paths del installer automáticamente - cat >> "$env_file" << EOF - -# ── Generado por el installer ────────────────────────────────────────────── -HV_CORE_HOME=${HV_CORE_HOME} -INSTALL_DIR=${HV_REPO_DIR} -CORE_DIR=${HV_CORE_HOME} -EOF - - log_ok ".env escrito → ${env_file}" - echo "" - - # Preview ocultando secrets - printf " ${DIM}Preview:${RESET}\n" - while IFS= read -r ln; do - if [[ -z "$ln" || "$ln" == \#* ]]; then - printf " ${DIM}%s${RESET}\n" "$ln" - else - local pk pv - pk="${ln%%=*}" - pv="${ln#*=}" - echo "$pk" | grep -qiE '(secret|token|key|pass)' && pv="****" - printf " ${DIM}%s${RESET}=${CYAN}%s${RESET}\n" "$pk" "$pv" - fi - done < "$env_file" - echo "" -} - -# ─── Step 3: Python venv ────────────────────────────────────────────────────── -step_python_env() { - print_section "Python Environment" - log_step "Setting up virtualenv + dependencies" 3 $TOTAL_STEPS - - if ! command -v python3 &>/dev/null; then - log_error "python3 no encontrado. Instalalo primero." - exit 1 - fi - log_info "Python: $(python3 --version 2>&1)" - - # Crear venv en HV_VENV_DIR (separado del repo) - start_spinner "Creando virtual environment..." - python3 -m venv "$HV_VENV_DIR" - stop_spinner "Virtual environment creado → ${HV_VENV_DIR}" - - source "$HV_VENV_DIR/bin/activate" - - start_spinner "Actualizando pip..." - pip install --quiet --upgrade pip - stop_spinner "pip actualizado" - - local req_file="$HV_REPO_DIR/requirements.txt" - - if [ ! -f "$req_file" ]; then - log_warn "requirements.txt no encontrado — instalando dependencias base" - start_spinner "Instalando dependencias..." - pip install --quiet \ - python-dotenv \ - "cryptography~=46.0.3" \ - "dataclasses-json~=0.6.7" \ - "psutil~=7.1.3" \ - "pysocks~=1.7.1" \ - "requests~=2.32.5" \ - "pydantic==2.13.3" - stop_spinner "Dependencias instaladas" - return 0 - fi - - # Instalar todo de una vez (no paquete por paquete) - # pip resuelve dependencias mejor en un solo lote - log_info "Instalando desde requirements.txt..." - start_spinner "Instalando paquetes..." - if pip install --quiet -r "$req_file"; then - stop_spinner "Paquetes instalados" - else - stop_spinner - log_warn "pip reportó errores — intentando instalar de a uno para identificar el fallo" - local failed=() - while IFS= read -r pkg || [ -n "$pkg" ]; do - pkg="${pkg%%#*}" - pkg="${pkg// /}" - [[ -z "$pkg" ]] && continue - pip install --quiet "$pkg" 2>/dev/null || failed+=("$pkg") - done < "$req_file" - [ "${#failed[@]}" -gt 0 ] && log_warn "Fallaron: ${failed[*]}" - fi - - # Instalar el paquete en modo editable - start_spinner "Instalando paquete (editable)..." - pip install --quiet -e "$HV_REPO_DIR" --no-deps - stop_spinner "Paquete instalado en modo editable" -} - -# ─── Step 4: sing-box ───────────────────────────────────────────────────────── -step_singbox() { - print_section "sing-box" - log_step "Checking sing-box binary" 4 $TOTAL_STEPS - - local found="" - for candidate in /usr/bin/sing-box /usr/local/bin/sing-box /bin/sing-box; do - [ -x "$candidate" ] && found="$candidate" && break - done - [ -z "$found" ] && found=$(command -v sing-box 2>/dev/null || true) - - if [ -n "$found" ]; then - local ver - ver=$("$found" version 2>/dev/null | head -1 || echo "unknown") - log_ok "Encontrado: ${found} (${ver})" - if [ "$found" != "$SINGBOX_BIN" ]; then - sudo ln -sf "$found" "$SINGBOX_BIN" - log_ok "Symlink → ${SINGBOX_BIN}" - fi - else - log_info "sing-box no encontrado — instalando v${SINGBOX_VERSION}..." - local url="https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-amd64.tar.gz" - local tmp_tar="/tmp/sing-box-$$.tar.gz" - local tmp_dir="/tmp/sing-box-$$" - - start_spinner "Descargando sing-box v${SINGBOX_VERSION}..." - if wget -q --timeout=60 "$url" -O "$tmp_tar"; then - stop_spinner "Descarga completa" - else - stop_spinner - rm -f "$tmp_tar" - log_error "Descarga falló: ${url}" - exit 1 - fi - - start_spinner "Extrayendo..." - mkdir -p "$tmp_dir" - tar -xzf "$tmp_tar" -C "$tmp_dir" --strip-components=1 - stop_spinner "Extraído" - - sudo cp "$tmp_dir/sing-box" "$SINGBOX_BIN" - sudo chmod 755 "$SINGBOX_BIN" - rm -rf "$tmp_tar" "$tmp_dir" - log_ok "sing-box instalado: $($SINGBOX_BIN version | head -1)" - fi - - # setcap — sing-box crea TUN sin necesitar root completo - if command -v setcap &>/dev/null; then - sudo setcap cap_net_admin,cap_net_raw+ep "$SINGBOX_BIN" - log_ok "setcap cap_net_admin,cap_net_raw+ep → ${SINGBOX_BIN}" - else - log_warn "setcap no disponible — instalá libcap2-bin para hardening completo" - log_warn "sing-box necesitará sudo completo para TUN hasta que se instale" - fi - - log_ok "sing-box listo → ${SINGBOX_BIN}" -} - -# ─── Step 5: Wrapper ────────────────────────────────────────────────────────── -step_wrapper() { - print_section "Security Wrapper" - log_step "Installing hydraveil-singbox wrapper" 5 $TOTAL_STEPS - - [ -f "$WRAPPER_PATH" ] && sudo rm -f "$WRAPPER_PATH" && log_ok "Wrapper anterior eliminado" - - sudo tee "$WRAPPER_PATH" > /dev/null << WRAPPER -#!/bin/bash -# hydraveil-singbox — wrapper de privilegios para sing-box -# ───────────────────────────────────────────────────────── -# Generado por el installer. NO editar manualmente. -# Solo este binario tiene NOPASSWD en sudoers. -# -# Uso: -# sudo hydraveil-singbox start -# sudo hydraveil-singbox "" stop -# sudo hydraveil-singbox "" log -set -euo pipefail - -CONFIG="\${1:-}" -ACTION="\${2:-}" - -# ── Paths fijos (escritos al instalar, nunca buscados en runtime) ───────────── -SINGBOX_BIN="${SINGBOX_BIN}" -PID_FILE="${SINGBOX_PID_FILE}" -LOG_FILE="${SINGBOX_LOG_FILE}" -CONFIG_DIR="${HV_CONFIG_DIR}" -STOP_TIMEOUT=8 - -# ── Validar acción ──────────────────────────────────────────────────────────── -if [[ "\$ACTION" != "start" && "\$ACTION" != "stop" && "\$ACTION" != "log" ]]; then - echo "Error: acción inválida '\$ACTION'. Uso: start|stop|log" >&2 - exit 1 -fi - -# ── Helpers ─────────────────────────────────────────────────────────────────── -_pid_running() { - local pid="\$1" - [[ "\$pid" =~ ^[0-9]+$ ]] && kill -0 "\$pid" 2>/dev/null -} - -_delete_tun() { - if ip link show tun0 &>/dev/null 2>&1; then - ip link delete tun0 2>/dev/null || true - echo "[wrapper] tun0 eliminada" >> "\$LOG_FILE" 2>/dev/null || true - fi -} - -_kill_by_pidfile() { - [ -f "\$PID_FILE" ] || return 0 - - local pid - pid=\$(cat "\$PID_FILE" 2>/dev/null || echo "") - - if ! _pid_running "\$pid"; then - rm -f "\$PID_FILE" - return 0 - fi - - # SIGTERM → esperar → SIGKILL si no murió - kill -TERM "\$pid" 2>/dev/null || true - echo "[wrapper] SIGTERM → PID \$pid" >> "\$LOG_FILE" 2>/dev/null || true - - local elapsed=0 - while _pid_running "\$pid" && (( elapsed < STOP_TIMEOUT * 2 )); do - sleep 0.5 - (( elapsed++ )) || true - done - - if _pid_running "\$pid"; then - kill -KILL "\$pid" 2>/dev/null || true - echo "[wrapper] SIGKILL → PID \$pid (no respondió a SIGTERM)" >> "\$LOG_FILE" 2>/dev/null || true - sleep 0.3 - fi - - rm -f "\$PID_FILE" -} - -# ── log ─────────────────────────────────────────────────────────────────────── -if [[ "\$ACTION" == "log" ]]; then - [ -f "\$LOG_FILE" ] && tail -80 "\$LOG_FILE" || echo "Sin logs disponibles" - exit 0 -fi - -# ── stop ────────────────────────────────────────────────────────────────────── -if [[ "\$ACTION" == "stop" ]]; then - _kill_by_pidfile - _delete_tun - echo "[wrapper] sing-box detenido — \$(date '+%Y-%m-%d %H:%M:%S')" >> "\$LOG_FILE" 2>/dev/null || true - exit 0 -fi - -# ── start ───────────────────────────────────────────────────────────────────── - -# 1. Config requerido -if [[ -z "\$CONFIG" ]]; then - echo "Error: config requerido para start" >&2 - exit 1 -fi - -# 2. Existe y es legible -if [[ ! -f "\$CONFIG" ]]; then - echo "Error: config no encontrado: \$CONFIG" >&2 - exit 1 -fi -if [[ ! -r "\$CONFIG" ]]; then - echo "Error: config no legible: \$CONFIG" >&2 - exit 1 -fi - -# 3. Path dentro del directorio permitido (evita path traversal) -_real_config=\$(realpath "\$CONFIG") -_real_config_dir=\$(realpath "\$CONFIG_DIR") -if [[ "\$_real_config" != "\$_real_config_dir"/* ]]; then - echo "Error: config fuera del directorio permitido" >&2 - echo " Config: \$_real_config" >&2 - echo " Permitido: \$_real_config_dir" >&2 - exit 1 -fi - -# 4. JSON válido -if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "\$CONFIG" 2>/dev/null; then - echo "Error: JSON inválido: \$CONFIG" >&2 - exit 1 -fi - -# 5. sing-box ejecutable -if [[ ! -x "\$SINGBOX_BIN" ]]; then - echo "Error: sing-box no encontrado: \$SINGBOX_BIN" >&2 - exit 1 -fi - -# 6. Parar instancia previa -_kill_by_pidfile -_delete_tun - -# 7. Directorios — solo mkdir/chmod, nunca tocar el log file existente -mkdir -p "\$(dirname "\$PID_FILE")" && chmod 700 "\$(dirname "\$PID_FILE")" -mkdir -p "\$(dirname "\$LOG_FILE")" && chmod 700 "\$(dirname "\$LOG_FILE")" -# NOTA: el log file fue creado por el installer con permisos del usuario. -# El wrapper solo appendea (>>) — nunca lo recrea ni cambia sus permisos. - -# 8. Lanzar sing-box -"\$SINGBOX_BIN" run -c "\$CONFIG" >> "\$LOG_FILE" 2>&1 & -_new_pid=\$! - -# 9. Verificar arranque inmediato -sleep 0.3 -if ! _pid_running "\$_new_pid"; then - echo "Error: sing-box terminó inmediatamente. Ver: \$LOG_FILE" >&2 - exit 1 -fi - -# 10. PID file -echo "\$_new_pid" > "\$PID_FILE" -chmod 600 "\$PID_FILE" - -echo "[wrapper] sing-box iniciado — PID \$_new_pid — \$(date '+%Y-%m-%d %H:%M:%S')" >> "\$LOG_FILE" -echo "[wrapper] config: \$_real_config" >> "\$LOG_FILE" -exit 0 -WRAPPER - - sudo chmod 755 "$WRAPPER_PATH" - log_ok "Wrapper instalado → ${WRAPPER_PATH}" -} - -# ─── Step 6: sudoers + directorios ──────────────────────────────────────────── -step_sudoers() { - print_section "Permissions" - log_step "Configurando sudoers + directorios" 6 $TOTAL_STEPS - - local _tmp_sudoers - _tmp_sudoers=$(mktemp) - - cat > "$_tmp_sudoers" << EOF -# hydraveil — generado por installer $(date '+%Y-%m-%d') -# NO editar manualmente -Defaults!${WRAPPER_PATH} !requiretty -${USER} ALL=(root) NOPASSWD: ${WRAPPER_PATH} -EOF - - # Validar con visudo ANTES de instalar — nunca instalar un sudoers roto - if ! sudo visudo -c -f "$_tmp_sudoers" &>/dev/null; then - rm -f "$_tmp_sudoers" - log_error "sudoers inválido — abortando para evitar lockout" - exit 1 - fi - - sudo cp "$_tmp_sudoers" "$SUDOERS_PATH" - sudo chmod 440 "$SUDOERS_PATH" - rm -f "$_tmp_sudoers" - log_ok "sudoers validado e instalado → ${SUDOERS_PATH}" - - # Directorios con permisos correctos - local -a _dirs=( - "$HV_CONFIG_DIR" - "$HV_RUN_DIR" - "$HV_LOG_DIR" - "$HV_DATA_DIR" - ) - for _dir in "${_dirs[@]}"; do - mkdir -p "$_dir" - chmod 700 "$_dir" - log_ok "Directorio → ${_dir}" - done - - # ── Crear log file con permisos del usuario ANTES de que root lo toque ──── - # Crítico: si root crea el archivo primero (via wrapper), Python no puede - # leerlo. _ensure_log_file() lo crea como usuario con chmod 600 ahora. - _ensure_log_file "$SINGBOX_LOG_FILE" - log_ok "Log file preparado con permisos de usuario → ${SINGBOX_LOG_FILE}" -} + source "$_step_path" +done \ No newline at end of file diff --git a/lib/steps/preflight.sh b/lib/steps/preflight.sh new file mode 100644 index 0000000..72747a3 --- /dev/null +++ b/lib/steps/preflight.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# lib/steps/preflight.sh — verificaciones previas + +step_preflight() { + print_section "Pre-flight" + log_step "System checks" 1 $TOTAL_STEPS + + _preflight_user + _preflight_python + _preflight_git +} + +_preflight_user() { + log_info "Running as: ${USER} (uid=$(id -u))" + if [ "$(id -u)" -eq 0 ]; then + log_error "No correr el installer como root" + exit 1 + fi + log_ok "User OK" +} + +_preflight_python() { + local found="" + for candidate in python3.12 python3.13; do + if command -v "$candidate" &>/dev/null; then + found="$candidate" + break + fi + done + if [ -z "$found" ]; then + log_error "Se necesita Python 3.12 o 3.13" + log_info "Arch: sudo pacman -S python312" + log_info "Ubuntu: sudo apt install python3.12" + exit 1 + fi + PYTHON_BIN="$found" + log_ok "Python: $($PYTHON_BIN --version 2>&1)" +} + +_preflight_git() { + if ! command -v git &>/dev/null; then + log_error "git no encontrado" + exit 1 + fi + log_ok "git: $(git --version)" +} \ No newline at end of file diff --git a/lib/steps/python_env.sh b/lib/steps/python_env.sh new file mode 100644 index 0000000..ee51652 --- /dev/null +++ b/lib/steps/python_env.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# lib/steps/python_env.sh — venv + pip install editable + +step_python_env() { + print_section "Python Environment" + log_step "Virtual environment + dependencies" 3 $TOTAL_STEPS + + _create_venv + _pip_install +} + +_create_venv() { + if [ -d "$HV_VENV" ]; then + log_warn "venv ya existe: ${HV_VENV}" + if ask_confirm "Recrear venv?"; then + rm -rf "$HV_VENV" + else + log_skip "venv existente conservado" + source "$HV_VENV/bin/activate" + return 0 + fi + fi + + start_spinner "Creando virtual environment (${PYTHON_BIN})..." + "$PYTHON_BIN" -m venv "$HV_VENV" + stop_spinner "venv creado → ${HV_VENV}" + + source "$HV_VENV/bin/activate" + + start_spinner "Actualizando pip..." + pip install --quiet --upgrade pip + stop_spinner "pip actualizado" +} + +_pip_install() { + source "$HV_VENV/bin/activate" + + # Fix: la GUI puede pedir core==2.3.4 pero core puede ser 2.3.x + if [ -f "$HV_GUI/pyproject.toml" ]; then + sed -i 's/sp-hydra-veil-core == [0-9.]*/sp-hydra-veil-core >= 2.3.0/' \ + "$HV_GUI/pyproject.toml" 2>/dev/null || true + fi + + # Orden de instalación: dependencias primero + for name in essentials core cli gui; do + local dir + case "$name" in + essentials) dir="$HV_ESSENTIALS" ;; + core) dir="$HV_CORE" ;; + cli) dir="$HV_CLI" ;; + gui) dir="$HV_GUI" ;; + esac + + if [ ! -d "$dir" ]; then + log_warn "${name} no encontrado en ${dir} — omitiendo" + continue + fi + + start_spinner "Instalando ${name} (editable)..." + if pip install --quiet -e "$dir" 2>/tmp/pip_err_${name}; then + stop_spinner "${name} instalado" + else + stop_spinner + log_error "pip install falló para ${name}:" + tail -5 /tmp/pip_err_${name} + exit 1 + fi + done +} \ No newline at end of file diff --git a/lib/steps/repos.sh b/lib/steps/repos.sh new file mode 100644 index 0000000..07710d1 --- /dev/null +++ b/lib/steps/repos.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# lib/steps/repos.sh — clonar repositorios + generar .env + +step_repos() { + print_section "Repositories" + log_step "Cloning repositories & configuring .env" 2 $TOTAL_STEPS + + mkdir -p "$HV_BASE" + chmod 700 "$HV_BASE" + + _clone_repos + _setup_env +} + +_clone_repos() { + for name in essentials cli gui; do + local url="${REPO_URLS[$name]}" + local branch="${REPO_BRANCHES[$name]}" + local dest="${REPO_DIRS[$name]}" + + if [ -d "$dest/.git" ]; then + log_warn "Repo ya existe: ${dest}" + if ask_confirm "Eliminar y re-clonar ${name}?"; then + rm -rf "$dest" + else + log_skip "Re-clone omitido — usando ${dest}" + continue + fi + fi + + start_spinner "Clonando ${name} (branch: ${branch})..." + if git clone --branch "$branch" "$url" "$dest" &>/dev/null; then + stop_spinner "${name} clonado → ${dest}" + else + stop_spinner + log_error "git clone falló: ${url}" + exit 1 + fi + chmod 700 "$dest" + done +} + +_setup_env() { + local env_file="$HV_CORE/.env" + local example_file="$HV_CORE/.env.example" + + if [ ! -f "$example_file" ]; then + log_error ".env.example no encontrado: ${example_file}" + exit 1 + fi + + if [ -f "$env_file" ]; then + log_warn ".env ya existe — sobreescribiendo" + fi + + echo "" + printf " ${DIM}Completá las variables. ENTER = dejar vacío.${RESET}\n" + print_divider + echo "" + + # APP_ENV + select_option \ + "APP_ENV — entorno de la aplicación" \ + "local desarrollo / testing" \ + "production producción / dominio real" + + case "$SELECT_RESULT" in + 0) _app_env="local" ;; + 1) _app_env="production" ;; + *) _app_env="production" ;; + esac + + # SP_API_BASE_URL_PRODUCTION + echo "" + printf " ${BCYAN}┌─${RESET} ${BWHITE}SP_API_BASE_URL_PRODUCTION${RESET} ${DIM}e.g. https://fake.simplifiedprivacy.org/api/v1${RESET}\n" + printf " ${BCYAN}›${RESET} " + read -r _api_url < /dev/tty + + # Escribir .env + cat > "$env_file" << EOF +APP_ENV=${_app_env} +SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 +SP_API_BASE_URL_PRODUCTION=${_api_url} + +# ── Generado por el installer ───────────────────────────────────────────── +HV_CORE_HOME=${HV_CORE} +INSTALL_DIR=${HOME} +CORE_DIR=${HV_CORE} +EOF + + chmod 600 "$env_file" + log_ok "APP_ENV=${_app_env}" + [ -n "$_api_url" ] && log_ok "SP_API_BASE_URL_PRODUCTION=${_api_url}" || label_skip "SP_API_BASE_URL_PRODUCTION — vacío" + log_ok "INSTALL_DIR=${HOME}" + log_ok ".env escrito → ${env_file}" +} \ No newline at end of file diff --git a/lib/steps/singbox.sh b/lib/steps/singbox.sh new file mode 100644 index 0000000..07710d1 --- /dev/null +++ b/lib/steps/singbox.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# lib/steps/repos.sh — clonar repositorios + generar .env + +step_repos() { + print_section "Repositories" + log_step "Cloning repositories & configuring .env" 2 $TOTAL_STEPS + + mkdir -p "$HV_BASE" + chmod 700 "$HV_BASE" + + _clone_repos + _setup_env +} + +_clone_repos() { + for name in essentials cli gui; do + local url="${REPO_URLS[$name]}" + local branch="${REPO_BRANCHES[$name]}" + local dest="${REPO_DIRS[$name]}" + + if [ -d "$dest/.git" ]; then + log_warn "Repo ya existe: ${dest}" + if ask_confirm "Eliminar y re-clonar ${name}?"; then + rm -rf "$dest" + else + log_skip "Re-clone omitido — usando ${dest}" + continue + fi + fi + + start_spinner "Clonando ${name} (branch: ${branch})..." + if git clone --branch "$branch" "$url" "$dest" &>/dev/null; then + stop_spinner "${name} clonado → ${dest}" + else + stop_spinner + log_error "git clone falló: ${url}" + exit 1 + fi + chmod 700 "$dest" + done +} + +_setup_env() { + local env_file="$HV_CORE/.env" + local example_file="$HV_CORE/.env.example" + + if [ ! -f "$example_file" ]; then + log_error ".env.example no encontrado: ${example_file}" + exit 1 + fi + + if [ -f "$env_file" ]; then + log_warn ".env ya existe — sobreescribiendo" + fi + + echo "" + printf " ${DIM}Completá las variables. ENTER = dejar vacío.${RESET}\n" + print_divider + echo "" + + # APP_ENV + select_option \ + "APP_ENV — entorno de la aplicación" \ + "local desarrollo / testing" \ + "production producción / dominio real" + + case "$SELECT_RESULT" in + 0) _app_env="local" ;; + 1) _app_env="production" ;; + *) _app_env="production" ;; + esac + + # SP_API_BASE_URL_PRODUCTION + echo "" + printf " ${BCYAN}┌─${RESET} ${BWHITE}SP_API_BASE_URL_PRODUCTION${RESET} ${DIM}e.g. https://fake.simplifiedprivacy.org/api/v1${RESET}\n" + printf " ${BCYAN}›${RESET} " + read -r _api_url < /dev/tty + + # Escribir .env + cat > "$env_file" << EOF +APP_ENV=${_app_env} +SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 +SP_API_BASE_URL_PRODUCTION=${_api_url} + +# ── Generado por el installer ───────────────────────────────────────────── +HV_CORE_HOME=${HV_CORE} +INSTALL_DIR=${HOME} +CORE_DIR=${HV_CORE} +EOF + + chmod 600 "$env_file" + log_ok "APP_ENV=${_app_env}" + [ -n "$_api_url" ] && log_ok "SP_API_BASE_URL_PRODUCTION=${_api_url}" || label_skip "SP_API_BASE_URL_PRODUCTION — vacío" + log_ok "INSTALL_DIR=${HOME}" + log_ok ".env escrito → ${env_file}" +} \ No newline at end of file diff --git a/lib/steps/sudoers.sh b/lib/steps/sudoers.sh new file mode 100644 index 0000000..5753297 --- /dev/null +++ b/lib/steps/sudoers.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# lib/steps/sudoers.sh — sudoers + directorios XDG + +step_sudoers() { + print_section "Permissions & Directories" + log_step "sudoers + XDG directories" 6 $TOTAL_STEPS + + _setup_sudoers + _create_directories +} + +_setup_sudoers() { + local tmp + tmp=$(mktemp) + + cat > "$tmp" << EOF +# hydraveil — generado por installer $(date '+%Y-%m-%d') +# NO editar manualmente +Defaults!${SINGBOX_WRAPPER} !requiretty +${USER} ALL=(root) NOPASSWD: ${SINGBOX_WRAPPER} +EOF + + if ! sudo visudo -c -f "$tmp" &>/dev/null; then + rm -f "$tmp" + log_error "sudoers inválido — abortando para evitar lockout" + exit 1 + fi + + sudo cp "$tmp" "$SUDOERS_PATH" + sudo chmod 440 "$SUDOERS_PATH" + rm -f "$tmp" + log_ok "sudoers instalado → ${SUDOERS_PATH}" +} + +_create_directories() { + local -a dirs=( + # XDG data + "$HV_DATA_DIR/configs" + "$HV_DATA_DIR/runtime" + "$HV_DATA_DIR/applications" + "$HV_DATA_DIR/profiles" + "$HV_DATA_DIR/ticket_data" + "$HV_DATA_DIR/incidents" + # XDG config + "$HV_CONFIG_DIR/profiles" + "$HV_CONFIG_DIR/ticketing" + # XDG state + "$HV_STATE_DIR/sessions" + "$HV_STATE_DIR/tor" + # XDG cache + "$HV_CACHE_DIR" + ) + + for dir in "${dirs[@]}"; do + mkdir -p "$dir" + chmod 700 "$dir" + log_ok "→ ${dir}" + done + + # Log file con permisos de usuario ANTES de que el wrapper lo toque + local log_file="$SINGBOX_LOG_FILE" + if [ ! -f "$log_file" ]; then + touch "$log_file" + chmod 600 "$log_file" + log_ok "Log file creado → ${log_file}" + else + log_ok "Log file ya existe → ${log_file}" + fi +} \ No newline at end of file diff --git a/lib/steps/wrapper.sh b/lib/steps/wrapper.sh new file mode 100644 index 0000000..0ca3fb0 --- /dev/null +++ b/lib/steps/wrapper.sh @@ -0,0 +1,140 @@ +#!/bin/bash +# lib/steps/wrapper.sh — generar wrapper de privilegios para sing-box + +step_wrapper() { + print_section "Security Wrapper" + log_step "Installing hydraveil-singbox wrapper" 5 $TOTAL_STEPS + + [ -f "$SINGBOX_WRAPPER" ] && sudo rm -f "$SINGBOX_WRAPPER" && log_ok "Wrapper anterior eliminado" + + sudo tee "$SINGBOX_WRAPPER" > /dev/null << WRAPPER +#!/bin/bash +# hydraveil-singbox — wrapper de privilegios para sing-box +# Generado por el installer. NO editar manualmente. +# Solo este binario tiene NOPASSWD en sudoers. +# +# Uso: +# sudo hydraveil-singbox start +# sudo hydraveil-singbox "" stop +# sudo hydraveil-singbox "" log +set -euo pipefail + +CONFIG="\${1:-}" +ACTION="\${2:-}" + +# ── Paths fijos (rutas XDG correctas — INSTALL_DIR=\$HOME) ──────────────────── +SINGBOX_BIN="${SINGBOX_BIN}" +PID_FILE="${SINGBOX_PID_FILE}" +LOG_FILE="${SINGBOX_LOG_FILE}" +CONFIG_DIR="${SINGBOX_CONFIG_DIR}" +STOP_TIMEOUT=8 + +# ── Validar acción ──────────────────────────────────────────────────────────── +if [[ "\$ACTION" != "start" && "\$ACTION" != "stop" && "\$ACTION" != "log" ]]; then + echo "Error: acción inválida '\$ACTION'. Uso: start|stop|log" >&2 + exit 1 +fi + +# ── Helpers ─────────────────────────────────────────────────────────────────── +_pid_running() { + local pid="\$1" + [[ "\$pid" =~ ^[0-9]+$ ]] && kill -0 "\$pid" 2>/dev/null +} + +_delete_tun() { + if ip link show tun0 &>/dev/null 2>&1; then + ip link delete tun0 2>/dev/null || true + echo "[wrapper] tun0 eliminada" >> "\$LOG_FILE" 2>/dev/null || true + fi +} + +_kill_by_pidfile() { + [ -f "\$PID_FILE" ] || return 0 + local pid + pid=\$(cat "\$PID_FILE" 2>/dev/null || echo "") + if ! _pid_running "\$pid"; then + rm -f "\$PID_FILE" + return 0 + fi + kill -TERM "\$pid" 2>/dev/null || true + echo "[wrapper] SIGTERM → PID \$pid" >> "\$LOG_FILE" 2>/dev/null || true + local elapsed=0 + while _pid_running "\$pid" && (( elapsed < STOP_TIMEOUT * 2 )); do + sleep 0.5 + (( elapsed++ )) || true + done + if _pid_running "\$pid"; then + kill -KILL "\$pid" 2>/dev/null || true + echo "[wrapper] SIGKILL → PID \$pid" >> "\$LOG_FILE" 2>/dev/null || true + sleep 0.3 + fi + rm -f "\$PID_FILE" +} + +# ── log ─────────────────────────────────────────────────────────────────────── +if [[ "\$ACTION" == "log" ]]; then + [ -f "\$LOG_FILE" ] && tail -80 "\$LOG_FILE" || echo "Sin logs disponibles" + exit 0 +fi + +# ── stop ────────────────────────────────────────────────────────────────────── +if [[ "\$ACTION" == "stop" ]]; then + _kill_by_pidfile + _delete_tun + echo "[wrapper] sing-box detenido — \$(date '+%Y-%m-%d %H:%M:%S')" >> "\$LOG_FILE" 2>/dev/null || true + exit 0 +fi + +# ── start ───────────────────────────────────────────────────────────────────── +[[ -z "\$CONFIG" ]] && { echo "Error: config requerido para start" >&2; exit 1; } +[[ ! -f "\$CONFIG" ]] && { echo "Error: config no encontrado: \$CONFIG" >&2; exit 1; } +[[ ! -r "\$CONFIG" ]] && { echo "Error: config no legible: \$CONFIG" >&2; exit 1; } + +_real_config=\$(realpath "\$CONFIG") +_real_config_dir=\$(realpath "\$CONFIG_DIR") +if [[ "\$_real_config" != "\$_real_config_dir"/* ]]; then + echo "Error: config fuera del directorio permitido" >&2 + echo " Config: \$_real_config" >&2 + echo " Permitido: \$_real_config_dir" >&2 + exit 1 +fi + +if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "\$CONFIG" 2>/dev/null; then + echo "Error: JSON inválido: \$CONFIG" >&2 + exit 1 +fi + +[[ ! -x "\$SINGBOX_BIN" ]] && { echo "Error: sing-box no encontrado: \$SINGBOX_BIN" >&2; exit 1; } + +_kill_by_pidfile +_delete_tun + +mkdir -p "\$(dirname "\$PID_FILE")" && chmod 700 "\$(dirname "\$PID_FILE")" +mkdir -p "\$(dirname "\$LOG_FILE")" && chmod 700 "\$(dirname "\$LOG_FILE")" + +"\$SINGBOX_BIN" run -c "\$CONFIG" >> "\$LOG_FILE" 2>&1 & +_new_pid=\$! + +sleep 0.3 +if ! _pid_running "\$_new_pid"; then + echo "Error: sing-box terminó inmediatamente. Ver: \$LOG_FILE" >&2 + exit 1 +fi + +echo "\$_new_pid" > "\$PID_FILE" +chmod 600 "\$PID_FILE" +echo "[wrapper] sing-box iniciado — PID \$_new_pid — \$(date '+%Y-%m-%d %H:%M:%S')" >> "\$LOG_FILE" +echo "[wrapper] config: \$_real_config" >> "\$LOG_FILE" +exit 0 +WRAPPER + + sudo chmod 755 "$SINGBOX_WRAPPER" + log_ok "Wrapper instalado → ${SINGBOX_WRAPPER}" + + # Verificar que el wrapper funciona + if sudo "$SINGBOX_WRAPPER" "" stop &>/dev/null; then + log_ok "Wrapper verificado (stop OK)" + else + log_warn "Wrapper instalado pero verificación falló — revisar manualmente" + fi +} \ No newline at end of file diff --git a/logs/hydra-veil/singbox.log b/logs/hydra-veil/singbox.log new file mode 100644 index 0000000..b946dfe --- /dev/null +++ b/logs/hydra-veil/singbox.log @@ -0,0 +1,2 @@ +[wrapper] sing-box detenido — 2026-05-30 07:44:53 +[wrapper] sing-box detenido — 2026-05-30 07:44:53 -- 2.45.2 From bf8eb9d96c48132c1ca7e117eab2b5fcefb14ad3 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 07:19:50 -0500 Subject: [PATCH 23/51] all support, core,cli,gui,essentiials --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 23e1148..59280df 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ print('status:', invoice.status) ## 2. Patch subscription / Parchear suscripción ```bash -sudo docker compose exec laravel.test php artisan tinker --execute=" +sudo docker compose exec laravel php artisan tinker --execute=" \$sub = \App\Models\Subscription::orderBy('id', 'desc')->first(); \$sub->expires_at = '2027-12-31 23:59:59'; \$sub->duration = 720; @@ -51,7 +51,7 @@ observer.subscribe('connected', lambda e: print('connected:', e.subject)) observer.subscribe('error', lambda e: print('error:', e.subject)) observer.subscribe('disconnected', lambda e: print('disconnected:', e.subject)) -session = WebServiceApiService.post_operator_proxy('', , 'vless') +session = WebServiceApiService.post_operator_proxy('MZKD-QWWI-1TS9-KHMD', 3, 'vless') print('session:', session) controller = VlessController(1080) @@ -72,7 +72,7 @@ observer.subscribe('connected', lambda e: print('connected:', e.subject)) observer.subscribe('error', lambda e: print('error:', e.subject)) observer.subscribe('disconnected', lambda e: print('disconnected:', e.subject)) -session = WebServiceApiService.post_operator_proxy('', , 'hysteria2') +session = WebServiceApiService.post_operator_proxy('MZKD-QWWI-1TS9-KHMD', 3, 'hysteria2') print('session:', session) controller = HysteriaController(1080) -- 2.45.2 From 7387844371370d343b63f4b17214039b3739a6f3 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 07:26:10 -0500 Subject: [PATCH 24/51] Remove deleted files --- data/hydra-veil/storage.db | Bin 86016 -> 0 bytes data/hydra-veil/ticket_data/logs/errors.log | 0 logs/hydra-veil/singbox.log | 2 -- 3 files changed, 2 deletions(-) delete mode 100644 data/hydra-veil/storage.db delete mode 100644 data/hydra-veil/ticket_data/logs/errors.log delete mode 100644 logs/hydra-veil/singbox.log diff --git a/data/hydra-veil/storage.db b/data/hydra-veil/storage.db deleted file mode 100644 index 628bceb98b4a6260af8f320c28be2dac12686cda..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 86016 zcmeI*PmJ7F9S897c(c3S?5>lv>9m9;15&#Uo1OLlKY>a#QHY{8NP$E}RA}U}pXZ;) z6$$l%IP}P^2YR3q2ZTfsLMj{(>ZNKgZIS4KH-EFUyV+#hP17V_ zD;bafyyy2mzh^sB`@R0)`pearUc(C{r?vLE9J7tFT5P$##9y@{UYnux>Rb^g#A+|~$v4X&>+E!d``o%U0BRY@v zTQ`5{`x#uW32Oz=|u=bh8H!39V;DoT3#^ z_ZTO4s~#yP*{D~ij-Qr9b#yEC! zw9U5~doDBk?Z4lCakW8;y{5k>gJwWz=$c`1e3cs?lZL2Wbx2s;FSF`6ghks52AABK zP|}UlAnv!r_(C)&`1$;#YJ(+u=@HHO+~u=(3ied8C{4ef5{-LHB2JT>++I+J0lf9& zVE`ur(e%seOaP~=4-DXbPH{Kb;~gX=dV4C?2|WR3?-+hFio@bvw};K)B|aoB35h>A z!}iO@ienPRqq`t!6F;W+n%xPgw{{TTwBki^`jzir5*KnC8_f3TZfkqhLFSVi?XJb8 z8OU8559FM^D^2S-dto}@HhAD&bm`m+%6#tn#+@44u7VcjT`N{zkPnkw+Le6y1?e!! z#a+pdUlHbWuc~({{Ei#sG%PbW>gihU%GRMX+Ii!iX0&*vaOjM7-pHpJE%cYqE##hk zmg&E```>aq{|y&qXWG?{8b=2aLKz4~e!;N1D82$c#E5pCVzt6w(*a4x!5P$## zAOHafKmY;|fB*y_0D*@_;3T`w%=8FgpJGL3Z$E(SDRzU~_5S~`|Nru@GOO>c{GQG* zK>z{}fB*y_009U<00I#Bq6N0U#x60rHLh=8+Flv;c@_(rTo5(2630PwVQb5^>ZA}= z8+0sOw@Ms37H8YCyM<<0-kJ=J7j1b}Kk00C46UdsTRTTl+ZsL=-7Jl|P>+`;hbY8W zSSa-*NszQnLEV(p8-jE}G%ko*K^RUy8qFTu=k{iHiCNp=`V9&+?fWfCQb8zak|q{R zWm7yNs>yTXt;v_o_O1gB!OFdnm$E5oUp}wX%o2Ad$MtVguko{QQIZS75gCoM+#1gZ zLNxOk7j;vVzH~0=|Nli_+HgDwKmY;|fB*y_009U<00Izz!2K84xBvg@8=2KN_^nIq_V@A%2%F6H8%K4($@>#`eUTzt znd{%=j;NXkzM3$4{{Lo%f0O?U|G_@ztY1)!$tVt={RzA6Rf!)*TkYW? z2183!8&;WYRk}`SZMI3(8;?3scrggB4mS0lVCI=Yp|XP{;-plvaDxskAt)Cl<%mlq z-b<+l&;PUh#~J=({(lcsh*4GuKmY;|fB*y_009U<00Izz00izPFvoICc8+DZ?CAIZ z`x*Lw|NG=_sjwXa5P$##AOHafKmY;|fB*y_0D*@-MkVZ8fi2Y3RBOjd#qfG+BZ$&kB4|?V)Qv`{dO_K#(fwN;B}#jdAB0V} z>JhQkirR)^n7ZaSV`>fGlO-pz4L7AlG3$-ck~JYxJB04v5{y7L9G|pl z7v(`7QrNDCnqTuA*RqLGPQyeA>weF2h-WJuyB(^PP_NY*j;!03VblGiUN;OZmk4!L zPujj}1iBW{>UZrFxIoRVt_C5?V4CBYzuq6n(1>aH%SE-AUT zrirTJs$$73iHbusgE)$;(;y7R)RIXw^*H zQ%S3$YDqn+N3CwVzEixc3hk7H6szV%J;5{Ui6GU?)OX93p4Re&PORIVa?qmzY)7%? zq;WY2NTbq8h-N00deUh4CaHJyMj-TB>->^Vw9HWJw} zf{JZ5B9aPqQ!58?LsQ!|uh$BC=4|GvOnQmca2-;s=`FeSS?b*h265_{X(h2kw|y&N zmN|VGsO_y=$F9+)kuP=g*DSkI^=YiG`t~$d^qe4uX=)`~RV-Qv8WdenT%u?SJy%Rc zG$c!r1%rl2wrtHRS(;6wsTh(g8MdwtW7U!g@ym79ucc}1)uW1Lrl#IbYxTA!l-)>* zr5^1yFIPNOvdng)=9;a<3OeabtXkzHA!;nS#4t>+QI4%%rRQ}FH|{uw+8vZ8NY%F8 z@M!k6x)~)-CUM;;YksdSM`ohOs!)q>M>mel zz|_s8=1MKC5yZ7f5G`S#t4lg<{0q>fyv+p*p2 zwt9xqae7)UbqNu4t>={$yFz45BECzh0@o?WO4#Xmx*V&DpSoT3_6aJe9k1OTjsO2) zhX0g~0Px`>d8tRGAOHafKmY;|fB*y_009U<00NJlz%iC#Sa$H4&9W@V4W9oq{GT%P z4<-me00Izz00bZa0SG_<0uX=z1Reqbi8;=lJah7yb?z+3Iphin8~Yrf&tXIaVGo O%|7ry!LHA~0`PwuO4t_w diff --git a/data/hydra-veil/ticket_data/logs/errors.log b/data/hydra-veil/ticket_data/logs/errors.log deleted file mode 100644 index e69de29..0000000 diff --git a/logs/hydra-veil/singbox.log b/logs/hydra-veil/singbox.log deleted file mode 100644 index b946dfe..0000000 --- a/logs/hydra-veil/singbox.log +++ /dev/null @@ -1,2 +0,0 @@ -[wrapper] sing-box detenido — 2026-05-30 07:44:53 -[wrapper] sing-box detenido — 2026-05-30 07:44:53 -- 2.45.2 From 5ef45ad6b42dca774c1e3580326c7145a9bea11d Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 07:53:09 -0500 Subject: [PATCH 25/51] 2 july 2026 --- lib/steps.sh | 11 +- lib/steps/preflight.sh | 1 - lib/steps/python_env.sh | 2 - lib/steps/repos.sh | 2 - pyproject.toml | 1 + requirements.txt | 31 ----- uninstall.sh | 252 +++++++++++++++------------------------- 7 files changed, 98 insertions(+), 202 deletions(-) delete mode 100644 requirements.txt diff --git a/lib/steps.sh b/lib/steps.sh index 49cc7c8..c5191b6 100755 --- a/lib/steps.sh +++ b/lib/steps.sh @@ -3,20 +3,15 @@ TOTAL_STEPS=6 -# ─── Variables globales ─────────────────────────────────────────────────────── -# Directorio base de hydra-veil HV_BASE="$HOME/.local/share/hydra-veil" -# Componentes HV_CORE="$HV_BASE/core" HV_CLI="$HV_BASE/cli" HV_GUI="$HV_BASE/gui" HV_ESSENTIALS="$HV_BASE/essentials" -# Venv — siempre en el core HV_VENV="$HV_CORE/.venv" -# XDG — INSTALL_DIR es $HOME, no el repo INSTALL_DIR="$HOME" HV_DATA_DIR="$HOME/data/hydra-veil" HV_CONFIG_DIR="$HOME/.config/hydra-veil" @@ -31,12 +26,10 @@ SINGBOX_CONFIG_DIR="$HV_DATA_DIR/configs" SINGBOX_PID_FILE="$HV_DATA_DIR/runtime/singbox.pid" SINGBOX_LOG_FILE="$HV_DATA_DIR/runtime/singbox.log" -# sudoers SUDOERS_PATH="/etc/sudoers.d/hydraveil" -# Repos declare -A REPO_URLS=( - ["core"]="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-core " + ["core"]="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-core" ["cli"]="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-cli" ["essentials"]="https://git.simplifiedprivacy.com/Support/sp-essentials" ["gui"]="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-gui" @@ -54,8 +47,6 @@ declare -A REPO_DIRS=( ["essentials"]="$HV_ESSENTIALS" ["gui"]="$HV_GUI" ) - -# ─── Importar steps atómicos ────────────────────────────────────────────────── _STEPS_DIR="$SCRIPT_DIR/lib/steps" for _step in preflight repos python_env singbox wrapper sudoers; do diff --git a/lib/steps/preflight.sh b/lib/steps/preflight.sh index 72747a3..994a044 100644 --- a/lib/steps/preflight.sh +++ b/lib/steps/preflight.sh @@ -1,6 +1,5 @@ #!/bin/bash # lib/steps/preflight.sh — verificaciones previas - step_preflight() { print_section "Pre-flight" log_step "System checks" 1 $TOTAL_STEPS diff --git a/lib/steps/python_env.sh b/lib/steps/python_env.sh index ee51652..60fa912 100644 --- a/lib/steps/python_env.sh +++ b/lib/steps/python_env.sh @@ -35,13 +35,11 @@ _create_venv() { _pip_install() { source "$HV_VENV/bin/activate" - # Fix: la GUI puede pedir core==2.3.4 pero core puede ser 2.3.x if [ -f "$HV_GUI/pyproject.toml" ]; then sed -i 's/sp-hydra-veil-core == [0-9.]*/sp-hydra-veil-core >= 2.3.0/' \ "$HV_GUI/pyproject.toml" 2>/dev/null || true fi - # Orden de instalación: dependencias primero for name in essentials core cli gui; do local dir case "$name" in diff --git a/lib/steps/repos.sh b/lib/steps/repos.sh index 07710d1..661bbfd 100644 --- a/lib/steps/repos.sh +++ b/lib/steps/repos.sh @@ -70,13 +70,11 @@ _setup_env() { *) _app_env="production" ;; esac - # SP_API_BASE_URL_PRODUCTION echo "" printf " ${BCYAN}┌─${RESET} ${BWHITE}SP_API_BASE_URL_PRODUCTION${RESET} ${DIM}e.g. https://fake.simplifiedprivacy.org/api/v1${RESET}\n" printf " ${BCYAN}›${RESET} " read -r _api_url < /dev/tty - # Escribir .env cat > "$env_file" << EOF APP_ENV=${_app_env} SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 diff --git a/pyproject.toml b/pyproject.toml index ed6412f..804ecad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ classifiers = [ ] dependencies = [ "cryptography ~= 46.0.3", + "python-dotenv ~= 1.0.0", "dataclasses-json ~= 0.6.7", "marshmallow ~= 3.26.1", "pysocks ~= 1.7.1", diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 01a6b02..0000000 --- a/requirements.txt +++ /dev/null @@ -1,31 +0,0 @@ -python-dotenv -cryptography~=46.0.3 -dataclasses-json~=0.6.7 -marshmallow~=3.26.1 -psutil~=7.1.3 -pysocks~=1.7.1 -python-dateutil~=2.9.0.post0 -requests~=2.32.5 -annotated-types==0.7.0 -certifi==2026.4.22 -charset-normalizer==3.4.7 -click==8.3.3 -cytoolz==1.1.0 -eth-hash==0.8.0 -eth-typing==6.0.0 -eth-utils==6.0.0 -idna==3.13 -packaging==26.2 -pathspec==1.1.1 -platformdirs==4.9.6 -py-ecc==8.0.0 -pydantic==2.13.3 -pydantic_core==2.46.3 -pydeps==3.0.6 -pytokens==0.4.1 -stdlib-list==0.12.0 -toolz==1.1.0 -typing-inspect==0.9.0 -typing-inspection==0.4.2 -typing_extensions==4.15.0 -urllib3==2.6.3 \ No newline at end of file diff --git a/uninstall.sh b/uninstall.sh index c5a9c43..d2f023c 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,113 +1,93 @@ #!/bin/bash -# hv-clean.sh — Limpieza completa de hydra-veil para reinstalación limpia -# Elimina: wrapper, sudoers, dirs de runtime (logs/run/pid) -# Conserva: repo, venv, configs (a menos que se pase --full) set -euo pipefail -# ─── Colores ────────────────────────────────────────────────────────────────── -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -DIM='\033[2m' -BOLD='\033[1m' -RESET='\033[0m' +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="$SCRIPT_DIR/lib" -ok() { printf " ${GREEN}✔${RESET} %s\n" "$*"; } -warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$*"; } -info() { printf " ${CYAN}→${RESET} %s\n" "$*"; } -err() { printf " ${RED}✖${RESET} %s\n" "$*" >&2; } -hdr() { echo ""; printf "${BOLD}${CYAN}── %s ${RESET}\n" "$*"; echo ""; } +for _lib in ui log input steps; do + _path="$LIB_DIR/${_lib}.sh" + if [ ! -f "$_path" ]; then + echo "[ERROR] Missing lib: $_path" >&2 + exit 1 + fi + source "$_path" +done -# ─── Paths (deben coincidir con steps.sh) ──────────────────────────────────── -HV_CORE_HOME="$HOME/.local/share/hydra-veil/core" -HV_LOG_DIR="$HV_CORE_HOME/logs/hydra-veil" -HV_RUN_DIR="$HV_CORE_HOME/run" -HV_DATA_DIR="$HV_CORE_HOME/data/hydra-veil" -HV_CONFIG_DIR="$HV_DATA_DIR/configs" -HV_VENV_DIR="$HV_CORE_HOME/.venv" - -SINGBOX_BIN="/usr/bin/sing-box" -SINGBOX_PID_FILE="$HV_RUN_DIR/singbox.pid" -SINGBOX_LOG_FILE="$HV_LOG_DIR/singbox.log" - -WRAPPER_PATH="/usr/local/bin/hydraveil-singbox" -SUDOERS_PATH="/etc/sudoers.d/hydraveil" - -# ─── Flags ──────────────────────────────────────────────────────────────────── FULL=false YES=false for arg in "$@"; do case "$arg" in - --full|-f) FULL=true ;; - --yes|-y) YES=true ;; + --full|-f) FULL=true ;; + --yes|-y) YES=true ;; --help|-h) echo "" - echo " Uso: $0 [opciones]" + echo " Usage: $0 [options]" echo "" - echo " Sin flags — elimina wrapper, sudoers, logs, pid, run" - echo " conserva repo, venv y configs" + echo " No flags — removes wrapper, sudoers, runtime" + echo " keeps repos, venv, configs and data" echo "" - echo " --full — todo lo anterior + repo completo, venv y configs" - echo " --yes — no pide confirmación" + echo " --full — everything above + repos, venv, configs and data" + echo " --yes — skip confirmation prompt" echo "" exit 0 ;; *) - err "Flag desconocido: $arg" + log_error "Unknown flag: $arg" exit 1 ;; esac done -# ─── Banner ─────────────────────────────────────────────────────────────────── -echo "" -printf "${BOLD}${RED} hydra-veil — limpieza para reinstalación${RESET}\n" -echo "" +clear +print_header "HYDRA-VEIL UNINSTALLER" "v2.0.0" +printf " ${DIM}%s${RESET}\n" "$(date '+%Y-%m-%d %H:%M:%S') — $(uname -n)" +print_divider if $FULL; then - warn "Modo --full: se eliminará TODO incluyendo repo, venv y configs" + label_warn "Mode --full: everything will be removed (repos, venv, configs, data)" else - info "Modo normal: conserva repo, venv y configs" + label_info "Normal mode: repos, venv, configs and data will be kept" + label_info "Use --full to remove everything" fi echo "" -# ─── Confirmación ───────────────────────────────────────────────────────────── if ! $YES; then - printf " ${YELLOW}¿Continuar?${RESET} ${DIM}[s/N]:${RESET} " - read -r _confirm < /dev/tty - case "$_confirm" in - s|S|y|Y) : ;; - *) - echo "" - info "Cancelado." - exit 0 - ;; - esac - echo "" + if ! ask_confirm "Continue with uninstall?"; then + log_warn "Uninstall cancelled." + exit 0 + fi fi -# ─── 1. Detener sing-box si está corriendo ──────────────────────────────────── -hdr "1. Proceso sing-box" +print_divider + +_rm() { + local target="$1" + if [ -e "$target" ]; then + rm -rf "$target" + label_ok "Removed: $target" + else + label_info "Not found: $target" + fi +} + +print_section "1. sing-box process" _stop_singbox() { local pid="" - # Intentar via pidfile primero if [ -f "$SINGBOX_PID_FILE" ]; then pid=$(cat "$SINGBOX_PID_FILE" 2>/dev/null || echo "") fi - # Si no hay pidfile, buscar por nombre if [[ -z "$pid" || ! "$pid" =~ ^[0-9]+$ ]]; then pid=$(pgrep -x sing-box 2>/dev/null | head -1 || echo "") fi if [[ -n "$pid" && "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then - info "Deteniendo sing-box PID $pid..." + log_info "Stopping sing-box PID $pid..." kill -TERM "$pid" 2>/dev/null || true local elapsed=0 while kill -0 "$pid" 2>/dev/null && (( elapsed < 16 )); do @@ -117,149 +97,109 @@ _stop_singbox() { if kill -0 "$pid" 2>/dev/null; then kill -KILL "$pid" 2>/dev/null || true sleep 0.3 - ok "sing-box eliminado (SIGKILL)" + label_ok "sing-box killed (SIGKILL)" else - ok "sing-box detenido (SIGTERM)" + label_ok "sing-box stopped (SIGTERM)" fi else - info "sing-box no está corriendo" + label_info "sing-box is not running" fi } -# Intentar via wrapper si existe, sino directo -if [ -x "$WRAPPER_PATH" ]; then - sudo "$WRAPPER_PATH" "" stop 2>/dev/null && ok "Detenido via wrapper" || _stop_singbox +if [ -x "$SINGBOX_WRAPPER" ]; then + sudo "$SINGBOX_WRAPPER" "" stop 2>/dev/null && label_ok "Stopped via wrapper" || _stop_singbox else _stop_singbox fi -# Eliminar interfaz tun0 si existe if ip link show tun0 &>/dev/null 2>&1; then - sudo ip link delete tun0 2>/dev/null && ok "tun0 eliminada" || warn "No se pudo eliminar tun0" + sudo ip link delete tun0 2>/dev/null && label_ok "tun0 removed" || label_warn "Could not remove tun0" else - info "tun0 no existe" + label_info "tun0 not found" fi -# ─── 2. Wrapper ─────────────────────────────────────────────────────────────── -hdr "2. Wrapper" +print_divider +print_section "2. Wrapper" -if [ -f "$WRAPPER_PATH" ]; then - sudo rm -f "$WRAPPER_PATH" - ok "Eliminado: $WRAPPER_PATH" +if [ -f "$SINGBOX_WRAPPER" ]; then + sudo rm -f "$SINGBOX_WRAPPER" + label_ok "Removed: $SINGBOX_WRAPPER" else - info "No existe: $WRAPPER_PATH" + label_info "Not found: $SINGBOX_WRAPPER" fi -# ─── 3. Sudoers ─────────────────────────────────────────────────────────────── -hdr "3. Sudoers" +print_divider +print_section "3. Sudoers" if [ -f "$SUDOERS_PATH" ]; then sudo rm -f "$SUDOERS_PATH" - ok "Eliminado: $SUDOERS_PATH" + label_ok "Removed: $SUDOERS_PATH" else - info "No existe: $SUDOERS_PATH" + label_info "Not found: $SUDOERS_PATH" fi -# Verificar que no haya entradas huérfanas en sudoers.d _orphans=$(sudo grep -rl "hydraveil" /etc/sudoers.d/ 2>/dev/null || true) if [ -n "$_orphans" ]; then - warn "Entradas huérfanas de hydraveil encontradas:" + label_warn "Orphaned sudoers entries found:" echo "$_orphans" | while read -r f; do - warn " $f" - sudo rm -f "$f" && ok " Eliminado: $f" + sudo rm -f "$f" && label_ok " Removed: $f" done else - info "Sin entradas huérfanas en sudoers.d" + label_info "No orphaned entries in sudoers.d" fi -# ─── 4. PID file y runtime ──────────────────────────────────────────────────── -hdr "4. Runtime (pid / run)" +print_divider +print_section "4. Runtime" -if [ -f "$SINGBOX_PID_FILE" ]; then - rm -f "$SINGBOX_PID_FILE" - ok "Eliminado PID file: $SINGBOX_PID_FILE" -else - info "Sin PID file" -fi +_rm "$SINGBOX_PID_FILE" +_rm "$SINGBOX_LOG_FILE" +_rm "$HV_DATA_DIR/runtime" -if [ -d "$HV_RUN_DIR" ]; then - rm -rf "$HV_RUN_DIR" - ok "Eliminado: $HV_RUN_DIR" -else - info "No existe: $HV_RUN_DIR" -fi +print_divider -# ─── 5. Logs ────────────────────────────────────────────────────────────────── -hdr "5. Logs" - -if [ -d "$HV_LOG_DIR" ]; then - rm -rf "$HV_LOG_DIR" - ok "Eliminado: $HV_LOG_DIR" -else - info "No existe: $HV_LOG_DIR" -fi - -# ─── 6. Limpieza completa (--full) ──────────────────────────────────────────── if $FULL; then - hdr "6. Limpieza completa (--full)" + print_section "5. Data" + _rm "$HV_DATA_DIR" - # Configs - if [ -d "$HV_CONFIG_DIR" ]; then - rm -rf "$HV_CONFIG_DIR" - ok "Eliminado: $HV_CONFIG_DIR" - else - info "No existe: $HV_CONFIG_DIR" + print_section "6. Configs" + _rm "$HV_CONFIG_DIR" + _rm "$HV_CACHE_DIR" + _rm "$HV_STATE_DIR" + + print_section "7. Venv" + _rm "$HV_VENV" + + print_section "8. Repos" + for _dir in "$HV_CORE" "$HV_CLI" "$HV_GUI" "$HV_ESSENTIALS"; do + _rm "$_dir" + done + + if [ -d "$HV_BASE" ] && [ -z "$(ls -A "$HV_BASE" 2>/dev/null)" ]; then + rmdir "$HV_BASE" + label_ok "Empty base directory removed: $HV_BASE" fi - # Data dir completo - if [ -d "$HV_DATA_DIR" ]; then - rm -rf "$HV_DATA_DIR" - ok "Eliminado: $HV_DATA_DIR" - else - info "No existe: $HV_DATA_DIR" - fi - - # Venv - if [ -d "$HV_VENV_DIR" ]; then - rm -rf "$HV_VENV_DIR" - ok "Eliminado venv: $HV_VENV_DIR" - else - info "No existe: $HV_VENV_DIR" - fi - - # Repo completo - if [ -d "$HV_CORE_HOME/.git" ]; then - rm -rf "$HV_CORE_HOME" - ok "Eliminado repo: $HV_CORE_HOME" - elif [ -d "$HV_CORE_HOME" ]; then - rm -rf "$HV_CORE_HOME" - ok "Eliminado directorio: $HV_CORE_HOME" - else - info "No existe: $HV_CORE_HOME" - fi - - # sing-box binary (solo si se instaló por el installer, no si era preexistente) if [ -L "$SINGBOX_BIN" ]; then sudo rm -f "$SINGBOX_BIN" - ok "Eliminado symlink sing-box: $SINGBOX_BIN" + label_ok "sing-box symlink removed: $SINGBOX_BIN" else - info "sing-box no es symlink — conservado: $SINGBOX_BIN" + label_info "sing-box is not a symlink — kept: $SINGBOX_BIN" fi + + print_divider fi -# ─── Resumen ────────────────────────────────────────────────────────────────── echo "" -printf "${BOLD}${GREEN} ✔ Limpieza completa.${RESET}" +label_ok "Uninstall complete" if $FULL; then - printf " ${DIM}(modo --full)${RESET}\n" + label_ok "Mode --full: everything removed" else - printf "\n" echo "" - printf " ${DIM}Conservados: repo, venv, configs${RESET}\n" - printf " ${DIM}Para eliminar todo: %s --full${RESET}\n" "$0" + label_info "Kept: repos, venv, configs, data" + label_info "To remove everything: $0 --full" fi echo "" -info "Podés correr el installer nuevamente sin conflictos." +print_divider echo "" -- 2.45.2 From 55f251587499bebf64c383fe3b6922f013f61e77 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 15:51:00 +0000 Subject: [PATCH 26/51] Actualizar lib/steps.sh --- lib/steps.sh | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/steps.sh b/lib/steps.sh index c5191b6..d5fe20d 100755 --- a/lib/steps.sh +++ b/lib/steps.sh @@ -1,15 +1,12 @@ #!/bin/bash -# lib/steps.sh — orquestador de steps del core installer TOTAL_STEPS=6 HV_BASE="$HOME/.local/share/hydra-veil" - HV_CORE="$HV_BASE/core" HV_CLI="$HV_BASE/cli" HV_GUI="$HV_BASE/gui" HV_ESSENTIALS="$HV_BASE/essentials" - HV_VENV="$HV_CORE/.venv" INSTALL_DIR="$HOME" @@ -18,7 +15,6 @@ HV_CONFIG_DIR="$HOME/.config/hydra-veil" HV_CACHE_DIR="$HOME/.cache/hydra-veil" HV_STATE_DIR="$HOME/.state/hydra-veil" -# sing-box SINGBOX_BIN="/usr/bin/sing-box" SINGBOX_WRAPPER="/usr/local/bin/hydraveil-singbox" SINGBOX_VERSION="1.13.5" @@ -43,10 +39,12 @@ declare -A REPO_BRANCHES=( ) declare -A REPO_DIRS=( + ["core"]="$HV_CORE" ["cli"]="$HV_CLI" ["essentials"]="$HV_ESSENTIALS" ["gui"]="$HV_GUI" ) + _STEPS_DIR="$SCRIPT_DIR/lib/steps" for _step in preflight repos python_env singbox wrapper sudoers; do -- 2.45.2 From 594f6a8d4bef56279f77565573f054f4c434a500 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 15:51:37 +0000 Subject: [PATCH 27/51] Actualizar lib/steps/repos.sh --- lib/steps/repos.sh | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/lib/steps/repos.sh b/lib/steps/repos.sh index 661bbfd..13df0c7 100644 --- a/lib/steps/repos.sh +++ b/lib/steps/repos.sh @@ -1,41 +1,39 @@ #!/bin/bash -# lib/steps/repos.sh — clonar repositorios + generar .env step_repos() { print_section "Repositories" log_step "Cloning repositories & configuring .env" 2 $TOTAL_STEPS - mkdir -p "$HV_BASE" chmod 700 "$HV_BASE" - _clone_repos _setup_env } _clone_repos() { - for name in essentials cli gui; do + for name in core essentials cli gui; do local url="${REPO_URLS[$name]}" local branch="${REPO_BRANCHES[$name]}" local dest="${REPO_DIRS[$name]}" if [ -d "$dest/.git" ]; then - log_warn "Repo ya existe: ${dest}" - if ask_confirm "Eliminar y re-clonar ${name}?"; then + log_warn "Repo already exists: ${dest}" + if ask_confirm "Delete and re-clone ${name}?"; then rm -rf "$dest" else - log_skip "Re-clone omitido — usando ${dest}" + log_skip "Re-clone skipped — using ${dest}" continue fi fi - start_spinner "Clonando ${name} (branch: ${branch})..." + start_spinner "Cloning ${name} (branch: ${branch})..." if git clone --branch "$branch" "$url" "$dest" &>/dev/null; then - stop_spinner "${name} clonado → ${dest}" + stop_spinner "${name} cloned → ${dest}" else stop_spinner - log_error "git clone falló: ${url}" + log_error "git clone failed: ${url}" exit 1 fi + chmod 700 "$dest" done } @@ -45,24 +43,23 @@ _setup_env() { local example_file="$HV_CORE/.env.example" if [ ! -f "$example_file" ]; then - log_error ".env.example no encontrado: ${example_file}" + log_error ".env.example not found: ${example_file}" exit 1 fi if [ -f "$env_file" ]; then - log_warn ".env ya existe — sobreescribiendo" + log_warn ".env already exists — overwriting" fi echo "" - printf " ${DIM}Completá las variables. ENTER = dejar vacío.${RESET}\n" + printf " ${DIM}Fill in the variables. ENTER = leave empty.${RESET}\n" print_divider echo "" - # APP_ENV select_option \ - "APP_ENV — entorno de la aplicación" \ - "local desarrollo / testing" \ - "production producción / dominio real" + "APP_ENV — application environment" \ + "local development / testing" \ + "production production / real domain" case "$SELECT_RESULT" in 0) _app_env="local" ;; @@ -79,16 +76,15 @@ _setup_env() { APP_ENV=${_app_env} SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 SP_API_BASE_URL_PRODUCTION=${_api_url} - -# ── Generado por el installer ───────────────────────────────────────────── HV_CORE_HOME=${HV_CORE} INSTALL_DIR=${HOME} CORE_DIR=${HV_CORE} EOF chmod 600 "$env_file" + log_ok "APP_ENV=${_app_env}" - [ -n "$_api_url" ] && log_ok "SP_API_BASE_URL_PRODUCTION=${_api_url}" || label_skip "SP_API_BASE_URL_PRODUCTION — vacío" + [ -n "$_api_url" ] && log_ok "SP_API_BASE_URL_PRODUCTION=${_api_url}" || label_skip "SP_API_BASE_URL_PRODUCTION — empty" log_ok "INSTALL_DIR=${HOME}" - log_ok ".env escrito → ${env_file}" + log_ok ".env written → ${env_file}" } \ No newline at end of file -- 2.45.2 From c55706f573908d4edda5c5994fc74b5a1e48f9f2 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 16:11:04 +0000 Subject: [PATCH 28/51] Actualizar lib/steps/repos.sh --- lib/steps/repos.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/steps/repos.sh b/lib/steps/repos.sh index 13df0c7..ae05f78 100644 --- a/lib/steps/repos.sh +++ b/lib/steps/repos.sh @@ -8,12 +8,15 @@ step_repos() { _clone_repos _setup_env } - _clone_repos() { for name in core essentials cli gui; do local url="${REPO_URLS[$name]}" local branch="${REPO_BRANCHES[$name]}" local dest="${REPO_DIRS[$name]}" + [ "$name" = "core" ] && dest="$HV_CORE" + [ "$name" = "cli" ] && dest="$HV_CLI" + [ "$name" = "essentials" ] && dest="$HV_ESSENTIALS" + [ "$name" = "gui" ] && dest="$HV_GUI" if [ -d "$dest/.git" ]; then log_warn "Repo already exists: ${dest}" -- 2.45.2 From 58d7837c7f302b665849bdba6c92e32c781adb92 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 16:21:59 +0000 Subject: [PATCH 29/51] Actualizar lib/steps/repos.sh --- lib/steps/repos.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/steps/repos.sh b/lib/steps/repos.sh index ae05f78..c52f034 100644 --- a/lib/steps/repos.sh +++ b/lib/steps/repos.sh @@ -29,7 +29,7 @@ _clone_repos() { fi start_spinner "Cloning ${name} (branch: ${branch})..." - if git clone --branch "$branch" "$url" "$dest" &>/dev/null; then + if git clone --branch "$branch" "$url" "$dest" ; then stop_spinner "${name} cloned → ${dest}" else stop_spinner -- 2.45.2 From dd7edfa4ed4a26ded75494a423bb31e631f79fd5 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 16:30:48 +0000 Subject: [PATCH 30/51] Actualizar lib/steps/repos.sh --- lib/steps/repos.sh | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/steps/repos.sh b/lib/steps/repos.sh index c52f034..4fee556 100644 --- a/lib/steps/repos.sh +++ b/lib/steps/repos.sh @@ -8,17 +8,32 @@ step_repos() { _clone_repos _setup_env } + _clone_repos() { + echo "[DEBUG] ===============================" + echo "[DEBUG] HV_BASE=$HV_BASE" + echo "[DEBUG] HV_CORE=$HV_CORE" + echo "[DEBUG] REPO_URLS[core]=${REPO_URLS[core]}" + echo "[DEBUG] REPO_BRANCHES[core]=${REPO_BRANCHES[core]}" + echo "[DEBUG] REPO_DIRS[core]=${REPO_DIRS[core]}" + echo "[DEBUG] ===============================" + for name in core essentials cli gui; do local url="${REPO_URLS[$name]}" local branch="${REPO_BRANCHES[$name]}" local dest="${REPO_DIRS[$name]}" - [ "$name" = "core" ] && dest="$HV_CORE" + [ "$name" = "core" ] && dest="$HV_CORE" [ "$name" = "cli" ] && dest="$HV_CLI" [ "$name" = "essentials" ] && dest="$HV_ESSENTIALS" [ "$name" = "gui" ] && dest="$HV_GUI" + echo "[DEBUG] --- name=$name" + echo "[DEBUG] url=$url" + echo "[DEBUG] branch=$branch" + echo "[DEBUG] dest=$dest" + if [ -d "$dest/.git" ]; then + echo "[DEBUG] .git EXISTS at $dest — asking re-clone" log_warn "Repo already exists: ${dest}" if ask_confirm "Delete and re-clone ${name}?"; then rm -rf "$dest" @@ -26,25 +41,35 @@ _clone_repos() { log_skip "Re-clone skipped — using ${dest}" continue fi + else + echo "[DEBUG] .git NOT found — proceeding to clone" fi - start_spinner "Cloning ${name} (branch: ${branch})..." - if git clone --branch "$branch" "$url" "$dest" ; then + echo "[DEBUG] running: git clone --branch $branch $url $dest" + if git clone --branch "$branch" "$url" "$dest"; then + echo "[DEBUG] clone SUCCESS" stop_spinner "${name} cloned → ${dest}" else + echo "[DEBUG] clone FAILED (exit code $?)" stop_spinner log_error "git clone failed: ${url}" exit 1 fi chmod 700 "$dest" + echo "[DEBUG] chmod 700 done" done + + echo "[DEBUG] _clone_repos finished" } _setup_env() { local env_file="$HV_CORE/.env" local example_file="$HV_CORE/.env.example" + echo "[DEBUG] _setup_env: example_file=$example_file" + echo "[DEBUG] _setup_env: env_file=$env_file" + if [ ! -f "$example_file" ]; then log_error ".env.example not found: ${example_file}" exit 1 -- 2.45.2 From fde1d35f170fe82091797d12cf2350bf9dfe4a4d Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 16:51:56 +0000 Subject: [PATCH 31/51] Actualizar lib/steps/repos.sh --- lib/steps/repos.sh | 43 ++++++++++--------------------------------- 1 file changed, 10 insertions(+), 33 deletions(-) diff --git a/lib/steps/repos.sh b/lib/steps/repos.sh index 4fee556..3c35917 100644 --- a/lib/steps/repos.sh +++ b/lib/steps/repos.sh @@ -3,73 +3,50 @@ step_repos() { print_section "Repositories" log_step "Cloning repositories & configuring .env" 2 $TOTAL_STEPS + mkdir -p "$HV_BASE" chmod 700 "$HV_BASE" + _clone_repos _setup_env } _clone_repos() { - echo "[DEBUG] ===============================" - echo "[DEBUG] HV_BASE=$HV_BASE" - echo "[DEBUG] HV_CORE=$HV_CORE" - echo "[DEBUG] REPO_URLS[core]=${REPO_URLS[core]}" - echo "[DEBUG] REPO_BRANCHES[core]=${REPO_BRANCHES[core]}" - echo "[DEBUG] REPO_DIRS[core]=${REPO_DIRS[core]}" - echo "[DEBUG] ===============================" - for name in core essentials cli gui; do local url="${REPO_URLS[$name]}" local branch="${REPO_BRANCHES[$name]}" local dest="${REPO_DIRS[$name]}" - [ "$name" = "core" ] && dest="$HV_CORE" - [ "$name" = "cli" ] && dest="$HV_CLI" - [ "$name" = "essentials" ] && dest="$HV_ESSENTIALS" - [ "$name" = "gui" ] && dest="$HV_GUI" - - echo "[DEBUG] --- name=$name" - echo "[DEBUG] url=$url" - echo "[DEBUG] branch=$branch" - echo "[DEBUG] dest=$dest" if [ -d "$dest/.git" ]; then - echo "[DEBUG] .git EXISTS at $dest — asking re-clone" - log_warn "Repo already exists: ${dest}" + log_warn "Repository already exists: ${dest}" if ask_confirm "Delete and re-clone ${name}?"; then rm -rf "$dest" else log_skip "Re-clone skipped — using ${dest}" continue fi - else - echo "[DEBUG] .git NOT found — proceeding to clone" fi - echo "[DEBUG] running: git clone --branch $branch $url $dest" - if git clone --branch "$branch" "$url" "$dest"; then - echo "[DEBUG] clone SUCCESS" - stop_spinner "${name} cloned → ${dest}" - else - echo "[DEBUG] clone FAILED (exit code $?)" + start_spinner "Cloning ${name} (branch: ${branch})..." + if git clone --branch "$branch" "$url" "$dest" 2>&1 | grep -q "fatal\|error"; then stop_spinner log_error "git clone failed: ${url}" exit 1 fi - + stop_spinner "${name} cloned → ${dest}" chmod 700 "$dest" - echo "[DEBUG] chmod 700 done" done - echo "[DEBUG] _clone_repos finished" + if [ ! -d "$HV_CORE/.git" ]; then + log_error "core repository not cloned successfully" + exit 1 + fi } _setup_env() { local env_file="$HV_CORE/.env" local example_file="$HV_CORE/.env.example" - echo "[DEBUG] _setup_env: example_file=$example_file" - echo "[DEBUG] _setup_env: env_file=$env_file" - if [ ! -f "$example_file" ]; then log_error ".env.example not found: ${example_file}" exit 1 -- 2.45.2 From bf7bdfa4d74b162c6bb02c9f3ce0dd9a33937244 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 17:00:21 +0000 Subject: [PATCH 32/51] Actualizar lib/steps/repos.sh --- lib/steps/repos.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/steps/repos.sh b/lib/steps/repos.sh index 3c35917..2f3833b 100644 --- a/lib/steps/repos.sh +++ b/lib/steps/repos.sh @@ -28,17 +28,23 @@ _clone_repos() { fi start_spinner "Cloning ${name} (branch: ${branch})..." - if git clone --branch "$branch" "$url" "$dest" 2>&1 | grep -q "fatal\|error"; then + + local clone_log="/tmp/clone_${name}_$$.log" + if ! git clone --branch "$branch" "$url" "$dest" > "$clone_log" 2>&1; then stop_spinner log_error "git clone failed: ${url}" + cat "$clone_log" + rm -f "$clone_log" exit 1 fi + rm -f "$clone_log" + stop_spinner "${name} cloned → ${dest}" chmod 700 "$dest" done if [ ! -d "$HV_CORE/.git" ]; then - log_error "core repository not cloned successfully" + log_error "core repository not cloned" exit 1 fi } -- 2.45.2 From 24dacb2e33c5eaeed779f22bcaf40d05a6290242 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 17:02:54 +0000 Subject: [PATCH 33/51] Actualizar lib/steps/repos.sh --- lib/steps/repos.sh | 48 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/steps/repos.sh b/lib/steps/repos.sh index 2f3833b..9f01051 100644 --- a/lib/steps/repos.sh +++ b/lib/steps/repos.sh @@ -12,14 +12,31 @@ step_repos() { } _clone_repos() { + echo "" + log_info "REPOS CONFIG:" + for name in core essentials cli gui; do + local url="${REPO_URLS[$name]}" + local branch="${REPO_BRANCHES[$name]}" + local dest="${REPO_DIRS[$name]}" + echo " $name:" + echo " URL: $url" + echo " Branch: $branch" + echo " Dest: $dest" + done + echo "" + for name in core essentials cli gui; do local url="${REPO_URLS[$name]}" local branch="${REPO_BRANCHES[$name]}" local dest="${REPO_DIRS[$name]}" + log_info "Processing ${name}..." + log_info " Checking if exists: $dest/.git" + if [ -d "$dest/.git" ]; then log_warn "Repository already exists: ${dest}" if ask_confirm "Delete and re-clone ${name}?"; then + log_info "Deleting: $dest" rm -rf "$dest" else log_skip "Re-clone skipped — using ${dest}" @@ -27,22 +44,49 @@ _clone_repos() { fi fi + log_info "Starting clone..." + log_info " git clone --branch $branch $url $dest" + start_spinner "Cloning ${name} (branch: ${branch})..." local clone_log="/tmp/clone_${name}_$$.log" if ! git clone --branch "$branch" "$url" "$dest" > "$clone_log" 2>&1; then stop_spinner - log_error "git clone failed: ${url}" - cat "$clone_log" + log_error "git clone FAILED for ${name}" + log_error "URL: $url" + log_error "Branch: $branch" + log_error "Dest: $dest" + log_error "Output:" + cat "$clone_log" | sed 's/^/ /' rm -f "$clone_log" exit 1 fi rm -f "$clone_log" + log_info "Checking if clone succeeded: ls -la $dest/.git" + if [ ! -d "$dest/.git" ]; then + stop_spinner + log_error "${name} clone directory exists but no .git found" + ls -la "$dest" 2>&1 | sed 's/^/ /' + exit 1 + fi + stop_spinner "${name} cloned → ${dest}" chmod 700 "$dest" + log_ok "${name} OK" done + echo "" + log_info "FINAL CHECK:" + for name in core essentials cli gui; do + local dest="${REPO_DIRS[$name]}" + if [ -d "$dest/.git" ]; then + log_ok "$name: ✓ cloned at $dest" + else + log_error "$name: ✗ NOT cloned — $dest/.git missing" + fi + done + echo "" if [ ! -d "$HV_CORE/.git" ]; then log_error "core repository not cloned" exit 1 -- 2.45.2 From 2d958f91453f62570beee08e4817b6a5ea8449fd Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 17:33:09 +0000 Subject: [PATCH 34/51] Actualizar lib/steps/repos.sh --- lib/steps/repos.sh | 90 +++++++++------------------------------------- 1 file changed, 16 insertions(+), 74 deletions(-) diff --git a/lib/steps/repos.sh b/lib/steps/repos.sh index 9f01051..bb98053 100644 --- a/lib/steps/repos.sh +++ b/lib/steps/repos.sh @@ -12,85 +12,27 @@ step_repos() { } _clone_repos() { - echo "" - log_info "REPOS CONFIG:" - for name in core essentials cli gui; do - local url="${REPO_URLS[$name]}" - local branch="${REPO_BRANCHES[$name]}" - local dest="${REPO_DIRS[$name]}" - echo " $name:" - echo " URL: $url" - echo " Branch: $branch" - echo " Dest: $dest" - done - echo "" - for name in core essentials cli gui; do local url="${REPO_URLS[$name]}" local branch="${REPO_BRANCHES[$name]}" local dest="${REPO_DIRS[$name]}" - log_info "Processing ${name}..." - log_info " Checking if exists: $dest/.git" - - if [ -d "$dest/.git" ]; then - log_warn "Repository already exists: ${dest}" - if ask_confirm "Delete and re-clone ${name}?"; then - log_info "Deleting: $dest" - rm -rf "$dest" - else - log_skip "Re-clone skipped — using ${dest}" - continue - fi + if [ -d "$dest" ]; then + log_warn "Repo ya existe: ${dest}" + rm -rf "$dest" fi - log_info "Starting clone..." - log_info " git clone --branch $branch $url $dest" - - start_spinner "Cloning ${name} (branch: ${branch})..." - - local clone_log="/tmp/clone_${name}_$$.log" - if ! git clone --branch "$branch" "$url" "$dest" > "$clone_log" 2>&1; then - stop_spinner - log_error "git clone FAILED for ${name}" - log_error "URL: $url" - log_error "Branch: $branch" - log_error "Dest: $dest" - log_error "Output:" - cat "$clone_log" | sed 's/^/ /' - rm -f "$clone_log" + if ! git clone --branch "$branch" "$url" "$dest" 2>&1; then + log_error "git clone FAILED: $name" + log_error " URL: $url" + log_error " Branch: $branch" + log_error " Dest: $dest" exit 1 fi - rm -f "$clone_log" - - log_info "Checking if clone succeeded: ls -la $dest/.git" - if [ ! -d "$dest/.git" ]; then - stop_spinner - log_error "${name} clone directory exists but no .git found" - ls -la "$dest" 2>&1 | sed 's/^/ /' - exit 1 - fi - - stop_spinner "${name} cloned → ${dest}" + chmod 700 "$dest" - log_ok "${name} OK" + log_ok "${name} clonado → ${dest}" done - - echo "" - log_info "FINAL CHECK:" - for name in core essentials cli gui; do - local dest="${REPO_DIRS[$name]}" - if [ -d "$dest/.git" ]; then - log_ok "$name: ✓ cloned at $dest" - else - log_error "$name: ✗ NOT cloned — $dest/.git missing" - fi - done - echo "" - if [ ! -d "$HV_CORE/.git" ]; then - log_error "core repository not cloned" - exit 1 - fi } _setup_env() { @@ -98,12 +40,12 @@ _setup_env() { local example_file="$HV_CORE/.env.example" if [ ! -f "$example_file" ]; then - log_error ".env.example not found: ${example_file}" + log_error ".env.example no encontrado: ${example_file}" exit 1 fi if [ -f "$env_file" ]; then - log_warn ".env already exists — overwriting" + log_warn ".env ya existe — sobreescribiendo" fi echo "" @@ -127,19 +69,19 @@ _setup_env() { printf " ${BCYAN}›${RESET} " read -r _api_url < /dev/tty - cat > "$env_file" << EOF + cat > "$env_file" << ENVEOF APP_ENV=${_app_env} SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 SP_API_BASE_URL_PRODUCTION=${_api_url} HV_CORE_HOME=${HV_CORE} INSTALL_DIR=${HOME} CORE_DIR=${HV_CORE} -EOF +ENVEOF chmod 600 "$env_file" log_ok "APP_ENV=${_app_env}" - [ -n "$_api_url" ] && log_ok "SP_API_BASE_URL_PRODUCTION=${_api_url}" || label_skip "SP_API_BASE_URL_PRODUCTION — empty" + [ -n "$_api_url" ] && log_ok "SP_API_BASE_URL_PRODUCTION=${_api_url}" || label_skip "SP_API_BASE_URL_PRODUCTION — vacío" log_ok "INSTALL_DIR=${HOME}" - log_ok ".env written → ${env_file}" + log_ok ".env escrito → ${env_file}" } \ No newline at end of file -- 2.45.2 From eaa28aada1ffac581931dc49956cfad28f11ff44 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 17:59:20 +0000 Subject: [PATCH 35/51] Actualizar lib/steps/repos.sh --- lib/steps/repos.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/steps/repos.sh b/lib/steps/repos.sh index bb98053..42bad4f 100644 --- a/lib/steps/repos.sh +++ b/lib/steps/repos.sh @@ -12,10 +12,11 @@ step_repos() { } _clone_repos() { - for name in core essentials cli gui; do + for name in essentials cli gui core; do local url="${REPO_URLS[$name]}" local branch="${REPO_BRANCHES[$name]}" local dest="${REPO_DIRS[$name]}" + log_ok "HIJUEPUTA" if [ -d "$dest" ]; then log_warn "Repo ya existe: ${dest}" -- 2.45.2 From 50bf6503d9cba1db382a03efff37b94d9dd60e8d Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 2 Jun 2026 18:24:02 +0000 Subject: [PATCH 36/51] Actualizar lib/steps/singbox.sh --- lib/steps/singbox.sh | 146 ++++++++++++++++++------------------------- 1 file changed, 62 insertions(+), 84 deletions(-) diff --git a/lib/steps/singbox.sh b/lib/steps/singbox.sh index 07710d1..5b27791 100644 --- a/lib/steps/singbox.sh +++ b/lib/steps/singbox.sh @@ -1,96 +1,74 @@ #!/bin/bash -# lib/steps/repos.sh — clonar repositorios + generar .env +# lib/steps/singbox.sh — instalar/verificar sing-box -step_repos() { - print_section "Repositories" - log_step "Cloning repositories & configuring .env" 2 $TOTAL_STEPS +step_singbox() { + print_section "sing-box" + log_step "Checking sing-box binary" 4 $TOTAL_STEPS - mkdir -p "$HV_BASE" - chmod 700 "$HV_BASE" - - _clone_repos - _setup_env -} - -_clone_repos() { - for name in essentials cli gui; do - local url="${REPO_URLS[$name]}" - local branch="${REPO_BRANCHES[$name]}" - local dest="${REPO_DIRS[$name]}" - - if [ -d "$dest/.git" ]; then - log_warn "Repo ya existe: ${dest}" - if ask_confirm "Eliminar y re-clonar ${name}?"; then - rm -rf "$dest" - else - log_skip "Re-clone omitido — usando ${dest}" - continue - fi - fi - - start_spinner "Clonando ${name} (branch: ${branch})..." - if git clone --branch "$branch" "$url" "$dest" &>/dev/null; then - stop_spinner "${name} clonado → ${dest}" - else - stop_spinner - log_error "git clone falló: ${url}" - exit 1 - fi - chmod 700 "$dest" + local found="" + for candidate in /usr/bin/sing-box /usr/local/bin/sing-box /bin/sing-box; do + [ -x "$candidate" ] && found="$candidate" && break done + [ -z "$found" ] && found=$(command -v sing-box 2>/dev/null || true) + + if [ -n "$found" ]; then + local ver + ver=$("$found" version 2>/dev/null | head -1 || echo "unknown") + log_ok "Encontrado: ${found} (${ver})" + if [ "$found" != "$SINGBOX_BIN" ]; then + sudo ln -sf "$found" "$SINGBOX_BIN" + log_ok "Symlink → ${SINGBOX_BIN}" + fi + else + _install_singbox + fi + + # setcap — sing-box crea TUN sin necesitar root completo + if command -v setcap &>/dev/null; then + sudo setcap cap_net_admin,cap_net_raw+ep "$SINGBOX_BIN" + log_ok "setcap cap_net_admin,cap_net_raw+ep → ${SINGBOX_BIN}" + else + log_warn "setcap no disponible — sing-box necesitará sudo para TUN" + fi + + log_ok "sing-box listo → ${SINGBOX_BIN}" } -_setup_env() { - local env_file="$HV_CORE/.env" - local example_file="$HV_CORE/.env.example" +_install_singbox() { + log_info "sing-box no encontrado — instalando v${SINGBOX_VERSION}..." - if [ ! -f "$example_file" ]; then - log_error ".env.example no encontrado: ${example_file}" + local arch + arch=$(uname -m) + case "$arch" in + x86_64) arch="amd64" ;; + aarch64) arch="arm64" ;; + *) + log_error "Arquitectura no soportada: ${arch}" + exit 1 + ;; + esac + + local url="https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${arch}.tar.gz" + local tmp_tar="/tmp/sing-box-$$.tar.gz" + local tmp_dir="/tmp/sing-box-$$" + + start_spinner "Descargando sing-box v${SINGBOX_VERSION}..." + if wget -q --timeout=60 "$url" -O "$tmp_tar"; then + stop_spinner "Descarga completa" + else + stop_spinner + rm -f "$tmp_tar" + log_error "Descarga falló: ${url}" exit 1 fi - if [ -f "$env_file" ]; then - log_warn ".env ya existe — sobreescribiendo" - fi + start_spinner "Extrayendo..." + mkdir -p "$tmp_dir" + tar -xzf "$tmp_tar" -C "$tmp_dir" --strip-components=1 + stop_spinner "Extraído" - echo "" - printf " ${DIM}Completá las variables. ENTER = dejar vacío.${RESET}\n" - print_divider - echo "" - - # APP_ENV - select_option \ - "APP_ENV — entorno de la aplicación" \ - "local desarrollo / testing" \ - "production producción / dominio real" - - case "$SELECT_RESULT" in - 0) _app_env="local" ;; - 1) _app_env="production" ;; - *) _app_env="production" ;; - esac - - # SP_API_BASE_URL_PRODUCTION - echo "" - printf " ${BCYAN}┌─${RESET} ${BWHITE}SP_API_BASE_URL_PRODUCTION${RESET} ${DIM}e.g. https://fake.simplifiedprivacy.org/api/v1${RESET}\n" - printf " ${BCYAN}›${RESET} " - read -r _api_url < /dev/tty - - # Escribir .env - cat > "$env_file" << EOF -APP_ENV=${_app_env} -SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 -SP_API_BASE_URL_PRODUCTION=${_api_url} - -# ── Generado por el installer ───────────────────────────────────────────── -HV_CORE_HOME=${HV_CORE} -INSTALL_DIR=${HOME} -CORE_DIR=${HV_CORE} -EOF - - chmod 600 "$env_file" - log_ok "APP_ENV=${_app_env}" - [ -n "$_api_url" ] && log_ok "SP_API_BASE_URL_PRODUCTION=${_api_url}" || label_skip "SP_API_BASE_URL_PRODUCTION — vacío" - log_ok "INSTALL_DIR=${HOME}" - log_ok ".env escrito → ${env_file}" + sudo cp "$tmp_dir/sing-box" "$SINGBOX_BIN" + sudo chmod 755 "$SINGBOX_BIN" + rm -rf "$tmp_tar" "$tmp_dir" + log_ok "sing-box instalado: $($SINGBOX_BIN version | head -1)" } \ No newline at end of file -- 2.45.2 From 35d94908f476556bd8b9b3f0bb049a4dda2cb85c Mon Sep 17 00:00:00 2001 From: zenaku Date: Wed, 3 Jun 2026 17:54:29 -0500 Subject: [PATCH 37/51] Core configuration synchronized with the CLI --- core/controllers/ConnectionController.py | 88 +++++++++++++--------- core/controllers/ProfileController.py | 12 ++- core/controllers/SubscriptionController.py | 50 +++++++++++- core/models/SubscriptionPlan.py | 8 +- core/models/system/SystemConnection.py | 17 +++-- 5 files changed, 127 insertions(+), 48 deletions(-) diff --git a/core/controllers/ConnectionController.py b/core/controllers/ConnectionController.py index 689cde8..f8ffef4 100644 --- a/core/controllers/ConnectionController.py +++ b/core/controllers/ConnectionController.py @@ -45,6 +45,8 @@ class ConnectionController: @staticmethod def establish_connection(profile: Union[SessionProfile, SystemProfile], ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None): + from core.controllers.ConnectionController import ConnectionController + connection = profile.connection if connection.needs_proxy_configuration() and not profile.has_proxy_configuration(): @@ -66,32 +68,6 @@ class ConnectionController: if connection.needs_wireguard_configuration() and not profile.has_wireguard_configuration(): - - - - if connection.needs_operator_proxy(): - - if profile.has_subscription(): - - if not profile.subscription.has_been_activated(): - ProfileController.activate_subscription(profile, connection_observer=connection_observer) - - operator_proxy_session = ConnectionController.with_preferred_connection( - profile.subscription.billing_code, - profile.subscription.operator_id, - connection.get_protocol(), - task=WebServiceApiService.post_operator_proxy, - connection_observer=connection_observer - ) - - if operator_proxy_session is None: - raise InvalidSubscriptionError() - - profile.attach_operator_proxy_session(operator_proxy_session) - - else: - raise MissingSubscriptionError() - if profile.has_subscription(): if not profile.subscription.has_been_activated(): @@ -112,6 +88,29 @@ class ConnectionController: raise MissingSubscriptionError() + if connection.needs_operator_proxy() and not profile.has_operator_proxy_session(): + + if profile.has_subscription(): + + if not profile.subscription.has_been_activated(): + ProfileController.activate_subscription(profile, connection_observer=connection_observer) + + operator_proxy_session = ConnectionController.with_preferred_connection( + profile.subscription.billing_code, + profile.subscription.operator_id, + connection.get_protocol(), + task=WebServiceApiService.post_operator_proxy, + connection_observer=connection_observer + ) + + if operator_proxy_session is None: + raise InvalidSubscriptionError() + + profile.attach_operator_proxy_session(operator_proxy_session) + + else: + raise MissingSubscriptionError() + if profile.is_session_profile(): try: @@ -143,7 +142,6 @@ class ConnectionController: raise ConnectionError('The connection could not be established.') return None - @staticmethod def establish_session_connection(profile: SessionProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None): @@ -212,6 +210,20 @@ class ConnectionController: @staticmethod def establish_system_connection(profile: SystemProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None): + if profile.connection.needs_operator_proxy(): + operator_proxy_session = profile.get_operator_proxy_session() + protocol = profile.connection.get_protocol() + + if protocol == 'vless': + from core.controllers.encrypted_proxy.VlessController import VlessController + VlessController(1080).enable(operator_proxy_session.links[0], operator_proxy_session.username, connection_observer) + elif protocol == 'hysteria2': + from core.controllers.encrypted_proxy.HysteriaController import HysteriaController + HysteriaController(1080).enable(operator_proxy_session.username, operator_proxy_session.password, operator_proxy_session.operator_hysteria2_host, connection_observer) + + SystemStateController.create(profile.id) + return + if ConfigurationController.get_endpoint_verification_enabled(): ProfileController.verify_wireguard_endpoint(profile, ignore=ignore) @@ -219,36 +231,28 @@ class ConnectionController: ConnectionController.__establish_system_connection(profile, connection_observer) except ConnectionError: - try: ConnectionController.terminate_system_connection() except ConnectionTerminationError: pass - raise ConnectionError('The connection could not be established.') except CalledProcessError: - try: ConnectionController.terminate_system_connection() except ConnectionTerminationError: pass - try: ConnectionController.__establish_system_connection(profile, connection_observer) - except (ConnectionError, CalledProcessError): - try: ConnectionController.terminate_system_connection() except ConnectionTerminationError: pass - raise ConnectionError('The connection could not be established.') ConnectionController.terminate_tor_connection() time.sleep(1.0) - @staticmethod def establish_tor_connection(connection_observer: Optional[ConnectionObserver] = None): @@ -334,6 +338,19 @@ class ConnectionController: @staticmethod def terminate_system_connection(): + system_state = SystemStateController.get() + if system_state is not None: + profile = ProfileController.get(system_state.profile_id) + if profile is not None and profile.connection.needs_operator_proxy(): + protocol = profile.connection.get_protocol() + if protocol == 'vless': + from core.controllers.encrypted_proxy.VlessController import VlessController + VlessController(1080).disable() + elif protocol == 'hysteria2': + from core.controllers.encrypted_proxy.HysteriaController import HysteriaController + HysteriaController(1080).disable() + SystemState.dissolve() + return if shutil.which('nmcli') is None: raise CommandNotFoundError('nmcli') @@ -351,7 +368,6 @@ class ConnectionController: else: raise ConnectionTerminationError('The connection could not be terminated.') - @staticmethod def get_proxies(port_number: int): diff --git a/core/controllers/ProfileController.py b/core/controllers/ProfileController.py index ffdad9c..14473eb 100644 --- a/core/controllers/ProfileController.py +++ b/core/controllers/ProfileController.py @@ -155,20 +155,24 @@ class ProfileController: from core.controllers.ConnectionController import ConnectionController if profile.has_subscription(): - + + print(f"DEBUG activate_subscription:") + print(f" billing_code: {profile.subscription.billing_code}") + print(f" operator_id: {profile.subscription.operator_id}") + subscription = ConnectionController.with_preferred_connection(profile.subscription.billing_code, task=WebServiceApiService.get_subscription, connection_observer=connection_observer) + print(f" get_subscription returned: {subscription}") + if subscription is not None: - profile.subscription = subscription profile.save() - else: raise InvalidSubscriptionError() else: raise MissingSubscriptionError() - + @staticmethod def is_enabled(profile: Union[SessionProfile, SystemProfile]): diff --git a/core/controllers/SubscriptionController.py b/core/controllers/SubscriptionController.py index 79b75bc..dfaa3f1 100644 --- a/core/controllers/SubscriptionController.py +++ b/core/controllers/SubscriptionController.py @@ -5,7 +5,6 @@ from core.observers.ConnectionObserver import ConnectionObserver from core.services.WebServiceApiService import WebServiceApiService from typing import Union - class SubscriptionController: @staticmethod @@ -18,4 +17,51 @@ class SubscriptionController: def create(subscription_plan: SubscriptionPlan, profile: Union[SessionProfile, SystemProfile], connection_observer: ConnectionObserver = None): from core.controllers.ConnectionController import ConnectionController - return ConnectionController.with_preferred_connection(subscription_plan.id, profile.location.id, task=WebServiceApiService.post_subscription, connection_observer=connection_observer) + + if profile.location: + return ConnectionController.with_preferred_connection( + subscription_plan.id, + profile.location.id, + task=WebServiceApiService.post_subscription, + connection_observer=connection_observer + ) + else: + return ConnectionController.with_preferred_connection( + subscription_plan.id, + operator_id=profile.connection.operator_id, + task=WebServiceApiService.post_subscription, + connection_observer=connection_observer + ) + + from core.controllers.ConnectionController import ConnectionController + + if profile.location: + return ConnectionController.with_preferred_connection( + subscription_plan.id, + profile.location.id, + task=WebServiceApiService.post_subscription, + connection_observer=connection_observer + ) + else: + return ConnectionController.with_preferred_connection( + subscription_plan.id, + task=WebServiceApiService.post_subscription, + connection_observer=connection_observer + ) + + from core.controllers.ConnectionController import ConnectionController + + if profile.location: + # Para WireGuard (con location) + return ConnectionController.with_preferred_connection( + subscription_plan.id, + profile.location.id, + task=WebServiceApiService.post_subscription, + connection_observer=connection_observer + ) + else: + return ConnectionController.with_preferred_connection( + subscription_plan.id, + task=WebServiceApiService.post_subscription, + connection_observer=connection_observer + ) \ No newline at end of file diff --git a/core/models/SubscriptionPlan.py b/core/models/SubscriptionPlan.py index 5a55c8e..540e7fa 100644 --- a/core/models/SubscriptionPlan.py +++ b/core/models/SubscriptionPlan.py @@ -53,6 +53,9 @@ class SubscriptionPlan(Model): if connection.code == 'wireguard': features_wireguard = True + if connection.code == 'operator': + features_proxy = True + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) return Model._query_one('SELECT * FROM subscription_plans WHERE features_proxy = ? AND features_wireguard = ? AND duration = ? LIMIT 1', SubscriptionPlan.factory, [features_proxy, features_wireguard, duration]) @@ -76,6 +79,9 @@ class SubscriptionPlan(Model): if connection.code == 'wireguard': features_wireguard = True + if connection.code == 'operator': + features_proxy = True + return Model._query_all('SELECT * FROM subscription_plans WHERE features_proxy = ? AND features_wireguard = ?', SubscriptionPlan.factory, [features_proxy, features_wireguard]) @staticmethod @@ -94,4 +100,4 @@ class SubscriptionPlan(Model): @staticmethod def tuple_factory(subscription_plan): - return subscription_plan.id, subscription_plan.code, subscription_plan.wireguard_session_limit, subscription_plan.duration, subscription_plan.price, subscription_plan.features_proxy, subscription_plan.features_wireguard + return subscription_plan.id, subscription_plan.code, subscription_plan.wireguard_session_limit, subscription_plan.duration, subscription_plan.price, subscription_plan.features_proxy, subscription_plan.features_wireguard \ No newline at end of file diff --git a/core/models/system/SystemConnection.py b/core/models/system/SystemConnection.py index 8122b76..d146002 100644 --- a/core/models/system/SystemConnection.py +++ b/core/models/system/SystemConnection.py @@ -1,15 +1,22 @@ from core.models.BaseConnection import BaseConnection -from dataclasses import dataclass - +from dataclasses import dataclass, field +from typing import Optional @dataclass -class SystemConnection (BaseConnection): +class SystemConnection(BaseConnection): + operator_id: Optional[int] = field(default=None) + protocol: Optional[str] = field(default=None) def __post_init__(self): - - if self.code != 'wireguard': + if self.code not in ('wireguard', 'operator'): raise ValueError('Invalid connection code.') @staticmethod def needs_proxy_configuration(): return False + + def needs_operator_proxy(self): + return self.code == 'operator' + + def get_protocol(self): + return self.protocol \ No newline at end of file -- 2.45.2 From ed0de4b2d9aee9aeb30deac83fcd452a0015f036 Mon Sep 17 00:00:00 2001 From: zenaku Date: Thu, 4 Jun 2026 07:38:55 -0500 Subject: [PATCH 38/51] Verify Sing-box before the connection --- core/Errors.py | 52 +-------- core/controllers/ConnectionController.py | 27 ++++- install.sh | 98 ---------------- lib/input.sh | 32 ------ lib/log.sh | 52 --------- lib/network.sh | 53 --------- lib/steps.sh | 57 --------- lib/steps/preflight.sh | 45 -------- lib/steps/python_env.sh | 67 ----------- lib/steps/repos.sh | 88 -------------- lib/steps/singbox.sh | 74 ------------ lib/steps/sudoers.sh | 69 ----------- lib/steps/wrapper.sh | 140 ----------------------- lib/ui.sh | 114 ------------------ 14 files changed, 27 insertions(+), 941 deletions(-) delete mode 100755 install.sh delete mode 100755 lib/input.sh delete mode 100755 lib/log.sh delete mode 100755 lib/network.sh delete mode 100755 lib/steps.sh delete mode 100644 lib/steps/preflight.sh delete mode 100644 lib/steps/python_env.sh delete mode 100644 lib/steps/repos.sh delete mode 100644 lib/steps/singbox.sh delete mode 100644 lib/steps/sudoers.sh delete mode 100644 lib/steps/wrapper.sh delete mode 100755 lib/ui.sh diff --git a/core/Errors.py b/core/Errors.py index cdbd85d..a0e6f1b 100644 --- a/core/Errors.py +++ b/core/Errors.py @@ -1,102 +1,54 @@ class CommandNotFoundError(OSError): - def __init__(self, subject): - self.subject = subject super().__init__(f"Command '{subject}' could not be found.") - - class UnknownClientPathError(Exception): pass - - class UnknownClientVersionError(Exception): pass - - class UnknownConnectionTypeError(Exception): pass - - class UnknownTimeZoneError(Exception): pass - - class ConnectionTerminationError(Exception): pass - - class PolicyAssignmentError(Exception): pass - - class PolicyInstatementError(Exception): pass - - class PolicyRevocationError(Exception): pass - - class ProfileDeletionError(Exception): pass - - class ProfileModificationError(Exception): pass - - class ProfileStateConflictError(Exception): pass - - class ProfileActivationError(Exception): pass - - class ProfileDeactivationError(Exception): pass - - class UnsupportedApplicationVersionError(Exception): pass - - class ApplicationAlreadyInstalledError(Exception): pass - - class MissingLocationError(Exception): pass - - class MissingSubscriptionError(Exception): pass - - class InvalidSubscriptionError(Exception): pass - - class InvoiceNotFoundError(Exception): pass - - class InvoiceExpiredError(Exception): pass - - class InvoicePaymentFailedError(Exception): pass - - class ConnectionUnprotectedError(Exception): pass - - class FileIntegrityError(Exception): pass - - class EndpointVerificationError(Exception): pass +class SingboxNotInstalledException(Exception): + pass \ No newline at end of file diff --git a/core/controllers/ConnectionController.py b/core/controllers/ConnectionController.py index f8ffef4..ae64a83 100644 --- a/core/controllers/ConnectionController.py +++ b/core/controllers/ConnectionController.py @@ -44,9 +44,10 @@ class ConnectionController: @staticmethod def establish_connection(profile: Union[SessionProfile, SystemProfile], ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None): - from core.controllers.ConnectionController import ConnectionController - + + ConnectionController.verify_singbox_installation() + connection = profile.connection if connection.needs_proxy_configuration() and not profile.has_proxy_configuration(): @@ -543,3 +544,25 @@ class ConnectionController: return True return False + + @staticmethod + def verify_singbox_installation(): + import subprocess + import os + from core.Errors import SingboxNotInstalledException + + singbox_binary = '/usr/bin/sing-box' + singbox_wrapper = '/usr/local/bin/hydraveil-singbox' + + if not os.path.exists(singbox_binary): + raise SingboxNotInstalledException(f'sing-box binary not found at {singbox_binary}') + + if not os.path.exists(singbox_wrapper): + raise SingboxNotInstalledException(f'sing-box wrapper not found at {singbox_wrapper}') + + try: + subprocess.run([singbox_binary, 'version'], capture_output=True, timeout=5, check=True) + except subprocess.CalledProcessError: + raise SingboxNotInstalledException('sing-box binary check failed') + except subprocess.TimeoutExpired: + raise SingboxNotInstalledException('sing-box verification timeout') \ No newline at end of file diff --git a/install.sh b/install.sh deleted file mode 100755 index e90c2aa..0000000 --- a/install.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/bin/bash -# install.sh — hydra-veil core installer - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LIB_DIR="$SCRIPT_DIR/lib" - -trap 'echo ""; echo " [ERROR] Installer failed at line $LINENO — check: cat $LOG_FILE" >&2' ERR - -for _lib in ui log input network steps; do - _path="$LIB_DIR/${_lib}.sh" - if [ ! -f "$_path" ]; then - echo "[ERROR] Missing lib: $_path" >&2 - exit 1 - fi - source "$_path" -done - -for arg in "$@"; do - case "$arg" in - --no-log) LOG_ENABLED="false" ;; - esac -done - -_log_init - -clear -print_header "HYDRA-VEIL CORE INSTALLER" "v2.0.0" -printf " ${DIM}%s${RESET}\n" "Simplified Privacy" -printf " ${DIM}%s${RESET}\n" "$(date '+%Y-%m-%d %H:%M:%S') — $(uname -n)" -print_divider - -print_section "Pre-flight" -log_info "User: ${USER}" -log_info "Home: ${HOME}" -log_info "Install dir: ${HV_BASE}" -require_internet -print_divider - -echo "" -label_info "hydra-veil se instalará en:" -printf " ${BCYAN}%s${RESET}\n\n" "$HV_BASE" -label_warn "sudo es requerido para sing-box, wrapper y sudoers." -echo "" -if ! ask_confirm "Continuar con la instalación?"; then - log_warn "Instalación cancelada." - exit 0 -fi -echo "" -print_divider - -step_preflight -step_repos -step_python_env -step_singbox -step_wrapper -step_sudoers - -echo "" -print_divider -printf "${BGREEN}" -cat << 'EOF' - _ - _ _|_| _ - | \_ _| | _/ | - | \__|___|__/ | - | | _ _ | | - | || | | || | - \_ || | | || _/ - \_||_| |_||_/ - ______| |______ - _/ \_ - _/ S I M P L I F I E D \_ - _/ P R I V A C Y \_ - | _____ _____ | - | _/ |_ _| \_ | - |_/ |_ _| \_| - \_____/ - -EOF -printf "${RESET}" -print_divider -echo "" -label_ok "hydra-veil instalado correctamente" -label_ok "Todos los steps completados" -echo "" -label_info "Activar entorno:" -printf " ${CYAN}source %s/core/.venv/bin/activate${RESET}\n" "$HV_BASE" -echo "" -label_info "CLI disponible:" -printf " ${CYAN}python -m cli${RESET}\n" -echo "" -label_info "Wrapper disponible:" -printf " ${CYAN}sudo /usr/local/bin/hydraveil-singbox start${RESET}\n" -echo "" -log_file_path -echo "" -print_divider -echo "" \ No newline at end of file diff --git a/lib/input.sh b/lib/input.sh deleted file mode 100755 index bd80153..0000000 --- a/lib/input.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -ask_input() { - local label="$1" - local default="${2:-}" - local prompt_default="" - [ -n "$default" ] && prompt_default=" ${DIM}[${default}]${RESET}" - - printf " ${BCYAN}?${RESET} ${BWHITE}%s${RESET}%b " "$label" "$prompt_default" - read -r INPUT_RESULT - [ -z "$INPUT_RESULT" ] && INPUT_RESULT="$default" -} - -ask_confirm() { - local label="$1" - local default="${2:-y}" - local hint - if [ "$default" = "y" ]; then hint="${BGREEN}Y${RESET}${DIM}/n${RESET}"; else hint="${DIM}y/${RESET}${BRED}N${RESET}"; fi - - printf " ${BYELLOW}?${RESET} ${BWHITE}%s${RESET} [%b] " "$label" "$hint" - read -r _confirm - _confirm="${_confirm:-$default}" - case "${_confirm,,}" in - y|yes) return 0 ;; - *) return 1 ;; - esac -} - -ask_pause() { - local msg="${1:-Press ENTER to continue...}" - printf " ${DIM}%s${RESET}" "$msg" - read -r -} diff --git a/lib/log.sh b/lib/log.sh deleted file mode 100755 index 6609658..0000000 --- a/lib/log.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -LOG_FILE="${LOG_FILE:-/tmp/hydra-veil-core.log}" -LOG_ENABLED="${LOG_ENABLED:-true}" - -_log_init() { - if [ "$LOG_ENABLED" = "true" ]; then - mkdir -p "$(dirname "$LOG_FILE")" - echo "──────────────────────────────────────────" >> "$LOG_FILE" - echo " LOG START: $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE" - echo "──────────────────────────────────────────" >> "$LOG_FILE" - fi -} - -_log_write() { - local level="$1" - local msg="$2" - if [ "$LOG_ENABLED" = "true" ]; then - printf "[%s] [%s] %s\n" "$(date '+%H:%M:%S')" "$level" "$msg" >> "$LOG_FILE" - fi -} - -log_info() { - label_info "$1" - _log_write "INFO " "$1" -} - -log_ok() { - label_ok "$1" - _log_write "OK " "$1" -} - -log_warn() { - label_warn "$1" - _log_write "WARN " "$1" -} - -log_error() { - label_error "$1" - _log_write "ERROR" "$1" -} - -log_step() { - local msg="$1" - local current="$2" - local total="$3" - label_step "$msg" "$current" "$total" - _log_write "STEP " "[$current/$total] $msg" -} - -log_file_path() { - printf " ${DIM}Log file: ${CYAN}%s${RESET}\n" "$LOG_FILE" -} diff --git a/lib/network.sh b/lib/network.sh deleted file mode 100755 index 776ca4f..0000000 --- a/lib/network.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash -NET_CHECK_HOST="${NET_CHECK_HOST:-8.8.8.8}" -NET_CHECK_TIMEOUT="${NET_CHECK_TIMEOUT:-3}" - -check_internet() { - local host="${1:-$NET_CHECK_HOST}" - if ping -c 1 -W "$NET_CHECK_TIMEOUT" "$host" &>/dev/null; then - label_connected "Internet reachable ${DIM}(${host})${RESET}" - _log_write "NET " "Connected: $host" - return 0 - else - label_disconnect "No internet connection ${DIM}(${host})${RESET}" - _log_write "NET " "Disconnected: $host" - return 1 - fi -} - -check_host() { - local host="$1" - local label="${2:-$host}" - if ping -c 1 -W "$NET_CHECK_TIMEOUT" "$host" &>/dev/null 2>&1; then - label_connected "$label" - return 0 - else - label_disconnect "$label" - return 1 - fi -} - -check_url() { - local url="$1" - local label="${2:-$url}" - local http_code - http_code=$(curl -s -o /dev/null -w "%{http_code}" \ - --max-time "$NET_CHECK_TIMEOUT" "$url" 2>/dev/null || echo "000") - - if [[ "$http_code" =~ ^[23] ]]; then - label_connected "$label ${DIM}(HTTP ${http_code})${RESET}" - _log_write "NET " "URL OK [$http_code]: $url" - return 0 - else - label_disconnect "$label ${DIM}(HTTP ${http_code})${RESET}" - _log_write "NET " "URL FAIL [$http_code]: $url" - return 1 - fi -} - -require_internet() { - if ! check_internet; then - log_error "Internet connection required. Aborting." - exit 1 - fi -} diff --git a/lib/steps.sh b/lib/steps.sh deleted file mode 100755 index d5fe20d..0000000 --- a/lib/steps.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash - -TOTAL_STEPS=6 - -HV_BASE="$HOME/.local/share/hydra-veil" -HV_CORE="$HV_BASE/core" -HV_CLI="$HV_BASE/cli" -HV_GUI="$HV_BASE/gui" -HV_ESSENTIALS="$HV_BASE/essentials" -HV_VENV="$HV_CORE/.venv" - -INSTALL_DIR="$HOME" -HV_DATA_DIR="$HOME/data/hydra-veil" -HV_CONFIG_DIR="$HOME/.config/hydra-veil" -HV_CACHE_DIR="$HOME/.cache/hydra-veil" -HV_STATE_DIR="$HOME/.state/hydra-veil" - -SINGBOX_BIN="/usr/bin/sing-box" -SINGBOX_WRAPPER="/usr/local/bin/hydraveil-singbox" -SINGBOX_VERSION="1.13.5" -SINGBOX_CONFIG_DIR="$HV_DATA_DIR/configs" -SINGBOX_PID_FILE="$HV_DATA_DIR/runtime/singbox.pid" -SINGBOX_LOG_FILE="$HV_DATA_DIR/runtime/singbox.log" - -SUDOERS_PATH="/etc/sudoers.d/hydraveil" - -declare -A REPO_URLS=( - ["core"]="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-core" - ["cli"]="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-cli" - ["essentials"]="https://git.simplifiedprivacy.com/Support/sp-essentials" - ["gui"]="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-gui" -) - -declare -A REPO_BRANCHES=( - ["core"]="deploy" - ["cli"]="zenaku" - ["essentials"]="master" - ["gui"]="master" -) - -declare -A REPO_DIRS=( - ["core"]="$HV_CORE" - ["cli"]="$HV_CLI" - ["essentials"]="$HV_ESSENTIALS" - ["gui"]="$HV_GUI" -) - -_STEPS_DIR="$SCRIPT_DIR/lib/steps" - -for _step in preflight repos python_env singbox wrapper sudoers; do - _step_path="${_STEPS_DIR}/${_step}.sh" - if [ ! -f "$_step_path" ]; then - echo "[ERROR] Missing step: $_step_path" >&2 - exit 1 - fi - source "$_step_path" -done \ No newline at end of file diff --git a/lib/steps/preflight.sh b/lib/steps/preflight.sh deleted file mode 100644 index 994a044..0000000 --- a/lib/steps/preflight.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -# lib/steps/preflight.sh — verificaciones previas -step_preflight() { - print_section "Pre-flight" - log_step "System checks" 1 $TOTAL_STEPS - - _preflight_user - _preflight_python - _preflight_git -} - -_preflight_user() { - log_info "Running as: ${USER} (uid=$(id -u))" - if [ "$(id -u)" -eq 0 ]; then - log_error "No correr el installer como root" - exit 1 - fi - log_ok "User OK" -} - -_preflight_python() { - local found="" - for candidate in python3.12 python3.13; do - if command -v "$candidate" &>/dev/null; then - found="$candidate" - break - fi - done - if [ -z "$found" ]; then - log_error "Se necesita Python 3.12 o 3.13" - log_info "Arch: sudo pacman -S python312" - log_info "Ubuntu: sudo apt install python3.12" - exit 1 - fi - PYTHON_BIN="$found" - log_ok "Python: $($PYTHON_BIN --version 2>&1)" -} - -_preflight_git() { - if ! command -v git &>/dev/null; then - log_error "git no encontrado" - exit 1 - fi - log_ok "git: $(git --version)" -} \ No newline at end of file diff --git a/lib/steps/python_env.sh b/lib/steps/python_env.sh deleted file mode 100644 index 60fa912..0000000 --- a/lib/steps/python_env.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/bin/bash -# lib/steps/python_env.sh — venv + pip install editable - -step_python_env() { - print_section "Python Environment" - log_step "Virtual environment + dependencies" 3 $TOTAL_STEPS - - _create_venv - _pip_install -} - -_create_venv() { - if [ -d "$HV_VENV" ]; then - log_warn "venv ya existe: ${HV_VENV}" - if ask_confirm "Recrear venv?"; then - rm -rf "$HV_VENV" - else - log_skip "venv existente conservado" - source "$HV_VENV/bin/activate" - return 0 - fi - fi - - start_spinner "Creando virtual environment (${PYTHON_BIN})..." - "$PYTHON_BIN" -m venv "$HV_VENV" - stop_spinner "venv creado → ${HV_VENV}" - - source "$HV_VENV/bin/activate" - - start_spinner "Actualizando pip..." - pip install --quiet --upgrade pip - stop_spinner "pip actualizado" -} - -_pip_install() { - source "$HV_VENV/bin/activate" - - if [ -f "$HV_GUI/pyproject.toml" ]; then - sed -i 's/sp-hydra-veil-core == [0-9.]*/sp-hydra-veil-core >= 2.3.0/' \ - "$HV_GUI/pyproject.toml" 2>/dev/null || true - fi - - for name in essentials core cli gui; do - local dir - case "$name" in - essentials) dir="$HV_ESSENTIALS" ;; - core) dir="$HV_CORE" ;; - cli) dir="$HV_CLI" ;; - gui) dir="$HV_GUI" ;; - esac - - if [ ! -d "$dir" ]; then - log_warn "${name} no encontrado en ${dir} — omitiendo" - continue - fi - - start_spinner "Instalando ${name} (editable)..." - if pip install --quiet -e "$dir" 2>/tmp/pip_err_${name}; then - stop_spinner "${name} instalado" - else - stop_spinner - log_error "pip install falló para ${name}:" - tail -5 /tmp/pip_err_${name} - exit 1 - fi - done -} \ No newline at end of file diff --git a/lib/steps/repos.sh b/lib/steps/repos.sh deleted file mode 100644 index 42bad4f..0000000 --- a/lib/steps/repos.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash - -step_repos() { - print_section "Repositories" - log_step "Cloning repositories & configuring .env" 2 $TOTAL_STEPS - - mkdir -p "$HV_BASE" - chmod 700 "$HV_BASE" - - _clone_repos - _setup_env -} - -_clone_repos() { - for name in essentials cli gui core; do - local url="${REPO_URLS[$name]}" - local branch="${REPO_BRANCHES[$name]}" - local dest="${REPO_DIRS[$name]}" - log_ok "HIJUEPUTA" - - if [ -d "$dest" ]; then - log_warn "Repo ya existe: ${dest}" - rm -rf "$dest" - fi - - if ! git clone --branch "$branch" "$url" "$dest" 2>&1; then - log_error "git clone FAILED: $name" - log_error " URL: $url" - log_error " Branch: $branch" - log_error " Dest: $dest" - exit 1 - fi - - chmod 700 "$dest" - log_ok "${name} clonado → ${dest}" - done -} - -_setup_env() { - local env_file="$HV_CORE/.env" - local example_file="$HV_CORE/.env.example" - - if [ ! -f "$example_file" ]; then - log_error ".env.example no encontrado: ${example_file}" - exit 1 - fi - - if [ -f "$env_file" ]; then - log_warn ".env ya existe — sobreescribiendo" - fi - - echo "" - printf " ${DIM}Fill in the variables. ENTER = leave empty.${RESET}\n" - print_divider - echo "" - - select_option \ - "APP_ENV — application environment" \ - "local development / testing" \ - "production production / real domain" - - case "$SELECT_RESULT" in - 0) _app_env="local" ;; - 1) _app_env="production" ;; - *) _app_env="production" ;; - esac - - echo "" - printf " ${BCYAN}┌─${RESET} ${BWHITE}SP_API_BASE_URL_PRODUCTION${RESET} ${DIM}e.g. https://fake.simplifiedprivacy.org/api/v1${RESET}\n" - printf " ${BCYAN}›${RESET} " - read -r _api_url < /dev/tty - - cat > "$env_file" << ENVEOF -APP_ENV=${_app_env} -SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 -SP_API_BASE_URL_PRODUCTION=${_api_url} -HV_CORE_HOME=${HV_CORE} -INSTALL_DIR=${HOME} -CORE_DIR=${HV_CORE} -ENVEOF - - chmod 600 "$env_file" - - log_ok "APP_ENV=${_app_env}" - [ -n "$_api_url" ] && log_ok "SP_API_BASE_URL_PRODUCTION=${_api_url}" || label_skip "SP_API_BASE_URL_PRODUCTION — vacío" - log_ok "INSTALL_DIR=${HOME}" - log_ok ".env escrito → ${env_file}" -} \ No newline at end of file diff --git a/lib/steps/singbox.sh b/lib/steps/singbox.sh deleted file mode 100644 index 5b27791..0000000 --- a/lib/steps/singbox.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash -# lib/steps/singbox.sh — instalar/verificar sing-box - -step_singbox() { - print_section "sing-box" - log_step "Checking sing-box binary" 4 $TOTAL_STEPS - - local found="" - for candidate in /usr/bin/sing-box /usr/local/bin/sing-box /bin/sing-box; do - [ -x "$candidate" ] && found="$candidate" && break - done - [ -z "$found" ] && found=$(command -v sing-box 2>/dev/null || true) - - if [ -n "$found" ]; then - local ver - ver=$("$found" version 2>/dev/null | head -1 || echo "unknown") - log_ok "Encontrado: ${found} (${ver})" - if [ "$found" != "$SINGBOX_BIN" ]; then - sudo ln -sf "$found" "$SINGBOX_BIN" - log_ok "Symlink → ${SINGBOX_BIN}" - fi - else - _install_singbox - fi - - # setcap — sing-box crea TUN sin necesitar root completo - if command -v setcap &>/dev/null; then - sudo setcap cap_net_admin,cap_net_raw+ep "$SINGBOX_BIN" - log_ok "setcap cap_net_admin,cap_net_raw+ep → ${SINGBOX_BIN}" - else - log_warn "setcap no disponible — sing-box necesitará sudo para TUN" - fi - - log_ok "sing-box listo → ${SINGBOX_BIN}" -} - -_install_singbox() { - log_info "sing-box no encontrado — instalando v${SINGBOX_VERSION}..." - - local arch - arch=$(uname -m) - case "$arch" in - x86_64) arch="amd64" ;; - aarch64) arch="arm64" ;; - *) - log_error "Arquitectura no soportada: ${arch}" - exit 1 - ;; - esac - - local url="https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${arch}.tar.gz" - local tmp_tar="/tmp/sing-box-$$.tar.gz" - local tmp_dir="/tmp/sing-box-$$" - - start_spinner "Descargando sing-box v${SINGBOX_VERSION}..." - if wget -q --timeout=60 "$url" -O "$tmp_tar"; then - stop_spinner "Descarga completa" - else - stop_spinner - rm -f "$tmp_tar" - log_error "Descarga falló: ${url}" - exit 1 - fi - - start_spinner "Extrayendo..." - mkdir -p "$tmp_dir" - tar -xzf "$tmp_tar" -C "$tmp_dir" --strip-components=1 - stop_spinner "Extraído" - - sudo cp "$tmp_dir/sing-box" "$SINGBOX_BIN" - sudo chmod 755 "$SINGBOX_BIN" - rm -rf "$tmp_tar" "$tmp_dir" - log_ok "sing-box instalado: $($SINGBOX_BIN version | head -1)" -} \ No newline at end of file diff --git a/lib/steps/sudoers.sh b/lib/steps/sudoers.sh deleted file mode 100644 index 5753297..0000000 --- a/lib/steps/sudoers.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/bash -# lib/steps/sudoers.sh — sudoers + directorios XDG - -step_sudoers() { - print_section "Permissions & Directories" - log_step "sudoers + XDG directories" 6 $TOTAL_STEPS - - _setup_sudoers - _create_directories -} - -_setup_sudoers() { - local tmp - tmp=$(mktemp) - - cat > "$tmp" << EOF -# hydraveil — generado por installer $(date '+%Y-%m-%d') -# NO editar manualmente -Defaults!${SINGBOX_WRAPPER} !requiretty -${USER} ALL=(root) NOPASSWD: ${SINGBOX_WRAPPER} -EOF - - if ! sudo visudo -c -f "$tmp" &>/dev/null; then - rm -f "$tmp" - log_error "sudoers inválido — abortando para evitar lockout" - exit 1 - fi - - sudo cp "$tmp" "$SUDOERS_PATH" - sudo chmod 440 "$SUDOERS_PATH" - rm -f "$tmp" - log_ok "sudoers instalado → ${SUDOERS_PATH}" -} - -_create_directories() { - local -a dirs=( - # XDG data - "$HV_DATA_DIR/configs" - "$HV_DATA_DIR/runtime" - "$HV_DATA_DIR/applications" - "$HV_DATA_DIR/profiles" - "$HV_DATA_DIR/ticket_data" - "$HV_DATA_DIR/incidents" - # XDG config - "$HV_CONFIG_DIR/profiles" - "$HV_CONFIG_DIR/ticketing" - # XDG state - "$HV_STATE_DIR/sessions" - "$HV_STATE_DIR/tor" - # XDG cache - "$HV_CACHE_DIR" - ) - - for dir in "${dirs[@]}"; do - mkdir -p "$dir" - chmod 700 "$dir" - log_ok "→ ${dir}" - done - - # Log file con permisos de usuario ANTES de que el wrapper lo toque - local log_file="$SINGBOX_LOG_FILE" - if [ ! -f "$log_file" ]; then - touch "$log_file" - chmod 600 "$log_file" - log_ok "Log file creado → ${log_file}" - else - log_ok "Log file ya existe → ${log_file}" - fi -} \ No newline at end of file diff --git a/lib/steps/wrapper.sh b/lib/steps/wrapper.sh deleted file mode 100644 index 0ca3fb0..0000000 --- a/lib/steps/wrapper.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/bin/bash -# lib/steps/wrapper.sh — generar wrapper de privilegios para sing-box - -step_wrapper() { - print_section "Security Wrapper" - log_step "Installing hydraveil-singbox wrapper" 5 $TOTAL_STEPS - - [ -f "$SINGBOX_WRAPPER" ] && sudo rm -f "$SINGBOX_WRAPPER" && log_ok "Wrapper anterior eliminado" - - sudo tee "$SINGBOX_WRAPPER" > /dev/null << WRAPPER -#!/bin/bash -# hydraveil-singbox — wrapper de privilegios para sing-box -# Generado por el installer. NO editar manualmente. -# Solo este binario tiene NOPASSWD en sudoers. -# -# Uso: -# sudo hydraveil-singbox start -# sudo hydraveil-singbox "" stop -# sudo hydraveil-singbox "" log -set -euo pipefail - -CONFIG="\${1:-}" -ACTION="\${2:-}" - -# ── Paths fijos (rutas XDG correctas — INSTALL_DIR=\$HOME) ──────────────────── -SINGBOX_BIN="${SINGBOX_BIN}" -PID_FILE="${SINGBOX_PID_FILE}" -LOG_FILE="${SINGBOX_LOG_FILE}" -CONFIG_DIR="${SINGBOX_CONFIG_DIR}" -STOP_TIMEOUT=8 - -# ── Validar acción ──────────────────────────────────────────────────────────── -if [[ "\$ACTION" != "start" && "\$ACTION" != "stop" && "\$ACTION" != "log" ]]; then - echo "Error: acción inválida '\$ACTION'. Uso: start|stop|log" >&2 - exit 1 -fi - -# ── Helpers ─────────────────────────────────────────────────────────────────── -_pid_running() { - local pid="\$1" - [[ "\$pid" =~ ^[0-9]+$ ]] && kill -0 "\$pid" 2>/dev/null -} - -_delete_tun() { - if ip link show tun0 &>/dev/null 2>&1; then - ip link delete tun0 2>/dev/null || true - echo "[wrapper] tun0 eliminada" >> "\$LOG_FILE" 2>/dev/null || true - fi -} - -_kill_by_pidfile() { - [ -f "\$PID_FILE" ] || return 0 - local pid - pid=\$(cat "\$PID_FILE" 2>/dev/null || echo "") - if ! _pid_running "\$pid"; then - rm -f "\$PID_FILE" - return 0 - fi - kill -TERM "\$pid" 2>/dev/null || true - echo "[wrapper] SIGTERM → PID \$pid" >> "\$LOG_FILE" 2>/dev/null || true - local elapsed=0 - while _pid_running "\$pid" && (( elapsed < STOP_TIMEOUT * 2 )); do - sleep 0.5 - (( elapsed++ )) || true - done - if _pid_running "\$pid"; then - kill -KILL "\$pid" 2>/dev/null || true - echo "[wrapper] SIGKILL → PID \$pid" >> "\$LOG_FILE" 2>/dev/null || true - sleep 0.3 - fi - rm -f "\$PID_FILE" -} - -# ── log ─────────────────────────────────────────────────────────────────────── -if [[ "\$ACTION" == "log" ]]; then - [ -f "\$LOG_FILE" ] && tail -80 "\$LOG_FILE" || echo "Sin logs disponibles" - exit 0 -fi - -# ── stop ────────────────────────────────────────────────────────────────────── -if [[ "\$ACTION" == "stop" ]]; then - _kill_by_pidfile - _delete_tun - echo "[wrapper] sing-box detenido — \$(date '+%Y-%m-%d %H:%M:%S')" >> "\$LOG_FILE" 2>/dev/null || true - exit 0 -fi - -# ── start ───────────────────────────────────────────────────────────────────── -[[ -z "\$CONFIG" ]] && { echo "Error: config requerido para start" >&2; exit 1; } -[[ ! -f "\$CONFIG" ]] && { echo "Error: config no encontrado: \$CONFIG" >&2; exit 1; } -[[ ! -r "\$CONFIG" ]] && { echo "Error: config no legible: \$CONFIG" >&2; exit 1; } - -_real_config=\$(realpath "\$CONFIG") -_real_config_dir=\$(realpath "\$CONFIG_DIR") -if [[ "\$_real_config" != "\$_real_config_dir"/* ]]; then - echo "Error: config fuera del directorio permitido" >&2 - echo " Config: \$_real_config" >&2 - echo " Permitido: \$_real_config_dir" >&2 - exit 1 -fi - -if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "\$CONFIG" 2>/dev/null; then - echo "Error: JSON inválido: \$CONFIG" >&2 - exit 1 -fi - -[[ ! -x "\$SINGBOX_BIN" ]] && { echo "Error: sing-box no encontrado: \$SINGBOX_BIN" >&2; exit 1; } - -_kill_by_pidfile -_delete_tun - -mkdir -p "\$(dirname "\$PID_FILE")" && chmod 700 "\$(dirname "\$PID_FILE")" -mkdir -p "\$(dirname "\$LOG_FILE")" && chmod 700 "\$(dirname "\$LOG_FILE")" - -"\$SINGBOX_BIN" run -c "\$CONFIG" >> "\$LOG_FILE" 2>&1 & -_new_pid=\$! - -sleep 0.3 -if ! _pid_running "\$_new_pid"; then - echo "Error: sing-box terminó inmediatamente. Ver: \$LOG_FILE" >&2 - exit 1 -fi - -echo "\$_new_pid" > "\$PID_FILE" -chmod 600 "\$PID_FILE" -echo "[wrapper] sing-box iniciado — PID \$_new_pid — \$(date '+%Y-%m-%d %H:%M:%S')" >> "\$LOG_FILE" -echo "[wrapper] config: \$_real_config" >> "\$LOG_FILE" -exit 0 -WRAPPER - - sudo chmod 755 "$SINGBOX_WRAPPER" - log_ok "Wrapper instalado → ${SINGBOX_WRAPPER}" - - # Verificar que el wrapper funciona - if sudo "$SINGBOX_WRAPPER" "" stop &>/dev/null; then - log_ok "Wrapper verificado (stop OK)" - else - log_warn "Wrapper instalado pero verificación falló — revisar manualmente" - fi -} \ No newline at end of file diff --git a/lib/ui.sh b/lib/ui.sh deleted file mode 100755 index bb1ed54..0000000 --- a/lib/ui.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/bash - -RESET="\033[0m" -BOLD="\033[1m" -DIM="\033[2m" - -BLACK="\033[0;30m" -RED="\033[0;31m" -GREEN="\033[0;32m" -YELLOW="\033[0;33m" -BLUE="\033[0;34m" -CYAN="\033[0;36m" -WHITE="\033[0;37m" - -BRED="\033[1;31m" -BGREEN="\033[1;32m" -BYELLOW="\033[1;33m" -BBLUE="\033[1;34m" -BCYAN="\033[1;36m" -BWHITE="\033[1;37m" - -print_header() { - local title="${1:-INSTALLER}" - local version="${2:-}" - local width=60 - local line - printf -v line '%*s' "$width" '' - line="${line// /─}" - - echo "" - printf "${BCYAN}${line}${RESET}\n" - printf "${BCYAN} %-56s${RESET}\n" "" - printf "${BCYAN} ${BWHITE}%-54s${BCYAN} ${RESET}\n" "$title ${DIM}${version}${RESET}" - printf "${BCYAN} %-56s${RESET}\n" "" - printf "${BCYAN}${line}${RESET}\n" - echo "" -} - -print_section() { - local label="$1" - echo "" - printf "${DIM}${CYAN}┌─${RESET} ${BWHITE}${label}${RESET}\n" -} - -print_divider() { - printf "${DIM}────────────────────────────────────────────────────────────${RESET}\n" -} - -label_ok() { printf " ${BGREEN}[ OK ]${RESET} %s\n" "$1"; } -label_warn() { printf " ${BYELLOW}[ WARN ]${RESET} %s\n" "$1"; } -label_error() { printf " ${BRED}[ ERROR]${RESET} %s\n" "$1"; } -label_info() { printf " ${BCYAN}[ INFO ]${RESET} %s\n" "$1"; } -label_skip() { printf " ${DIM}[ SKIP ]${RESET} %s\n" "$1"; } -label_simple() { printf " ${DIM}[SIMPLE]${RESET} %s\n" "$1"; } -label_connected() { printf " ${BGREEN}[CONNECTED ]${RESET} %s\n" "$1"; } -label_disconnect() { printf " ${BRED}[DISCONNECTED]${RESET} %s\n" "$1"; } -label_step() { printf " ${BBLUE}[ %-2s/%-2s ]${RESET} %s\n" "$2" "$3" "$1"; } - -progress_bar() { - local current=$1 - local total=$2 - local label="${3:-}" - local width=40 - local filled=$(( current * width / total )) - local empty=$(( width - filled )) - local pct=$(( current * 100 / total )) - - local bar_filled bar_empty - printf -v bar_filled '%*s' "$filled" '' - printf -v bar_empty '%*s' "$empty" '' - bar_filled="${bar_filled// /#}" - bar_empty="${bar_empty// /·}" - - printf "\r ${DIM}[${RESET}${BGREEN}${bar_filled}${RESET}${DIM}${bar_empty}${RESET}${DIM}]${RESET} ${BWHITE}%3d%%${RESET} ${DIM}%s${RESET} " \ - "$pct" "$label" -} - -progress_simulate() { - local steps="${1:-20}" - local label="${2:-loading}" - for i in $(seq 1 "$steps"); do - progress_bar "$i" "$steps" "$label" - sleep 0.04 - done - echo "" -} - -SPINNER_PID="" - -start_spinner() { - local msg="${1:-Processing...}" - local frames=('─' '\\' '│' '/') - ( - local i=0 - while true; do - printf "\r ${BCYAN}${frames[$i]}${RESET} ${DIM}%s${RESET} " "$msg" - i=$(( (i + 1) % 4 )) - sleep 0.1 - done - ) & - SPINNER_PID=$! - disown "$SPINNER_PID" 2>/dev/null -} - -stop_spinner() { - local result_msg="${1:-}" - if [ -n "$SPINNER_PID" ]; then - kill "$SPINNER_PID" 2>/dev/null - wait "$SPINNER_PID" 2>/dev/null - SPINNER_PID="" - fi - printf "\r%*s\r" 60 "" - [ -n "$result_msg" ] && label_ok "$result_msg" -} -- 2.45.2 From 399c66e3cb5833ab7cfbb27ace1ba095d6777abe Mon Sep 17 00:00:00 2001 From: zenaku Date: Thu, 4 Jun 2026 07:40:04 -0500 Subject: [PATCH 39/51] Verify Sing-box before the connection --- uninstall.sh | 205 --------------------------------------------------- 1 file changed, 205 deletions(-) delete mode 100755 uninstall.sh diff --git a/uninstall.sh b/uninstall.sh deleted file mode 100755 index d2f023c..0000000 --- a/uninstall.sh +++ /dev/null @@ -1,205 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LIB_DIR="$SCRIPT_DIR/lib" - -for _lib in ui log input steps; do - _path="$LIB_DIR/${_lib}.sh" - if [ ! -f "$_path" ]; then - echo "[ERROR] Missing lib: $_path" >&2 - exit 1 - fi - source "$_path" -done - -FULL=false -YES=false - -for arg in "$@"; do - case "$arg" in - --full|-f) FULL=true ;; - --yes|-y) YES=true ;; - --help|-h) - echo "" - echo " Usage: $0 [options]" - echo "" - echo " No flags — removes wrapper, sudoers, runtime" - echo " keeps repos, venv, configs and data" - echo "" - echo " --full — everything above + repos, venv, configs and data" - echo " --yes — skip confirmation prompt" - echo "" - exit 0 - ;; - *) - log_error "Unknown flag: $arg" - exit 1 - ;; - esac -done - -clear -print_header "HYDRA-VEIL UNINSTALLER" "v2.0.0" -printf " ${DIM}%s${RESET}\n" "$(date '+%Y-%m-%d %H:%M:%S') — $(uname -n)" -print_divider - -if $FULL; then - label_warn "Mode --full: everything will be removed (repos, venv, configs, data)" -else - label_info "Normal mode: repos, venv, configs and data will be kept" - label_info "Use --full to remove everything" -fi - -echo "" - -if ! $YES; then - if ! ask_confirm "Continue with uninstall?"; then - log_warn "Uninstall cancelled." - exit 0 - fi -fi - -print_divider - -_rm() { - local target="$1" - if [ -e "$target" ]; then - rm -rf "$target" - label_ok "Removed: $target" - else - label_info "Not found: $target" - fi -} - -print_section "1. sing-box process" - -_stop_singbox() { - local pid="" - - if [ -f "$SINGBOX_PID_FILE" ]; then - pid=$(cat "$SINGBOX_PID_FILE" 2>/dev/null || echo "") - fi - - if [[ -z "$pid" || ! "$pid" =~ ^[0-9]+$ ]]; then - pid=$(pgrep -x sing-box 2>/dev/null | head -1 || echo "") - fi - - if [[ -n "$pid" && "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then - log_info "Stopping sing-box PID $pid..." - kill -TERM "$pid" 2>/dev/null || true - local elapsed=0 - while kill -0 "$pid" 2>/dev/null && (( elapsed < 16 )); do - sleep 0.5 - (( elapsed++ )) || true - done - if kill -0 "$pid" 2>/dev/null; then - kill -KILL "$pid" 2>/dev/null || true - sleep 0.3 - label_ok "sing-box killed (SIGKILL)" - else - label_ok "sing-box stopped (SIGTERM)" - fi - else - label_info "sing-box is not running" - fi -} - -if [ -x "$SINGBOX_WRAPPER" ]; then - sudo "$SINGBOX_WRAPPER" "" stop 2>/dev/null && label_ok "Stopped via wrapper" || _stop_singbox -else - _stop_singbox -fi - -if ip link show tun0 &>/dev/null 2>&1; then - sudo ip link delete tun0 2>/dev/null && label_ok "tun0 removed" || label_warn "Could not remove tun0" -else - label_info "tun0 not found" -fi - -print_divider -print_section "2. Wrapper" - -if [ -f "$SINGBOX_WRAPPER" ]; then - sudo rm -f "$SINGBOX_WRAPPER" - label_ok "Removed: $SINGBOX_WRAPPER" -else - label_info "Not found: $SINGBOX_WRAPPER" -fi - -print_divider -print_section "3. Sudoers" - -if [ -f "$SUDOERS_PATH" ]; then - sudo rm -f "$SUDOERS_PATH" - label_ok "Removed: $SUDOERS_PATH" -else - label_info "Not found: $SUDOERS_PATH" -fi - -_orphans=$(sudo grep -rl "hydraveil" /etc/sudoers.d/ 2>/dev/null || true) -if [ -n "$_orphans" ]; then - label_warn "Orphaned sudoers entries found:" - echo "$_orphans" | while read -r f; do - sudo rm -f "$f" && label_ok " Removed: $f" - done -else - label_info "No orphaned entries in sudoers.d" -fi - -print_divider -print_section "4. Runtime" - -_rm "$SINGBOX_PID_FILE" -_rm "$SINGBOX_LOG_FILE" -_rm "$HV_DATA_DIR/runtime" - -print_divider - -if $FULL; then - print_section "5. Data" - _rm "$HV_DATA_DIR" - - print_section "6. Configs" - _rm "$HV_CONFIG_DIR" - _rm "$HV_CACHE_DIR" - _rm "$HV_STATE_DIR" - - print_section "7. Venv" - _rm "$HV_VENV" - - print_section "8. Repos" - for _dir in "$HV_CORE" "$HV_CLI" "$HV_GUI" "$HV_ESSENTIALS"; do - _rm "$_dir" - done - - if [ -d "$HV_BASE" ] && [ -z "$(ls -A "$HV_BASE" 2>/dev/null)" ]; then - rmdir "$HV_BASE" - label_ok "Empty base directory removed: $HV_BASE" - fi - - if [ -L "$SINGBOX_BIN" ]; then - sudo rm -f "$SINGBOX_BIN" - label_ok "sing-box symlink removed: $SINGBOX_BIN" - else - label_info "sing-box is not a symlink — kept: $SINGBOX_BIN" - fi - - print_divider -fi - -echo "" -label_ok "Uninstall complete" - -if $FULL; then - label_ok "Mode --full: everything removed" -else - echo "" - label_info "Kept: repos, venv, configs, data" - label_info "To remove everything: $0 --full" -fi - -echo "" -print_divider -echo "" -- 2.45.2 From eff49cf1f75fbb084792c0399f451a0b6b19789d Mon Sep 17 00:00:00 2001 From: zenaku Date: Fri, 5 Jun 2026 10:31:35 -0500 Subject: [PATCH 40/51] radical change --- README.md | 1 + core/controllers/ConnectionController.py | 31 ++-- core/controllers/ProfileController.py | 21 +-- core/observers/ConnectionObserver.py | 8 +- .../encrypted_proxy/disable_service.py | 49 +++---- .../encrypted_proxy/hysteria_service.py | 83 +++++++++-- .../services/encrypted_proxy/vless_service.py | 79 ++++++++-- core/utils/encrypted_proxy/dns.py | 51 +++++++ core/utils/encrypted_proxy/killswitch.py | 36 +++++ core/utils/encrypted_proxy/net.py | 5 +- core/utils/encrypted_proxy/singbox.py | 135 +++++++++--------- 11 files changed, 347 insertions(+), 152 deletions(-) create mode 100644 core/utils/encrypted_proxy/dns.py create mode 100644 core/utils/encrypted_proxy/killswitch.py diff --git a/README.md b/README.md index 59280df..ec54fb4 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,4 @@ controller = HysteriaController(1080) controller.disable(observer) " ``` +sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf \ No newline at end of file diff --git a/core/controllers/ConnectionController.py b/core/controllers/ConnectionController.py index ae64a83..35ef893 100644 --- a/core/controllers/ConnectionController.py +++ b/core/controllers/ConnectionController.py @@ -217,14 +217,27 @@ class ConnectionController: if protocol == 'vless': from core.controllers.encrypted_proxy.VlessController import VlessController - VlessController(1080).enable(operator_proxy_session.links[0], operator_proxy_session.username, connection_observer) + ok = VlessController(1080).enable( + operator_proxy_session.links[0], + operator_proxy_session.username, + connection_observer + ) elif protocol == 'hysteria2': from core.controllers.encrypted_proxy.HysteriaController import HysteriaController - HysteriaController(1080).enable(operator_proxy_session.username, operator_proxy_session.password, operator_proxy_session.operator_hysteria2_host, connection_observer) + ok = HysteriaController(1080).enable( + operator_proxy_session.username, + operator_proxy_session.password, + operator_proxy_session.operator_hysteria2_host, + connection_observer + ) + else: + ok = False + + if not ok: + raise ConnectionError('The connection could not be established.') SystemStateController.create(profile.id) return - if ConfigurationController.get_endpoint_verification_enabled(): ProfileController.verify_wireguard_endpoint(profile, ignore=ignore) @@ -338,7 +351,7 @@ class ConnectionController: return subprocess.Popen(('proxychains4', '-f', proxychains_configuration_file_path, 'microsocks', '-p', str(proxy_port_number)), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) @staticmethod - def terminate_system_connection(): + def terminate_system_connection(connection_observer: Optional[ConnectionObserver] = None): system_state = SystemStateController.get() if system_state is not None: profile = ProfileController.get(system_state.profile_id) @@ -346,10 +359,10 @@ class ConnectionController: protocol = profile.connection.get_protocol() if protocol == 'vless': from core.controllers.encrypted_proxy.VlessController import VlessController - VlessController(1080).disable() + VlessController(1080).disable(connection_observer) elif protocol == 'hysteria2': from core.controllers.encrypted_proxy.HysteriaController import HysteriaController - HysteriaController(1080).disable() + HysteriaController(1080).disable(connection_observer) SystemState.dissolve() return @@ -357,18 +370,18 @@ class ConnectionController: raise CommandNotFoundError('nmcli') if SystemStateController.exists(): - process = subprocess.Popen(('nmcli', 'connection', 'delete', 'wg'), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8) if completed_successfully or not ConnectionController.system_uses_wireguard_interface(): - subprocess.run(('nmcli', 'connection', 'delete', 'hv-ipv6-sink'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) ConnectionController.terminate_tor_connection() SystemState.dissolve() - + if connection_observer is not None: + connection_observer.notify('disconnected', {}) else: raise ConnectionTerminationError('The connection could not be terminated.') + @staticmethod def get_proxies(port_number: int): diff --git a/core/controllers/ProfileController.py b/core/controllers/ProfileController.py index 14473eb..688837c 100644 --- a/core/controllers/ProfileController.py +++ b/core/controllers/ProfileController.py @@ -86,51 +86,36 @@ class ProfileController: profile_observer.notify('enabled', profile) @staticmethod - def disable(profile: Union[SessionProfile, SystemProfile], explicitly: bool = True, ignore: tuple[type[Exception]] = (), profile_observer: ProfileObserver = None): + def disable(profile: Union[SessionProfile, SystemProfile], explicitly: bool = True, ignore: tuple[type[Exception]] = (), profile_observer: ProfileObserver = None, connection_observer: ConnectionObserver = None): from core.controllers.ConnectionController import ConnectionController if profile.is_session_profile(): - if SessionStateController.exists(profile.id): - session_state = SessionStateController.get(profile.id) - if session_state is not None: - for port_number in session_state.network_port_numbers.tor: ConnectionController.terminate_tor_session_connection(port_number) - session_state.dissolve(session_state.id) if profile.is_system_profile(): - subjects = ProfileController.get_all().values() - for subject in subjects: - if subject.is_session_profile(): - if subject.connection.is_unprotected() and ProfileController.is_enabled(subject) and not ConnectionUnprotectedError in ignore: raise ConnectionUnprotectedError('Disabling this system connection would leave one or more sessions exposed.') if SystemStateController.exists(): - system_state = SystemStateController.get() - if profile.id != system_state.profile_id: raise ProfileDeactivationError('The profile could not be disabled.') - try: - ConnectionController.terminate_system_connection() + ConnectionController.terminate_system_connection(connection_observer=connection_observer) except ConnectionTerminationError: raise ProfileDeactivationError('The profile could not be disabled.') if profile_observer is not None: - - profile_observer.notify('disabled', profile, dict( - explicitly=explicitly, - )) + profile_observer.notify('disabled', profile, dict(explicitly=explicitly)) time.sleep(1.0) diff --git a/core/observers/ConnectionObserver.py b/core/observers/ConnectionObserver.py index 88ad874..bd6574e 100644 --- a/core/observers/ConnectionObserver.py +++ b/core/observers/ConnectionObserver.py @@ -1,5 +1,9 @@ from essentials.observers.ConnectionObserver import ConnectionObserver as BaseConnectionObserver - class ConnectionObserver(BaseConnectionObserver): - pass + + def __init__(self): + super().__init__() + self.on_connected = [] + self.on_disconnected = [] + self.on_error = [] \ No newline at end of file diff --git a/core/services/encrypted_proxy/disable_service.py b/core/services/encrypted_proxy/disable_service.py index 6ac777b..35acaea 100644 --- a/core/services/encrypted_proxy/disable_service.py +++ b/core/services/encrypted_proxy/disable_service.py @@ -1,33 +1,26 @@ -import ssl +import subprocess import time -import urllib.request -from pathlib import Path from core.utils.encrypted_proxy.singbox import SingboxRunner +from core.utils.encrypted_proxy.dns import revert_dns_on_tun +from core.utils.encrypted_proxy import killswitch def get_public_ip(timeout: int = 8) -> str: - ctx = ssl.create_default_context() - try: - req = urllib.request.Request( - "https://ifconfig.me/ip", - headers={"User-Agent": "curl/7.0"} - ) - with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: - return resp.read().decode().strip() - except Exception as e: - return f"unknown ({e})" - - -def disable_proxy(tmp_dir: Path, wrapper: str, unit: str, observer=None) -> bool: - runner = SingboxRunner(wrapper, unit) - runner.stop() - - for f in tmp_dir.glob("*-sing-box.json"): - f.unlink(missing_ok=True) - - time.sleep(1) - ip = get_public_ip() - - if observer: - observer.notify("disconnected", {"ip": ip}) - return True \ No newline at end of file + endpoints = [ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + ] + for url in endpoints: + try: + r = subprocess.run( + ["curl", "-s", "--max-time", str(timeout), url], + capture_output=True, + timeout=timeout + 2, + ) + ip = r.stdout.decode().strip() + if ip and "." in ip and not ip.startswith("unknown"): + return ip + except Exception: + continue + return "unknown" \ No newline at end of file diff --git a/core/services/encrypted_proxy/hysteria_service.py b/core/services/encrypted_proxy/hysteria_service.py index 42bf09a..4dbb430 100644 --- a/core/services/encrypted_proxy/hysteria_service.py +++ b/core/services/encrypted_proxy/hysteria_service.py @@ -1,24 +1,33 @@ import socket +import subprocess import time from pathlib import Path from core.Constants import Constants from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner - +from core.utils.encrypted_proxy.dns import wait_for_tun, set_dns_on_tun, revert_dns_on_tun +from core.utils.encrypted_proxy import killswitch +from core.services.encrypted_proxy.disable_service import get_public_ip def _resolve(host: str) -> str: return socket.gethostbyname(host) - def build_hysteria_config(username: str, password: str, server_host: str, socks5_port: int) -> dict: server_ip = _resolve(server_host) return { "dns": { - "servers": [{"tag": "local", "type": "udp", "server": "9.9.9.9"}], - "final": "local", + "servers": [ + { + "tag": "tunnel-dns", + "type": "udp", + "server": "9.9.9.9", + }, + ], + "final": "tunnel-dns", "strategy": "ipv4_only", + "independent_cache": True, }, "inbounds": [ { @@ -28,7 +37,7 @@ def build_hysteria_config(username: str, password: str, "address": ["172.19.0.1/30"], "mtu": 9000, "auto_route": True, - "stack": "system", + "stack": "gvisor", }, { "type": "socks", @@ -57,43 +66,88 @@ def build_hysteria_config(username: str, password: str, "rules": [ {"protocol": "dns", "action": "hijack-dns"}, {"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"}, - # DNS y servidor salen directo — nunca por el TUN - {"ip_cidr": ["9.9.9.9/32"], "outbound": "direct"}, {"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"}, {"ip_is_private": True, "outbound": "direct"}, {"ip_version": 6, "outbound": "block"}, {"inbound": ["tun-in", "socks-in"], "outbound": "proxy"}, ], "final": "proxy", - "default_domain_resolver": "local", + "default_domain_resolver": "tunnel-dns", "auto_detect_interface": True, }, } +def _cleanup_all() -> None: + try: + SingboxRunner().stop() + except Exception: + pass + + try: + revert_dns_on_tun() + except Exception: + pass + + try: + killswitch.disarm() + except Exception: + pass + + time.sleep(1) + + def enable_hysteria(username: str, password: str, server_host: str, socks5_port: int, observer=None) -> bool: + + _cleanup_all() + + server_ip = _resolve(server_host) real_ip = get_real_ip() + + if not killswitch.arm(server_ip): + if observer: + observer.notify("error", "Failed to arm kill switch") + return False + runner = SingboxRunner() runner.stop() config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json" config = build_hysteria_config(username, password, server_host, socks5_port) + try: runner.write_config(config_path, config) ok = runner.start(config_path) except Exception as e: + killswitch.disarm() if observer: observer.notify("error", str(e)) return False + if not ok: + killswitch.disarm() if observer: observer.notify("error", "sing-box not active after start") return False - # Esperar que el proxy esté listo para rutear tráfico - time.sleep(3) + if not wait_for_tun(timeout=15.0): + killswitch.disarm() + runner.stop() + if observer: + observer.notify("error", "tun0 did not appear after 15s") + return False + + set_dns_on_tun() proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0) + if not proxy_ip or proxy_ip == "unknown" or proxy_ip == real_ip: + killswitch.disarm() + revert_dns_on_tun() + runner.stop() + if observer: + observer.notify("error", "IP did not change — possible leak") + return False + if observer: observer.notify("connected", { "real_ip": real_ip, @@ -102,9 +156,12 @@ def enable_hysteria(username: str, password: str, server_host: str, }) return True - def disable_hysteria(observer=None) -> bool: + revert_dns_on_tun() SingboxRunner().stop() + killswitch.disarm() + time.sleep(1) + ip = get_public_ip() if observer: - observer.notify("disconnected", {}) - return True + observer.notify("disconnected", {"ip": ip}) + return True \ No newline at end of file diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py index ac721c5..517579e 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -1,10 +1,14 @@ from urllib.parse import unquote from pathlib import Path +import socket +import subprocess +import time +from core.services.encrypted_proxy.disable_service import get_public_ip from core.Constants import Constants from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner -import socket -import time +from core.utils.encrypted_proxy.dns import wait_for_tun, set_dns_on_tun, revert_dns_on_tun +from core.utils.encrypted_proxy import killswitch def parse_vless_link(link: str) -> dict: @@ -39,13 +43,14 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: "dns": { "servers": [ { - "tag": "local", + "tag": "tunnel-dns", "type": "udp", "server": "9.9.9.9", }, ], - "final": "local", + "final": "tunnel-dns", "strategy": "ipv4_only", + "independent_cache": True, }, "inbounds": [ { @@ -55,7 +60,7 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: "address": ["172.19.0.1/30"], "mtu": 9000, "auto_route": True, - "stack": "system", + "stack": "gvisor", }, { "type": "socks", @@ -66,7 +71,7 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: ], "outbounds": [ {"type": "direct", "tag": "direct"}, - {"type": "block", "tag": "block"}, + {"type": "block", "tag": "block"}, { "type": "vless", "tag": "proxy", @@ -89,43 +94,89 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict: "rules": [ {"protocol": "dns", "action": "hijack-dns"}, {"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"}, - {"ip_cidr": ["9.9.9.9/32"], "outbound": "direct"}, {"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"}, {"ip_is_private": True, "outbound": "direct"}, {"ip_version": 6, "outbound": "block"}, {"inbound": ["tun-in", "socks-in"], "outbound": "proxy"}, ], "final": "proxy", - "default_domain_resolver": "local", + "default_domain_resolver": "tunnel-dns", "auto_detect_interface": True, }, } +def _cleanup_all() -> None: + try: + SingboxRunner().stop() + except Exception: + pass + + try: + revert_dns_on_tun() + except Exception: + pass + + try: + killswitch.disarm() + except Exception: + pass + + time.sleep(1) + + def enable_vless(vless_link: str, username: str, socks5_port: int, observer=None) -> bool: + + _cleanup_all() + vless = parse_vless_link(vless_link) + server_ip = socket.gethostbyname(vless["host"]) real_ip = get_real_ip() + + if not killswitch.arm(server_ip): + if observer: + observer.notify("error", "Failed to arm kill switch") + return False + runner = SingboxRunner() runner.stop() config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json" config = build_vless_config(vless, socks5_port) + try: runner.write_config(config_path, config) ok = runner.start(config_path) except Exception as e: + killswitch.disarm() if observer: observer.notify("error", str(e)) return False + if not ok: + killswitch.disarm() if observer: observer.notify("error", "sing-box not active after start") return False - # Esperar que el proxy esté listo para rutear tráfico - time.sleep(3) + if not wait_for_tun(timeout=15.0): + killswitch.disarm() + runner.stop() + if observer: + observer.notify("error", "tun0 did not appear after 15s") + return False + + set_dns_on_tun() proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0) + if not proxy_ip or proxy_ip == "unknown" or proxy_ip == real_ip: + killswitch.disarm() + revert_dns_on_tun() + runner.stop() + if observer: + observer.notify("error", "IP did not change — possible leak") + return False + if observer: observer.notify("connected", { "real_ip": real_ip, @@ -136,7 +187,11 @@ def enable_vless(vless_link: str, username: str, def disable_vless(observer=None) -> bool: + revert_dns_on_tun() SingboxRunner().stop() + killswitch.disarm() + time.sleep(1) + ip = get_public_ip() if observer: - observer.notify("disconnected", {}) - return True + observer.notify("disconnected", {"ip": ip}) + return True \ No newline at end of file diff --git a/core/utils/encrypted_proxy/dns.py b/core/utils/encrypted_proxy/dns.py new file mode 100644 index 0000000..7b122b0 --- /dev/null +++ b/core/utils/encrypted_proxy/dns.py @@ -0,0 +1,51 @@ +import os +import subprocess +import time + +RESOLVECTL_WRAPPER = "/usr/local/bin/hydraveil-resolvectl" +TUN_IFACE = "tun0" +TUN_DNS = "9.9.9.9" + +_SUDO_KW = dict( + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + stdin=subprocess.DEVNULL, + env={**os.environ, "SUDO_ASKPASS": "/bin/false"}, + text=True, +) + + +def wait_for_tun(timeout: float = 15.0, interval: float = 0.5) -> bool: + elapsed = 0.0 + while elapsed < timeout: + result = subprocess.run( + ["ip", "link", "show", TUN_IFACE], + capture_output=True, + ) + if result.returncode == 0: + return True + time.sleep(interval) + elapsed += interval + return False + + +def set_dns_on_tun() -> bool: + result = subprocess.run( + ["sudo", RESOLVECTL_WRAPPER, "set", TUN_DNS], + **_SUDO_KW, + ) + if result.returncode != 0: + print(f"[DNS] Failed to configure tun0: {result.stderr.strip()}") + return False + return True + + +def revert_dns_on_tun() -> bool: + result = subprocess.run( + ["sudo", RESOLVECTL_WRAPPER, "revert"], + **_SUDO_KW, + ) + if result.returncode != 0: + print(f"[DNS] Failed to revert tun0: {result.stderr.strip()}") + return False + return True \ No newline at end of file diff --git a/core/utils/encrypted_proxy/killswitch.py b/core/utils/encrypted_proxy/killswitch.py new file mode 100644 index 0000000..09235bb --- /dev/null +++ b/core/utils/encrypted_proxy/killswitch.py @@ -0,0 +1,36 @@ +import subprocess + +KILLSWITCH_WRAPPER = "/usr/local/bin/hydraveil-killswitch" + + +def arm(server_ip: str) -> bool: + result = subprocess.run( + ["sudo", KILLSWITCH_WRAPPER, "arm", server_ip], + capture_output=True, + text=True + ) + if result.returncode != 0: + print(f"[killswitch] Failed to arm: {result.stderr.strip()}") + return False + return True + + +def disarm() -> bool: + result = subprocess.run( + ["sudo", KILLSWITCH_WRAPPER, "disarm"], + capture_output=True, + text=True + ) + if result.returncode != 0: + print(f"[killswitch] Failed to disarm: {result.stderr.strip()}") + return False + return True + + +def status() -> bool: + result = subprocess.run( + ["sudo", KILLSWITCH_WRAPPER, "status"], + capture_output=True, + text=True + ) + return result.returncode == 0 and result.stdout.strip() == "armed" \ No newline at end of file diff --git a/core/utils/encrypted_proxy/net.py b/core/utils/encrypted_proxy/net.py index 48f4fd9..14308c6 100644 --- a/core/utils/encrypted_proxy/net.py +++ b/core/utils/encrypted_proxy/net.py @@ -54,7 +54,6 @@ def get_proxied_ip(socks5_port: int, timeout: int = 15) -> str: stderr = r.stderr.decode().strip() if ip and not ip.startswith("<") and "." in ip: return ip - # Log silencioso para debug if stderr: print(f"[net] curl stderr ({url}): {stderr[:120]}") except subprocess.TimeoutExpired: @@ -66,10 +65,10 @@ def get_proxied_ip(socks5_port: int, timeout: int = 15) -> str: def verify_ip(socks5_port: int, retries: int = 5, delay: float = 3.0) -> str: for attempt in range(1, retries + 1): - print(f"[net] verify_ip intento {attempt}/{retries}...") + print(f"[net] verify_ip attempt {attempt}/{retries}...") ip = get_proxied_ip(socks5_port) if ip != "unknown": return ip if attempt < retries: time.sleep(delay) - return "unknown" + return "unknown" \ No newline at end of file diff --git a/core/utils/encrypted_proxy/singbox.py b/core/utils/encrypted_proxy/singbox.py index 76df3c0..784e689 100644 --- a/core/utils/encrypted_proxy/singbox.py +++ b/core/utils/encrypted_proxy/singbox.py @@ -8,33 +8,17 @@ from core.Constants import Constants class SingboxRunner: - """ - Gestiona el ciclo de vida de sing-box via wrapper sudo. - Flujo: - write_config() → start() → is_running() → stop() - - El wrapper es el único binario con NOPASSWD en sudoers. - Nunca se llama sing-box ni ip directamente con sudo desde Python. - """ - - _WRAPPER = Constants.SINGBOX_WRAPPER - _PID_FILE = Path(Constants.SINGBOX_PID_FILE) - _LOG_FILE = Path(Constants.SINGBOX_LOG_FILE) + _WRAPPER = Constants.SINGBOX_WRAPPER + _PID_FILE = Path(Constants.SINGBOX_PID_FILE) + _LOG_FILE = Path(Constants.SINGBOX_LOG_FILE) _CONFIG_DIR = Path(Constants.SINGBOX_CONFIG_DIR) - _START_TIMEOUT_S = 15.0 # tiempo total de espera - _START_POLL_S = 0.5 # intervalo de polling - _START_INITIAL_S = 2.0 # espera inicial antes de empezar a verificar - # (el wrapper necesita ~1s para escribir el PID file) - - # ── Público ─────────────────────────────────────────────────────────────── + _START_TIMEOUT_S = 15.0 + _START_POLL_S = 0.5 + _START_INITIAL_S = 2.0 def start(self, config_path: Path) -> bool: - """ - Para cualquier instancia previa y lanza sing-box via wrapper. - Retorna True solo cuando el proceso está confirmado vivo. - """ self.stop() self._assert_config_safe(config_path) self._ensure_log_file() @@ -49,11 +33,10 @@ class SingboxRunner: ) except FileNotFoundError: raise RuntimeError( - f'Wrapper no encontrado: {self._WRAPPER}\n' - 'Ejecutá el installer primero.' + f'Wrapper not found: {self._WRAPPER}\n' + 'Run the installer first.' ) - # Esperar que el wrapper escriba el PID file antes de empezar polling time.sleep(self._START_INITIAL_S) deadline = time.monotonic() + self._START_TIMEOUT_S @@ -63,15 +46,13 @@ class SingboxRunner: time.sleep(self._START_POLL_S) raise RuntimeError( - f'sing-box no levantó en {self._START_TIMEOUT_S}s.\n' - f'Último error:\n{self.get_last_error()}' + f'sing-box did not start within {self._START_TIMEOUT_S}s.\n' + f'Last error:\n{self.get_last_error()}' ) def stop(self) -> None: - """ - Para sing-box limpiamente via wrapper. - Nunca lanza excepción — stop debe ser siempre seguro de llamar. - """ + self._kill_orphans() + try: subprocess.run( ['sudo', self._WRAPPER, '', 'stop'], @@ -85,16 +66,7 @@ class SingboxRunner: self._emergency_kill() def is_running(self) -> bool: - """ - Verifica por PID file + señal 0. - PermissionError significa que el proceso VIVE (corre como root). - Si el PID file no existe todavía, verifica por pgrep como fallback - para cubrir la ventana entre que el proceso arranca y el wrapper - escribe el PID file. - """ if not self._PID_FILE.exists(): - # Fallback: el proceso puede estar vivo pero el PID file - # aún no fue escrito por el wrapper try: result = subprocess.run( ['pgrep', '-x', 'sing-box'], @@ -115,14 +87,9 @@ class SingboxRunner: self._PID_FILE.unlink(missing_ok=True) return False except PermissionError: - # Proceso vivo pero de root — PermissionError es señal de vida return True def get_last_error(self) -> str: - """ - Lee las últimas 80 líneas del log via sudo para evitar - problemas de permisos (el log lo escribe root via wrapper). - """ try: result = subprocess.run( ['sudo', self._WRAPPER, '', 'log'], @@ -141,10 +108,6 @@ class SingboxRunner: return '' def write_config(self, config_path: Path, config: dict) -> None: - """ - Escribe el JSON de forma atómica con permisos 0o600. - Write en .tmp + rename — nunca deja un config a medias. - """ self._assert_config_safe(config_path) config_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) @@ -158,44 +121,82 @@ class SingboxRunner: tmp.unlink(missing_ok=True) raise - # ── Privado ─────────────────────────────────────────────────────────────── - def _ensure_log_file(self) -> None: - """ - Crea el log file con permisos del usuario actual si no existe. - Debe llamarse ANTES de que el wrapper lo tome como root. - """ self._LOG_FILE.parent.mkdir(parents=True, exist_ok=True) if not self._LOG_FILE.exists(): self._LOG_FILE.touch(mode=0o600) def _assert_config_safe(self, config_path: Path) -> None: - """ - Verifica que el config está dentro del directorio permitido. - Lanza ValueError si no — nunca continúa con path inseguro. - """ try: config_path.resolve().relative_to(self._CONFIG_DIR.resolve()) except ValueError: raise ValueError( - f'Config fuera del directorio permitido.\n' + f'Config outside the allowed directory.\n' f' Config: {config_path}\n' - f' Permitido: {self._CONFIG_DIR}' + f' Allowed: {self._CONFIG_DIR}' ) def _emergency_kill(self) -> None: - """ - Kill directo por PID file cuando el wrapper no responde. - Último recurso — no usa sudo. - """ if not self._PID_FILE.exists(): return + try: pid = int(self._PID_FILE.read_text().strip()) if pid > 0: - os.kill(pid, 9) - except (ValueError, ProcessLookupError, PermissionError, OSError): + subprocess.run( + ['sudo', 'kill', '-9', str(pid)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, + timeout=5, + ) + except (ValueError, OSError, subprocess.TimeoutExpired): pass finally: self._PID_FILE.unlink(missing_ok=True) + def _kill_orphans(self) -> None: + try: + result = subprocess.run( + ['pgrep', '-x', 'sing-box'], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + return + + pids = [ + int(p) + for p in result.stdout.strip().splitlines() + if p.strip().isdigit() + ] + + if not pids: + return + + subprocess.run( + ['sudo', self._WRAPPER, '', 'stop'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, + timeout=15, + ) + + deadline = time.monotonic() + 4.0 + + while time.monotonic() < deadline: + check = subprocess.run( + ['pgrep', '-x', 'sing-box'], + capture_output=True, + ) + + if check.returncode != 0: + return + + time.sleep(0.3) + + except (OSError, ValueError, subprocess.TimeoutExpired): + pass \ No newline at end of file -- 2.45.2 From 9372fe4c2683addf7dc8f55010cded1a62ad6f4e Mon Sep 17 00:00:00 2001 From: zenaku Date: Fri, 5 Jun 2026 18:45:13 -0500 Subject: [PATCH 41/51] remove .env.example --- .env.example | 6 ------ core/Constants.py | 39 ++++++++++++++++++++------------------- 2 files changed, 20 insertions(+), 25 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index d135e89..0000000 --- a/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -APP_ENV= -SP_API_BASE_URL_LOCAL= -SP_API_BASE_URL_PRODUCTION= - -# SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1 -# SP_API_BASE_URL_PRODUCTION=https://api.hydraveil.net/api/v1 diff --git a/core/Constants.py b/core/Constants.py index f45ffdd..1c7d4a0 100644 --- a/core/Constants.py +++ b/core/Constants.py @@ -1,26 +1,20 @@ from dataclasses import dataclass from typing import Final -from dotenv import load_dotenv import os -load_dotenv(os.path.join(os.path.dirname(__file__), '../.env')) - -_env = os.environ.get('APP_ENV', 'production') - -_sp_api_base_url = ( - os.environ.get('SP_API_BASE_URL_LOCAL', 'http://simplifiedprivacy.test/api/v1') - if _env == 'local' - else os.environ.get('SP_API_BASE_URL_PRODUCTION', 'https://api.hydraveil.net/api/v1') -) - @dataclass(frozen=True) class Constants: + TICKET_API_BASE_URL: Final[str] = os.environ.get( "TICKET_API_BASE_URL", "https://ticket.hydraveil.net" ) - SP_API_BASE_URL: Final[str] = _sp_api_base_url - PING_URL: Final[str] = os.environ.get('PING_URL', f'{_sp_api_base_url}/health') + SP_API_BASE_URL: Final[str] = os.environ.get( + 'SP_API_BASE_URL', 'https://fake.simplifiedprivacy.org/api/v1' + ) + PING_URL: Final[str] = os.environ.get( + 'PING_URL', 'https://fake.simplifiedprivacy.org/api/v1//health' + ) CONNECTION_RETRY_INTERVAL: Final[int] = int(os.environ.get('CONNECTION_RETRY_INTERVAL', '5')) MAX_CONNECTION_ATTEMPTS: Final[int] = int(os.environ.get('MAX_CONNECTION_ATTEMPTS', '2')) HV_CLIENT_PATH: Final[str] = os.environ.get('HV_CLIENT_PATH') @@ -29,13 +23,12 @@ class Constants: # ── Paths base ──────────────────────────────────────────────────────────── HOME: Final[str] = os.path.expanduser('~') SYSTEM_CONFIG_PATH: Final[str] = '/etc' - INSTALL_DIR: Final[str] = os.environ.get('INSTALL_DIR', os.path.expanduser('~')) - # ── XDG ─────────────────────────────────────────────────────────────────── - CACHE_HOME: Final[str] = os.environ.get('XDG_CACHE_HOME', f'{INSTALL_DIR}/.cache') - CONFIG_HOME: Final[str] = os.environ.get('XDG_CONFIG_HOME', f'{INSTALL_DIR}/.config') - DATA_HOME: Final[str] = os.environ.get('XDG_DATA_HOME', f'{INSTALL_DIR}/data') - STATE_HOME: Final[str] = os.environ.get('XDG_STATE_HOME', f'{INSTALL_DIR}/.state') + # ── XDG estándar ───────────────────────────────────────────────────────── + CACHE_HOME: Final[str] = os.environ.get('XDG_CACHE_HOME', os.path.join(HOME, '.cache')) + CONFIG_HOME: Final[str] = os.environ.get('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) + DATA_HOME: Final[str] = os.environ.get('XDG_DATA_HOME', os.path.join(HOME, '.local/share')) + STATE_HOME: Final[str] = os.environ.get('XDG_STATE_HOME', os.path.join(HOME, '.local/state')) # ── hydra-veil dirs ─────────────────────────────────────────────────────── HV_SYSTEM_CONFIG_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/hydra-veil' @@ -68,3 +61,11 @@ class Constants: SINGBOX_CONFIG_DIR: Final[str] = f'{HV_DATA_HOME}/configs' SINGBOX_PID_FILE: Final[str] = f'{HV_RUNTIME_DATA_HOME}/singbox.pid' SINGBOX_LOG_FILE: Final[str] = f'{HV_RUNTIME_DATA_HOME}/singbox.log' + + # ── killswitch / dns wrappers ───────────────────────────────────────────── + KILLSWITCH_WRAPPER: Final[str] = os.environ.get( + 'KILLSWITCH_WRAPPER', '/usr/local/bin/hydraveil-killswitch' + ) + RESOLVECTL_WRAPPER: Final[str] = os.environ.get( + 'RESOLVECTL_WRAPPER', '/usr/local/bin/hydraveil-resolvectl' + ) \ No newline at end of file -- 2.45.2 From 76d365097998dc4347d8d429a905815485a0ede9 Mon Sep 17 00:00:00 2001 From: zenaku Date: Sat, 6 Jun 2026 09:30:31 -0500 Subject: [PATCH 42/51] fix hysteria and vless bug --- .../encrypted_proxy/hysteria_service.py | 29 +++++++------------ .../services/encrypted_proxy/vless_service.py | 24 ++++----------- 2 files changed, 17 insertions(+), 36 deletions(-) diff --git a/core/services/encrypted_proxy/hysteria_service.py b/core/services/encrypted_proxy/hysteria_service.py index 4dbb430..e49f1fd 100644 --- a/core/services/encrypted_proxy/hysteria_service.py +++ b/core/services/encrypted_proxy/hysteria_service.py @@ -1,5 +1,4 @@ import socket -import subprocess import time from pathlib import Path from core.Constants import Constants @@ -7,24 +6,21 @@ from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner from core.utils.encrypted_proxy.dns import wait_for_tun, set_dns_on_tun, revert_dns_on_tun from core.utils.encrypted_proxy import killswitch -from core.services.encrypted_proxy.disable_service import get_public_ip + def _resolve(host: str) -> str: return socket.gethostbyname(host) + def build_hysteria_config(username: str, password: str, - server_host: str, socks5_port: int) -> dict: - server_ip = _resolve(server_host) + server_host: str, socks5_port: int, + server_ip: str = None) -> dict: + if server_ip is None: + server_ip = _resolve(server_host) return { "dns": { - "servers": [ - { - "tag": "tunnel-dns", - "type": "udp", - "server": "9.9.9.9", - }, - ], + "servers": [{"tag": "tunnel-dns", "type": "udp", "server": "9.9.9.9"}], "final": "tunnel-dns", "strategy": "ipv4_only", "independent_cache": True, @@ -83,17 +79,14 @@ def _cleanup_all() -> None: SingboxRunner().stop() except Exception: pass - try: revert_dns_on_tun() except Exception: pass - try: killswitch.disarm() except Exception: pass - time.sleep(1) @@ -113,7 +106,8 @@ def enable_hysteria(username: str, password: str, server_host: str, runner = SingboxRunner() runner.stop() config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json" - config = build_hysteria_config(username, password, server_host, socks5_port) + config = build_hysteria_config(username, password, server_host, socks5_port, + server_ip=server_ip) try: runner.write_config(config_path, config) @@ -156,12 +150,11 @@ def enable_hysteria(username: str, password: str, server_host: str, }) return True + def disable_hysteria(observer=None) -> bool: revert_dns_on_tun() SingboxRunner().stop() killswitch.disarm() - time.sleep(1) - ip = get_public_ip() if observer: - observer.notify("disconnected", {"ip": ip}) + observer.notify("disconnected", {}) return True \ No newline at end of file diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py index 517579e..9930d55 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -1,9 +1,7 @@ from urllib.parse import unquote from pathlib import Path import socket -import subprocess import time -from core.services.encrypted_proxy.disable_service import get_public_ip from core.Constants import Constants from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner @@ -36,18 +34,13 @@ def parse_vless_link(link: str) -> dict: } -def build_vless_config(vless: dict, socks5_port: int) -> dict: - server_ip = socket.gethostbyname(vless["host"]) +def build_vless_config(vless: dict, socks5_port: int, server_ip: str = None) -> dict: + if server_ip is None: + server_ip = socket.gethostbyname(vless["host"]) return { "dns": { - "servers": [ - { - "tag": "tunnel-dns", - "type": "udp", - "server": "9.9.9.9", - }, - ], + "servers": [{"tag": "tunnel-dns", "type": "udp", "server": "9.9.9.9"}], "final": "tunnel-dns", "strategy": "ipv4_only", "independent_cache": True, @@ -111,17 +104,14 @@ def _cleanup_all() -> None: SingboxRunner().stop() except Exception: pass - try: revert_dns_on_tun() except Exception: pass - try: killswitch.disarm() except Exception: pass - time.sleep(1) @@ -142,7 +132,7 @@ def enable_vless(vless_link: str, username: str, runner = SingboxRunner() runner.stop() config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json" - config = build_vless_config(vless, socks5_port) + config = build_vless_config(vless, socks5_port, server_ip=server_ip) try: runner.write_config(config_path, config) @@ -190,8 +180,6 @@ def disable_vless(observer=None) -> bool: revert_dns_on_tun() SingboxRunner().stop() killswitch.disarm() - time.sleep(1) - ip = get_public_ip() if observer: - observer.notify("disconnected", {"ip": ip}) + observer.notify("disconnected", {}) return True \ No newline at end of file -- 2.45.2 From c1fb1bc7c60e7ac29e890de926cedf3a0cf5124e Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 9 Jun 2026 09:09:10 -0500 Subject: [PATCH 43/51] fix errors kill s. --- core/Errors.py | 2 ++ core/controllers/ConnectionController.py | 4 +-- core/controllers/ProfileController.py | 32 ++++++++++++++++-------- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/core/Errors.py b/core/Errors.py index a0e6f1b..d4ee750 100644 --- a/core/Errors.py +++ b/core/Errors.py @@ -51,4 +51,6 @@ class FileIntegrityError(Exception): class EndpointVerificationError(Exception): pass class SingboxNotInstalledException(Exception): + pass +class PaymentRequiredError(Exception): pass \ No newline at end of file diff --git a/core/controllers/ConnectionController.py b/core/controllers/ConnectionController.py index 35ef893..623dbdf 100644 --- a/core/controllers/ConnectionController.py +++ b/core/controllers/ConnectionController.py @@ -1,6 +1,5 @@ from collections.abc import Callable from core.Constants import Constants -from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionUnprotectedError, ConnectionTerminationError, CommandNotFoundError from core.controllers.ConfigurationController import ConfigurationController from core.controllers.ProfileController import ProfileController from core.controllers.SessionStateController import SessionStateController @@ -13,6 +12,7 @@ from core.services.WebServiceApiService import WebServiceApiService from essentials.modules.TorModule import TorModule from essentials.services.ConnectionService import ConnectionService from pathlib import Path +from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionUnprotectedError, ConnectionTerminationError, CommandNotFoundError, PaymentRequiredError from subprocess import CalledProcessError from typing import Union, Optional, Any import os @@ -105,7 +105,7 @@ class ConnectionController: ) if operator_proxy_session is None: - raise InvalidSubscriptionError() + raise PaymentRequiredError() profile.attach_operator_proxy_session(operator_proxy_session) diff --git a/core/controllers/ProfileController.py b/core/controllers/ProfileController.py index 688837c..cade914 100644 --- a/core/controllers/ProfileController.py +++ b/core/controllers/ProfileController.py @@ -1,4 +1,4 @@ -from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionTerminationError, ProfileActivationError, ProfileDeactivationError, MissingLocationError, ConnectionUnprotectedError, EndpointVerificationError, ProfileStateConflictError +from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionTerminationError, ProfileActivationError, ProfileDeactivationError, MissingLocationError, ConnectionUnprotectedError, EndpointVerificationError, ProfileStateConflictError, PaymentRequiredError from core.controllers.ApplicationController import ApplicationController from core.controllers.ApplicationVersionController import ApplicationVersionController from core.controllers.SessionStateController import SessionStateController @@ -140,15 +140,27 @@ class ProfileController: from core.controllers.ConnectionController import ConnectionController if profile.has_subscription(): - - print(f"DEBUG activate_subscription:") - print(f" billing_code: {profile.subscription.billing_code}") - print(f" operator_id: {profile.subscription.operator_id}") - + + # Operator profiles (vless/hysteria2): check if already activated locally first. + # If not, poll the server — the payment may have been processed since last attempt. + if profile.subscription.operator_id is not None: + if profile.subscription.has_been_activated(): + profile.save() + return + subscription = ConnectionController.with_preferred_connection( + profile.subscription.billing_code, + task=WebServiceApiService.get_subscription, + connection_observer=connection_observer + ) + if subscription is not None: + profile.subscription = subscription + profile.save() + return + else: + raise PaymentRequiredError() + subscription = ConnectionController.with_preferred_connection(profile.subscription.billing_code, task=WebServiceApiService.get_subscription, connection_observer=connection_observer) - print(f" get_subscription returned: {subscription}") - if subscription is not None: profile.subscription = subscription profile.save() @@ -157,7 +169,7 @@ class ProfileController: else: raise MissingSubscriptionError() - + @staticmethod def is_enabled(profile: Union[SessionProfile, SystemProfile]): @@ -299,4 +311,4 @@ class ProfileController: return dict( private=base64.b64encode(private_key).decode(), public=base64.b64encode(public_key).decode() - ) + ) \ No newline at end of file -- 2.45.2 From d6b122b975ed12ad31f85a3f90cd10c8e13d9d72 Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 16 Jun 2026 08:11:28 -0500 Subject: [PATCH 44/51] June Major Update --- README.md | 66 ++++++++++- core/Constants.py | 10 +- core/controllers/ConnectionController.py | 58 ++++++++-- core/controllers/SystemStateController.py | 9 +- .../encrypted_proxy/HysteriaController.py | 7 +- .../encrypted_proxy/VlessController.py | 7 +- core/models/OperatorProxySession.py | 3 +- core/models/system/SystemState.py | 9 +- .../encrypted_proxy/hysteria_service.py | 105 ++++++++---------- .../services/encrypted_proxy/vless_service.py | 96 +++++++--------- core/utils/encrypted_proxy/dns.py | 37 ++++-- core/utils/encrypted_proxy/killswitch.py | 17 +-- 12 files changed, 274 insertions(+), 150 deletions(-) diff --git a/README.md b/README.md index ec54fb4..d66d748 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,68 @@ -# hydra-veil — usage +# Hydra-Veil — June Major Update + +## Expanded Killswitch + +`hydraveil-killswitch.sh` was added to the installer: a wrapper around `nftables` that blocks all traffic not routed through the tunnel. It defaults to `tun0`, but supports a configurable interface IP that the operator will send when creating the profile. The IP is currently hardcoded; it will be removed in the next commit. + +## One Connection at a Time + +First in, first out. When a new connection is established, the previous one receives a signal (`SIGUSR1`) and closes cleanly before the new one takes over. + +## Unexpected Shutdowns + +If the terminal is closed or the machine is powered off, the process receives `SIGHUP` and performs a full disconnect: stops `sing-box`, clears state, and releases the network. No orphan processes. + +## Fewer Network Requests + +Unnecessary calls made when establishing and closing connections have been removed. + +--- + +## Repositories + +| Repo | Role | +|---|---| +| `core` | Business logic, controllers, models | +| `cli` | Command-line interface | +| `essentials` | Network modules (Tor, WireGuard, proxies) | +| `installer` | Installation scripts and killswitch | + +--- +--- + +# Hydra-Veil — Gran Actualización de Junio + +## Killswitch ampliado + +Se añadió `hydraveil-killswitch.sh` al installer: un wrapper sobre `nftables` que bloquea todo el tráfico que no pase por el túnel. Por defecto usa `tun0`, pero soporta una IP de interfaz configurable que el operador enviará al crear el perfil. Por ahora la IP está hardcodeada; se eliminará en el próximo commit. + +## Una conexión a la vez + +Primero en entrar, primero en salir. Cuando se establece una nueva conexión, la anterior recibe una señal (`SIGUSR1`) y se cierra limpiamente antes de que la nueva tome el control. + +## Cierres inesperados + +Si se cierra la terminal o se apaga el equipo, el proceso recibe `SIGHUP` y ejecuta la desconexión completa: para `sing-box`, limpia el estado y libera la red. Sin procesos huérfanos. + +## Menos solicitudes de red + +Se eliminaron llamadas innecesarias que se hacían al establecer y cerrar conexiones. + +--- + +## Repositorios + +| Repo | Rol | +|---|---| +| `core` | Lógica de negocio, controladores, modelos | +| `cli` | Interfaz de línea de comandos | +| `essentials` | Módulos de red (Tor, WireGuard, proxies) | +| `installer` | Scripts de instalación y killswitch | + +--- +--- + +## Uso rápido — Desde Core (innecesario, usar CLI) ## 1. Create billing code / Crear billing code diff --git a/core/Constants.py b/core/Constants.py index 1c7d4a0..a6e755a 100644 --- a/core/Constants.py +++ b/core/Constants.py @@ -51,7 +51,7 @@ class Constants: HV_SESSION_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/sessions' HV_TOR_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/tor' - # ── sing-box ────────────────────────────────────────────────────────────── +# ── sing-box ────────────────────────────────────────────────────────────── SINGBOX_WRAPPER: Final[str] = os.environ.get( 'SINGBOX_WRAPPER', '/usr/local/bin/hydraveil-singbox' ) @@ -61,6 +61,9 @@ class Constants: SINGBOX_CONFIG_DIR: Final[str] = f'{HV_DATA_HOME}/configs' SINGBOX_PID_FILE: Final[str] = f'{HV_RUNTIME_DATA_HOME}/singbox.pid' SINGBOX_LOG_FILE: Final[str] = f'{HV_RUNTIME_DATA_HOME}/singbox.log' + SINGBOX_TUN_IF: Final[str] = os.environ.get('SINGBOX_TUN_IF', 'tun0') + SINGBOX_INTERNAL_SUBNET: Final[str] = os.environ.get('SINGBOX_INTERNAL_SUBNET', '172.19.0.0/30') + SINGBOX_INTERNAL_ADDR: Final[str] = os.environ.get('SINGBOX_INTERNAL_ADDR', '172.19.0.1/30') # ── killswitch / dns wrappers ───────────────────────────────────────────── KILLSWITCH_WRAPPER: Final[str] = os.environ.get( @@ -68,4 +71,7 @@ class Constants: ) RESOLVECTL_WRAPPER: Final[str] = os.environ.get( 'RESOLVECTL_WRAPPER', '/usr/local/bin/hydraveil-resolvectl' - ) \ No newline at end of file + ) + + VLESS_DNS_ENABLED: Final[bool] = os.environ.get('VLESS_DNS_ENABLED', 'true').lower() == 'true' + HYSTERIA2_DNS_ENABLED: Final[bool] = os.environ.get('HYSTERIA2_DNS_ENABLED', 'true').lower() == 'true' \ No newline at end of file diff --git a/core/controllers/ConnectionController.py b/core/controllers/ConnectionController.py index 623dbdf..a6cedb5 100644 --- a/core/controllers/ConnectionController.py +++ b/core/controllers/ConnectionController.py @@ -19,6 +19,7 @@ import os import random import re import shutil +import signal import subprocess import sys import tempfile @@ -143,6 +144,7 @@ class ConnectionController: raise ConnectionError('The connection could not be established.') return None + @staticmethod def establish_session_connection(profile: SessionProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None): @@ -208,8 +210,32 @@ class ConnectionController: SessionStateController.update_or_create(session_state) return proxy_port_number or port_number + + @staticmethod + def _signal_previous_process() -> None: + + system_state = SystemStateController.get() + if system_state is None: + return + pid = system_state.pid + if pid is None or pid == os.getpid(): + return + try: + os.kill(pid, signal.SIGUSR1) + time.sleep(0.5) + except (ProcessLookupError, PermissionError): + pass + @staticmethod def establish_system_connection(profile: SystemProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None): + ConnectionController._signal_previous_process() + + system_state = SystemStateController.get() + if system_state is not None: + try: + ConnectionController.terminate_system_connection(connection_observer=connection_observer) + except ConnectionTerminationError: + pass if profile.connection.needs_operator_proxy(): operator_proxy_session = profile.get_operator_proxy_session() @@ -217,17 +243,29 @@ class ConnectionController: if protocol == 'vless': from core.controllers.encrypted_proxy.VlessController import VlessController + from core.services.encrypted_proxy.vless_service import parse_vless_link + import socket + server_ip = operator_proxy_session.server_ip + if server_ip is None: + vless = parse_vless_link(operator_proxy_session.links[0]) + server_ip = socket.gethostbyname(vless['host']) ok = VlessController(1080).enable( operator_proxy_session.links[0], operator_proxy_session.username, + server_ip, connection_observer ) elif protocol == 'hysteria2': from core.controllers.encrypted_proxy.HysteriaController import HysteriaController + import socket + server_ip = operator_proxy_session.server_ip + if server_ip is None: + server_ip = socket.gethostbyname(operator_proxy_session.operator_hysteria2_host) ok = HysteriaController(1080).enable( operator_proxy_session.username, operator_proxy_session.password, operator_proxy_session.operator_hysteria2_host, + server_ip, connection_observer ) else: @@ -236,8 +274,11 @@ class ConnectionController: if not ok: raise ConnectionError('The connection could not be established.') - SystemStateController.create(profile.id) + token = SystemStateController.create(profile.id) + if connection_observer is not None: + connection_observer.notify('connected_token', {'session_token': token}) return + if ConfigurationController.get_endpoint_verification_enabled(): ProfileController.verify_wireguard_endpoint(profile, ignore=ignore) @@ -267,6 +308,7 @@ class ConnectionController: ConnectionController.terminate_tor_connection() time.sleep(1.0) + @staticmethod def establish_tor_connection(connection_observer: Optional[ConnectionObserver] = None): @@ -484,7 +526,9 @@ class ConnectionController: except CalledProcessError: raise ConnectionError('The connection could not be established.') - SystemStateController.create(profile.id) + token = SystemStateController.create(profile.id) + if connection_observer is not None: + connection_observer.notify('connected_token', {'session_token': token}) try: ConnectionController.await_connection(connection_observer=connection_observer) @@ -557,22 +601,22 @@ class ConnectionController: return True return False - + @staticmethod def verify_singbox_installation(): import subprocess import os from core.Errors import SingboxNotInstalledException - + singbox_binary = '/usr/bin/sing-box' singbox_wrapper = '/usr/local/bin/hydraveil-singbox' - + if not os.path.exists(singbox_binary): raise SingboxNotInstalledException(f'sing-box binary not found at {singbox_binary}') - + if not os.path.exists(singbox_wrapper): raise SingboxNotInstalledException(f'sing-box wrapper not found at {singbox_wrapper}') - + try: subprocess.run([singbox_binary, 'version'], capture_output=True, timeout=5, check=True) except subprocess.CalledProcessError: diff --git a/core/controllers/SystemStateController.py b/core/controllers/SystemStateController.py index 18bb459..e5571c2 100644 --- a/core/controllers/SystemStateController.py +++ b/core/controllers/SystemStateController.py @@ -1,3 +1,5 @@ +import os +import uuid from core.models.system.SystemState import SystemState @@ -13,7 +15,10 @@ class SystemStateController: @staticmethod def create(profile_id): - return SystemState(profile_id).save() + token = str(uuid.uuid4()) + pid = os.getpid() + SystemState(profile_id, token, pid).save() + return token @staticmethod def update_or_create(system_state): @@ -21,4 +26,4 @@ class SystemStateController: @staticmethod def dissolve(): - return SystemState.dissolve() + return SystemState.dissolve() \ No newline at end of file diff --git a/core/controllers/encrypted_proxy/HysteriaController.py b/core/controllers/encrypted_proxy/HysteriaController.py index 233c282..5521ccb 100644 --- a/core/controllers/encrypted_proxy/HysteriaController.py +++ b/core/controllers/encrypted_proxy/HysteriaController.py @@ -5,7 +5,7 @@ class HysteriaController: self.socks5_port = socks5_port def enable(self, username: str, password: str, - server_host: str, observer=None) -> bool: + server_host: str, server_ip: str, observer=None) -> bool: if not username or not isinstance(username, str): if observer: observer.notify("error", "Invalid username") @@ -14,10 +14,15 @@ class HysteriaController: if observer: observer.notify("error", "Missing password or server_host") return False + if not server_ip: + if observer: + observer.notify("error", "Missing server_ip") + return False return enable_hysteria( username=username, password=password, server_host=server_host, + server_ip=server_ip, socks5_port=self.socks5_port, observer=observer, ) diff --git a/core/controllers/encrypted_proxy/VlessController.py b/core/controllers/encrypted_proxy/VlessController.py index a969cef..57f7f3e 100644 --- a/core/controllers/encrypted_proxy/VlessController.py +++ b/core/controllers/encrypted_proxy/VlessController.py @@ -4,7 +4,7 @@ class VlessController: def __init__(self, socks5_port: int): self.socks5_port = socks5_port - def enable(self, vless_link: str, username: str, observer=None) -> bool: + def enable(self, vless_link: str, username: str, server_ip: str, observer=None) -> bool: if not username or not isinstance(username, str): if observer: observer.notify("error", "Invalid username") @@ -13,9 +13,14 @@ class VlessController: if observer: observer.notify("error", "Invalid vless link") return False + if not server_ip: + if observer: + observer.notify("error", "Missing server_ip") + return False return enable_vless( vless_link=vless_link, username=username, + server_ip=server_ip, socks5_port=self.socks5_port, observer=observer, ) diff --git a/core/models/OperatorProxySession.py b/core/models/OperatorProxySession.py index 5bdfa6f..065d18d 100644 --- a/core/models/OperatorProxySession.py +++ b/core/models/OperatorProxySession.py @@ -16,4 +16,5 @@ class OperatorProxySession: operator_name: str operator_domain: Optional[str] = None operator_hysteria2_host: Optional[str] = None - operator_vless_host: Optional[str] = None \ No newline at end of file + operator_vless_host: Optional[str] = None + server_ip: Optional[str] = None # pre-resolved IP — avoids DNS leak at connect time \ No newline at end of file diff --git a/core/models/system/SystemState.py b/core/models/system/SystemState.py index 2187faa..d90efdc 100644 --- a/core/models/system/SystemState.py +++ b/core/models/system/SystemState.py @@ -1,8 +1,8 @@ from core.Constants import Constants -from dataclasses import dataclass +from dataclasses import dataclass, field from dataclasses_json import dataclass_json from pathlib import Path -from typing import Self +from typing import Optional, Self import json import os import pathlib @@ -11,6 +11,8 @@ import pathlib @dataclass class SystemState: profile_id: int + session_token: Optional[str] = field(default=None) + pid: Optional[int] = field(default=None) def save(self: Self): @@ -31,7 +33,6 @@ class SystemState: system_state_file_contents = open(f'{SystemState.__get_state_path()}/system.json', 'r').read() system_state_dict = json.loads(system_state_file_contents) - # noinspection PyUnresolvedReferences return SystemState.from_dict(system_state_dict) except (FileNotFoundError, ValueError, KeyError): @@ -53,4 +54,4 @@ class SystemState: @staticmethod def __get_state_path(): - return Constants.HV_STATE_HOME + return Constants.HV_STATE_HOME \ No newline at end of file diff --git a/core/services/encrypted_proxy/hysteria_service.py b/core/services/encrypted_proxy/hysteria_service.py index e49f1fd..f93a513 100644 --- a/core/services/encrypted_proxy/hysteria_service.py +++ b/core/services/encrypted_proxy/hysteria_service.py @@ -1,23 +1,14 @@ -import socket import time from pathlib import Path from core.Constants import Constants -from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner from core.utils.encrypted_proxy.dns import wait_for_tun, set_dns_on_tun, revert_dns_on_tun from core.utils.encrypted_proxy import killswitch -def _resolve(host: str) -> str: - return socket.gethostbyname(host) - - def build_hysteria_config(username: str, password: str, server_host: str, socks5_port: int, - server_ip: str = None) -> dict: - if server_ip is None: - server_ip = _resolve(server_host) - + server_ip: str) -> dict: return { "dns": { "servers": [{"tag": "tunnel-dns", "type": "udp", "server": "9.9.9.9"}], @@ -29,8 +20,8 @@ def build_hysteria_config(username: str, password: str, { "type": "tun", "tag": "tun-in", - "interface_name": "tun0", - "address": ["172.19.0.1/30"], + "interface_name": Constants.SINGBOX_TUN_IF, + "address": [Constants.SINGBOX_INTERNAL_ADDR], "mtu": 9000, "auto_route": True, "stack": "gvisor", @@ -61,7 +52,7 @@ def build_hysteria_config(username: str, password: str, "route": { "rules": [ {"protocol": "dns", "action": "hijack-dns"}, - {"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"}, + {"ip_cidr": [Constants.SINGBOX_INTERNAL_SUBNET], "action": "hijack-dns"}, {"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"}, {"ip_is_private": True, "outbound": "direct"}, {"ip_version": 6, "outbound": "block"}, @@ -91,64 +82,58 @@ def _cleanup_all() -> None: def enable_hysteria(username: str, password: str, server_host: str, - socks5_port: int, observer=None) -> bool: + server_ip: str, socks5_port: int, observer=None) -> bool: _cleanup_all() - server_ip = _resolve(server_host) - real_ip = get_real_ip() - - if not killswitch.arm(server_ip): - if observer: - observer.notify("error", "Failed to arm kill switch") - return False - - runner = SingboxRunner() - runner.stop() + runner = SingboxRunner() config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json" - config = build_hysteria_config(username, password, server_host, socks5_port, - server_ip=server_ip) + config = build_hysteria_config(username, password, server_host, + socks5_port, server_ip) + _connected = False try: + # Fase 1 — arrancar sing-box runner.write_config(config_path, config) - ok = runner.start(config_path) + if not runner.start(config_path): + if observer: + observer.notify("error", "sing-box not active after start") + return False + + # Fase 2 — esperar a que tun aparezca + if not wait_for_tun(timeout=15.0): + if observer: + observer.notify("error", f"{Constants.SINGBOX_TUN_IF} did not appear after 15s") + return False + + # Fase 3 — armar kill switch ahora que la interfaz existe + if not killswitch.arm(server_ip, Constants.SINGBOX_TUN_IF): + if observer: + observer.notify("error", "Failed to arm kill switch") + return False + + # Fase 4 — DNS + _C = Constants() + dns_ok = set_dns_on_tun() if _C.HYSTERIA2_DNS_ENABLED else False + + _connected = True + if observer: + observer.notify("connected", { + "tunnel_if": Constants.SINGBOX_TUN_IF, + "socks5_port": socks5_port, + "dns_enabled": _C.HYSTERIA2_DNS_ENABLED, + "dns_active": dns_ok, + }) + return True + except Exception as e: - killswitch.disarm() if observer: observer.notify("error", str(e)) return False - if not ok: - killswitch.disarm() - if observer: - observer.notify("error", "sing-box not active after start") - return False - - if not wait_for_tun(timeout=15.0): - killswitch.disarm() - runner.stop() - if observer: - observer.notify("error", "tun0 did not appear after 15s") - return False - - set_dns_on_tun() - - proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0) - if not proxy_ip or proxy_ip == "unknown" or proxy_ip == real_ip: - killswitch.disarm() - revert_dns_on_tun() - runner.stop() - if observer: - observer.notify("error", "IP did not change — possible leak") - return False - - if observer: - observer.notify("connected", { - "real_ip": real_ip, - "proxy_ip": proxy_ip, - "socks5_port": socks5_port, - }) - return True + finally: + if not _connected: + _cleanup_all() def disable_hysteria(observer=None) -> bool: @@ -156,5 +141,5 @@ def disable_hysteria(observer=None) -> bool: SingboxRunner().stop() killswitch.disarm() if observer: - observer.notify("disconnected", {}) + observer.notify("disconnected", {"tunnel_if": Constants.SINGBOX_TUN_IF}) return True \ No newline at end of file diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py index 9930d55..0cdfa96 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -3,7 +3,6 @@ from pathlib import Path import socket import time from core.Constants import Constants -from core.utils.encrypted_proxy.net import get_real_ip, verify_ip from core.utils.encrypted_proxy.singbox import SingboxRunner from core.utils.encrypted_proxy.dns import wait_for_tun, set_dns_on_tun, revert_dns_on_tun from core.utils.encrypted_proxy import killswitch @@ -34,10 +33,7 @@ def parse_vless_link(link: str) -> dict: } -def build_vless_config(vless: dict, socks5_port: int, server_ip: str = None) -> dict: - if server_ip is None: - server_ip = socket.gethostbyname(vless["host"]) - +def build_vless_config(vless: dict, socks5_port: int, server_ip: str) -> dict: return { "dns": { "servers": [{"tag": "tunnel-dns", "type": "udp", "server": "9.9.9.9"}], @@ -49,8 +45,8 @@ def build_vless_config(vless: dict, socks5_port: int, server_ip: str = None) -> { "type": "tun", "tag": "tun-in", - "interface_name": "tun0", - "address": ["172.19.0.1/30"], + "interface_name": Constants.SINGBOX_TUN_IF, + "address": [Constants.SINGBOX_INTERNAL_ADDR], "mtu": 9000, "auto_route": True, "stack": "gvisor", @@ -86,7 +82,7 @@ def build_vless_config(vless: dict, socks5_port: int, server_ip: str = None) -> "route": { "rules": [ {"protocol": "dns", "action": "hijack-dns"}, - {"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"}, + {"ip_cidr": [Constants.SINGBOX_INTERNAL_SUBNET], "action": "hijack-dns"}, {"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"}, {"ip_is_private": True, "outbound": "direct"}, {"ip_version": 6, "outbound": "block"}, @@ -115,65 +111,55 @@ def _cleanup_all() -> None: time.sleep(1) -def enable_vless(vless_link: str, username: str, +def enable_vless(vless_link: str, username: str, server_ip: str, socks5_port: int, observer=None) -> bool: _cleanup_all() - vless = parse_vless_link(vless_link) - server_ip = socket.gethostbyname(vless["host"]) - real_ip = get_real_ip() - - if not killswitch.arm(server_ip): - if observer: - observer.notify("error", "Failed to arm kill switch") - return False - - runner = SingboxRunner() - runner.stop() + vless = parse_vless_link(vless_link) + runner = SingboxRunner() config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json" - config = build_vless_config(vless, socks5_port, server_ip=server_ip) + config = build_vless_config(vless, socks5_port, server_ip) + _connected = False try: runner.write_config(config_path, config) - ok = runner.start(config_path) + if not runner.start(config_path): + if observer: + observer.notify("error", "sing-box not active after start") + return False + + if not wait_for_tun(timeout=15.0): + if observer: + observer.notify("error", f"{Constants.SINGBOX_TUN_IF} did not appear after 15s") + return False + + if not killswitch.arm(server_ip, Constants.SINGBOX_TUN_IF): + if observer: + observer.notify("error", "Failed to arm kill switch") + return False + + _C = Constants() + dns_ok = set_dns_on_tun() if _C.VLESS_DNS_ENABLED else False + + _connected = True + if observer: + observer.notify("connected", { + "tunnel_if": Constants.SINGBOX_TUN_IF, + "socks5_port": socks5_port, + "dns_enabled": _C.VLESS_DNS_ENABLED, + "dns_active": dns_ok, + }) + return True + except Exception as e: - killswitch.disarm() if observer: observer.notify("error", str(e)) return False - if not ok: - killswitch.disarm() - if observer: - observer.notify("error", "sing-box not active after start") - return False - - if not wait_for_tun(timeout=15.0): - killswitch.disarm() - runner.stop() - if observer: - observer.notify("error", "tun0 did not appear after 15s") - return False - - set_dns_on_tun() - - proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0) - if not proxy_ip or proxy_ip == "unknown" or proxy_ip == real_ip: - killswitch.disarm() - revert_dns_on_tun() - runner.stop() - if observer: - observer.notify("error", "IP did not change — possible leak") - return False - - if observer: - observer.notify("connected", { - "real_ip": real_ip, - "proxy_ip": proxy_ip, - "socks5_port": socks5_port, - }) - return True + finally: + if not _connected: + _cleanup_all() def disable_vless(observer=None) -> bool: @@ -181,5 +167,5 @@ def disable_vless(observer=None) -> bool: SingboxRunner().stop() killswitch.disarm() if observer: - observer.notify("disconnected", {}) + observer.notify("disconnected", {"tunnel_if": Constants.SINGBOX_TUN_IF}) return True \ No newline at end of file diff --git a/core/utils/encrypted_proxy/dns.py b/core/utils/encrypted_proxy/dns.py index 7b122b0..035939e 100644 --- a/core/utils/encrypted_proxy/dns.py +++ b/core/utils/encrypted_proxy/dns.py @@ -1,10 +1,9 @@ import os import subprocess import time +from core.Constants import Constants -RESOLVECTL_WRAPPER = "/usr/local/bin/hydraveil-resolvectl" -TUN_IFACE = "tun0" -TUN_DNS = "9.9.9.9" +_C = Constants() _SUDO_KW = dict( stdout=subprocess.DEVNULL, @@ -15,11 +14,24 @@ _SUDO_KW = dict( ) +def is_resolved_active() -> bool: + try: + result = subprocess.run( + ["systemctl", "is-active", "systemd-resolved"], + capture_output=True, + text=True, + timeout=5, + ) + return result.stdout.strip() == "active" + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def wait_for_tun(timeout: float = 15.0, interval: float = 0.5) -> bool: elapsed = 0.0 while elapsed < timeout: result = subprocess.run( - ["ip", "link", "show", TUN_IFACE], + ["ip", "link", "show", _C.SINGBOX_TUN_IF], capture_output=True, ) if result.returncode == 0: @@ -30,22 +42,31 @@ def wait_for_tun(timeout: float = 15.0, interval: float = 0.5) -> bool: def set_dns_on_tun() -> bool: + + if not is_resolved_active(): + print("[DNS] systemd-resolved is not active — DNS on tun skipped") + return False + result = subprocess.run( - ["sudo", RESOLVECTL_WRAPPER, "set", TUN_DNS], + ["sudo", _C.RESOLVECTL_WRAPPER, "set", "9.9.9.9"], **_SUDO_KW, ) if result.returncode != 0: - print(f"[DNS] Failed to configure tun0: {result.stderr.strip()}") + print(f"[DNS] Failed to configure {_C.SINGBOX_TUN_IF}: {result.stderr.strip()}") return False return True def revert_dns_on_tun() -> bool: + + if not is_resolved_active(): + return True + result = subprocess.run( - ["sudo", RESOLVECTL_WRAPPER, "revert"], + ["sudo", _C.RESOLVECTL_WRAPPER, "revert"], **_SUDO_KW, ) if result.returncode != 0: - print(f"[DNS] Failed to revert tun0: {result.stderr.strip()}") + print(f"[DNS] Failed to revert {_C.SINGBOX_TUN_IF}: {result.stderr.strip()}") return False return True \ No newline at end of file diff --git a/core/utils/encrypted_proxy/killswitch.py b/core/utils/encrypted_proxy/killswitch.py index 09235bb..3629976 100644 --- a/core/utils/encrypted_proxy/killswitch.py +++ b/core/utils/encrypted_proxy/killswitch.py @@ -1,13 +1,14 @@ import subprocess +from core.Constants import Constants -KILLSWITCH_WRAPPER = "/usr/local/bin/hydraveil-killswitch" +_C = Constants() -def arm(server_ip: str) -> bool: +def arm(server_ip: str, tunnel_if: str) -> bool: result = subprocess.run( - ["sudo", KILLSWITCH_WRAPPER, "arm", server_ip], + ["sudo", _C.KILLSWITCH_WRAPPER, "arm", server_ip, tunnel_if], capture_output=True, - text=True + text=True, ) if result.returncode != 0: print(f"[killswitch] Failed to arm: {result.stderr.strip()}") @@ -17,9 +18,9 @@ def arm(server_ip: str) -> bool: def disarm() -> bool: result = subprocess.run( - ["sudo", KILLSWITCH_WRAPPER, "disarm"], + ["sudo", _C.KILLSWITCH_WRAPPER, "disarm"], capture_output=True, - text=True + text=True, ) if result.returncode != 0: print(f"[killswitch] Failed to disarm: {result.stderr.strip()}") @@ -29,8 +30,8 @@ def disarm() -> bool: def status() -> bool: result = subprocess.run( - ["sudo", KILLSWITCH_WRAPPER, "status"], + ["sudo", _C.KILLSWITCH_WRAPPER, "status"], capture_output=True, - text=True + text=True, ) return result.returncode == 0 and result.stdout.strip() == "armed" \ No newline at end of file -- 2.45.2 From 7dca2744ced48440549786bb296015075b56d32c Mon Sep 17 00:00:00 2001 From: zenaku Date: Fri, 19 Jun 2026 06:42:08 -0500 Subject: [PATCH 45/51] small fix --- core/services/WebServiceApiService.py | 1 + core/services/encrypted_proxy/hysteria_service.py | 4 +--- core/services/encrypted_proxy/vless_service.py | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/services/WebServiceApiService.py b/core/services/WebServiceApiService.py index 5579b97..c810fb2 100644 --- a/core/services/WebServiceApiService.py +++ b/core/services/WebServiceApiService.py @@ -180,6 +180,7 @@ class WebServiceApiService: data['operator'].get('domain'), data['operator'].get('hysteria2_host'), data['operator'].get('vless_host'), + data.get('server_ip'), ) return None diff --git a/core/services/encrypted_proxy/hysteria_service.py b/core/services/encrypted_proxy/hysteria_service.py index f93a513..44e30a0 100644 --- a/core/services/encrypted_proxy/hysteria_service.py +++ b/core/services/encrypted_proxy/hysteria_service.py @@ -93,20 +93,17 @@ def enable_hysteria(username: str, password: str, server_host: str, _connected = False try: - # Fase 1 — arrancar sing-box runner.write_config(config_path, config) if not runner.start(config_path): if observer: observer.notify("error", "sing-box not active after start") return False - # Fase 2 — esperar a que tun aparezca if not wait_for_tun(timeout=15.0): if observer: observer.notify("error", f"{Constants.SINGBOX_TUN_IF} did not appear after 15s") return False - # Fase 3 — armar kill switch ahora que la interfaz existe if not killswitch.arm(server_ip, Constants.SINGBOX_TUN_IF): if observer: observer.notify("error", "Failed to arm kill switch") @@ -121,6 +118,7 @@ def enable_hysteria(username: str, password: str, server_host: str, observer.notify("connected", { "tunnel_if": Constants.SINGBOX_TUN_IF, "socks5_port": socks5_port, + "server_ip": server_ip, "dns_enabled": _C.HYSTERIA2_DNS_ENABLED, "dns_active": dns_ok, }) diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py index 0cdfa96..3980db0 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -147,6 +147,7 @@ def enable_vless(vless_link: str, username: str, server_ip: str, observer.notify("connected", { "tunnel_if": Constants.SINGBOX_TUN_IF, "socks5_port": socks5_port, + "server_ip": server_ip, "dns_enabled": _C.VLESS_DNS_ENABLED, "dns_active": dns_ok, }) -- 2.45.2 From aca954d3642cfa2e6cac7b56c3c3a0fb9cf42180 Mon Sep 17 00:00:00 2001 From: zenaku Date: Sat, 20 Jun 2026 08:43:43 -0500 Subject: [PATCH 46/51] small fix 20-06-26 --- core/utils/encrypted_proxy/singbox.py | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/core/utils/encrypted_proxy/singbox.py b/core/utils/encrypted_proxy/singbox.py index 784e689..3589ade 100644 --- a/core/utils/encrypted_proxy/singbox.py +++ b/core/utils/encrypted_proxy/singbox.py @@ -198,5 +198,61 @@ class SingboxRunner: time.sleep(0.3) + for pid in pids: + try: + subprocess.run( + ['sudo', 'kill', '-9', str(pid)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + pass + + except (OSError, ValueError, subprocess.TimeoutExpired): + pass + try: + result = subprocess.run( + ['pgrep', '-x', 'sing-box'], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + return + + pids = [ + int(p) + for p in result.stdout.strip().splitlines() + if p.strip().isdigit() + ] + + if not pids: + return + + subprocess.run( + ['sudo', self._WRAPPER, '', 'stop'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, + timeout=15, + ) + + deadline = time.monotonic() + 4.0 + + while time.monotonic() < deadline: + check = subprocess.run( + ['pgrep', '-x', 'sing-box'], + capture_output=True, + ) + + if check.returncode != 0: + return + + time.sleep(0.3) + except (OSError, ValueError, subprocess.TimeoutExpired): pass \ No newline at end of file -- 2.45.2 From 999ddbf855b96552536f15a860fe8844a1e48f98 Mon Sep 17 00:00:00 2001 From: zenaku Date: Sun, 5 Jul 2026 06:17:20 -0500 Subject: [PATCH 47/51] Add location_country_code/city_code to operator proxy flow + SingboxProcessMonitor ABC + observer_helpers --- core/models/OperatorProxySession.py | 4 +- core/models/system/SystemProfile.py | 17 ++ core/services/WebServiceApiService.py | 2 + .../encrypted_proxy/observer_helpers.py | 185 ++++++++++++ core/utils/encrypted_proxy/singbox.py | 278 +++++++++++------- 5 files changed, 376 insertions(+), 110 deletions(-) create mode 100644 core/services/encrypted_proxy/observer_helpers.py diff --git a/core/models/OperatorProxySession.py b/core/models/OperatorProxySession.py index 065d18d..da7e628 100644 --- a/core/models/OperatorProxySession.py +++ b/core/models/OperatorProxySession.py @@ -17,4 +17,6 @@ class OperatorProxySession: operator_domain: Optional[str] = None operator_hysteria2_host: Optional[str] = None operator_vless_host: Optional[str] = None - server_ip: Optional[str] = None # pre-resolved IP — avoids DNS leak at connect time \ No newline at end of file + server_ip: Optional[str] = None # pre-resolved IP — avoids DNS leak at connect time + location_country_code: Optional[str] = None + location_city_code: Optional[str] = None \ No newline at end of file diff --git a/core/models/system/SystemProfile.py b/core/models/system/SystemProfile.py index db86ea3..0a1de60 100644 --- a/core/models/system/SystemProfile.py +++ b/core/models/system/SystemProfile.py @@ -79,6 +79,23 @@ class SystemProfile(BaseProfile): operator_proxy_session_file_path = self.get_operator_proxy_session_path() with open(operator_proxy_session_file_path, 'w') as operator_proxy_session_file: operator_proxy_session_file.write(operator_proxy_session_file_contents) + if operator_proxy_session.location_country_code and operator_proxy_session.location_city_code: + try: + from core.models.orm_models.Location import Location + from core.models.manage.session_management import get_session + from sqlalchemy import select + session = get_session() + loc = session.execute( + select(Location).where( + (Location.country_code == operator_proxy_session.location_country_code) & + (Location.code == operator_proxy_session.location_city_code) + ) + ).scalar_one_or_none() + if loc: + self.location = loc + self.save() + except Exception: + pass def get_operator_proxy_session_path(self): return f'{self.get_config_path()}/operator_proxy_session.json' diff --git a/core/services/WebServiceApiService.py b/core/services/WebServiceApiService.py index 70a0b2d..484f8e3 100644 --- a/core/services/WebServiceApiService.py +++ b/core/services/WebServiceApiService.py @@ -181,6 +181,8 @@ class WebServiceApiService: data['operator'].get('hysteria2_host'), data['operator'].get('vless_host'), data.get('server_ip'), + data.get('location_country_code'), + data.get('location_city_code'), ) return None diff --git a/core/services/encrypted_proxy/observer_helpers.py b/core/services/encrypted_proxy/observer_helpers.py new file mode 100644 index 0000000..9b4d458 --- /dev/null +++ b/core/services/encrypted_proxy/observer_helpers.py @@ -0,0 +1,185 @@ +from core.errors.logger import logger +from core.utils.encrypted_proxy import killswitch + +from typing import Optional, Dict, Any +from dataclasses import dataclass + + +@dataclass +class ConnectionStateData: + tunnel_interface: str = "?" + server_ip: Optional[str] = None + socks5_port: str = "?" + dns_enabled: bool = True + dns_active: bool = False + session_token: Optional[str] = None + + +def create_ui_state() -> Dict[str, Any]: + return { + 'spinner': None, + 'monitor': None, + 'timer_state': None, + 'connection_data': ConnectionStateData(), + } + + +# ── Cleanup helpers ─────────────────────────────────────────────────────────── + +def cleanup_spinner(state: Dict[str, Any]) -> None: + if state['spinner']: + try: + state['spinner'].stop() + except Exception as e: + logger.error(f"Failed to stop spinner: {e}") + finally: + state['spinner'] = None + + +def cleanup_monitor(state: Dict[str, Any]) -> None: + if state['monitor'] is not None: + try: + state['monitor'].stop() + except Exception as e: + logger.error(f"Failed to stop monitor: {e}") + finally: + state['monitor'] = None + + +def cleanup_timer(state: Dict[str, Any]) -> None: + if state['timer_state'] and state['timer_state'].get('stop'): + try: + state['timer_state']['stop'].set() + except Exception as e: + logger.error(f"Failed to stop timer: {e}") + finally: + if state['timer_state']: + state['timer_state']['stop'] = None + + +def cleanup_reconnecting_spinner(state: Dict[str, Any]) -> None: + if state['timer_state'] and state['timer_state'].get('reconnecting'): + try: + state['timer_state']['reconnecting'].set() + except Exception as e: + logger.error(f"Failed to stop reconnecting spinner: {e}") + finally: + if state['timer_state']: + state['timer_state']['reconnecting'] = None + + +def cleanup_all(state: Dict[str, Any]) -> None: + cleanup_spinner(state) + cleanup_timer(state) + cleanup_reconnecting_spinner(state) + cleanup_monitor(state) + + +# ── Handler factory ─────────────────────────────────────────────────────────── + +def create_event_handlers(state: Dict[str, Any], drop_state: Dict, tunnel_state: Dict): + """Factory: returns event handler closures bound to state.""" + + def _update_connection_data(event) -> ConnectionStateData: + d = event.subject or {} + return ConnectionStateData( + tunnel_interface=d.get('tunnel_if', "?"), + server_ip=d.get('server_ip'), + socks5_port=d.get('socks5_port', "?"), + dns_enabled=d.get('dns_enabled', True), + dns_active=d.get('dns_active', False), + ) + + def _update_ui_labels(conn: ConnectionStateData) -> None: + from cli.ui import ( + label_connected, label_killswitch, label_dns, label_ipv6, label_ipv4 + ) + label_connected(f"tunnel={conn.tunnel_interface} port={conn.socks5_port}") + label_killswitch(killswitch.status()) + label_dns(enabled=conn.dns_enabled, active=conn.dns_active) + label_ipv6(blocked=True) + if conn.server_ip: + label_ipv4(server_ip=conn.server_ip, reachable=True) + + def on_connecting(event) -> None: + try: + cleanup_timer(state) + if (state['timer_state'] and + state['timer_state'].get('reconnecting') is None and + state['timer_state'].get('_retrying')): + from cli.ui import connecting_spinner + state['timer_state']['reconnecting'] = connecting_spinner() + + d = event.subject or {} + attempt = d.get("attempt_count", "?") + total = d.get("maximum_number_of_attempts", "?") + + cleanup_spinner(state) + from cli.ui import Spinner + state['spinner'] = Spinner(f"Connecting... attempt {attempt}/{total}") + state['spinner'].start() + except Exception as e: + logger.error(f"Error in on_connecting: {e}", exc_info=True) + + def on_connected(event) -> None: + try: + cleanup_spinner(state) + cleanup_reconnecting_spinner(state) + + if state['timer_state']: + state['timer_state']['_retrying'] = False + + state['connection_data'] = _update_connection_data(event) + _update_ui_labels(state['connection_data']) + + d = event.subject or {} + if state['monitor'] is not None: + state['monitor'].set_connection_data(socks5_port=d.get('socks5_port')) + + if state['timer_state'] is not None: + from cli.ui import session_timer + state['timer_state']['stop'] = session_timer( + drop_state=drop_state, + tunnel_state=tunnel_state, + ) + except Exception as e: + logger.error(f"Error in on_connected: {e}", exc_info=True) + + def on_connected_token(event) -> None: + try: + if state['monitor'] is not None: + token = (event.subject or {}).get('session_token') + if token: + state['monitor'].set_session_token(token) + state['connection_data'].session_token = token + except Exception as e: + logger.error(f"Error in on_connected_token: {e}", exc_info=True) + + def on_disconnected(event) -> None: + try: + cleanup_all(state) + from cli.ui import label_disconnect + tunnel_if = (event.subject or {}).get('tunnel_if', '?') + label_disconnect(f"tunnel={tunnel_if}") + except Exception as e: + logger.error(f"Error in on_disconnected: {e}", exc_info=True) + + def on_error(event) -> None: + try: + cleanup_all(state) + from cli.ui import label_error + msg = event.subject + if isinstance(msg, dict): + msg = msg.get('message', str(msg)) + label_error(str(msg or "Unknown error")) + logger.error(f"Connection error: {msg}") + except Exception as e: + logger.error(f"Error in on_error: {e}", exc_info=True) + + return { + 'on_connecting': on_connecting, + 'on_connected': on_connected, + 'on_connected_token': on_connected_token, + 'on_disconnected': on_disconnected, + 'on_error': on_error, + } \ No newline at end of file diff --git a/core/utils/encrypted_proxy/singbox.py b/core/utils/encrypted_proxy/singbox.py index 3589ade..35549b6 100644 --- a/core/utils/encrypted_proxy/singbox.py +++ b/core/utils/encrypted_proxy/singbox.py @@ -2,21 +2,34 @@ import json import os import subprocess import time +from abc import ABC, abstractmethod from pathlib import Path +from typing import Optional +import threading from core.Constants import Constants +import logging +logger = logging.getLogger(__name__) + class SingboxRunner: - _WRAPPER = Constants.SINGBOX_WRAPPER - _PID_FILE = Path(Constants.SINGBOX_PID_FILE) - _LOG_FILE = Path(Constants.SINGBOX_LOG_FILE) + _WRAPPER = Constants.SINGBOX_WRAPPER + _PID_FILE = Path(Constants.SINGBOX_PID_FILE) + _LOG_FILE = Path(Constants.SINGBOX_LOG_FILE) _CONFIG_DIR = Path(Constants.SINGBOX_CONFIG_DIR) - _START_TIMEOUT_S = 15.0 - _START_POLL_S = 0.5 - _START_INITIAL_S = 2.0 + _START_TIMEOUT_S = 15.0 + _START_POLL_S = 0.5 + _START_INITIAL_S = 2.0 + + _ORPHAN_GRACEFUL_TIMEOUT_S = 4.0 + _ORPHAN_FINAL_VERIFY_TIMEOUT_S = 2.0 + _ORPHAN_POLL_S = 0.3 + + def __init__(self): + self._wrapper_process: Optional[subprocess.Popen] = None def start(self, config_path: Path) -> bool: self.stop() @@ -24,13 +37,14 @@ class SingboxRunner: self._ensure_log_file() try: - subprocess.Popen( + self._wrapper_process = subprocess.Popen( ['sudo', self._WRAPPER, str(config_path), 'start'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, ) + logger.info(f"Wrapper spawned (PID {self._wrapper_process.pid})") except FileNotFoundError: raise RuntimeError( f'Wrapper not found: {self._WRAPPER}\n' @@ -51,6 +65,15 @@ class SingboxRunner: ) def stop(self) -> None: + if self._wrapper_process is not None: + try: + self._wrapper_process.terminate() + self._wrapper_process.wait(timeout=2) + except subprocess.TimeoutExpired: + self._wrapper_process.kill() + finally: + self._wrapper_process = None + self._kill_orphans() try: @@ -63,6 +86,7 @@ class SingboxRunner: timeout=15, ) except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + logger.warning("Graceful stop failed, attempting emergency kill") self._emergency_kill() def is_running(self) -> bool: @@ -100,7 +124,7 @@ class SingboxRunner: env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, ) return result.stdout.strip() - except Exception: + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): try: lines = self._LOG_FILE.read_text(errors='replace').splitlines() return '\n'.join(lines[-80:]) @@ -121,6 +145,88 @@ class SingboxRunner: tmp.unlink(missing_ok=True) raise + # ── Kill helpers ────────────────────────────────────────────────────────── + + def _kill_orphans(self) -> None: + if not self._has_running_processes(): + return + logger.debug("Found running sing-box processes, attempting graceful stop") + self._attempt_graceful_stop() + if self._wait_for_process_exit(self._ORPHAN_GRACEFUL_TIMEOUT_S): + logger.info("sing-box stopped gracefully") + return + logger.warning("Graceful stop timeout, force killing remaining sing-box processes") + self._force_kill_all_processes() + if self._wait_for_process_exit(self._ORPHAN_FINAL_VERIFY_TIMEOUT_S): + logger.info("All sing-box processes terminated") + return + logger.error("Failed to terminate all sing-box processes after force kill") + + def _has_running_processes(self) -> bool: + try: + result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True) + return result.returncode == 0 + except OSError: + return False + + def _attempt_graceful_stop(self) -> None: + try: + subprocess.run( + ['sudo', self._WRAPPER, '', 'stop'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, + timeout=15, + ) + except (OSError, subprocess.TimeoutExpired): + pass + + def _wait_for_process_exit(self, timeout_s: float) -> bool: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if not self._has_running_processes(): + return True + time.sleep(self._ORPHAN_POLL_S) + return False + + def _force_kill_all_processes(self) -> None: + try: + result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True) + if result.returncode != 0: + return + pids = [int(p) for p in result.stdout.strip().splitlines() if p.strip().isdigit()] + for pid in pids: + self._force_kill_process(pid) + except (OSError, subprocess.TimeoutExpired): + pass + + def _force_kill_process(self, pid: int) -> None: + try: + subprocess.run( + ['sudo', 'kill', '-9', str(pid)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, + timeout=5, + ) + logger.debug(f"Force killed sing-box process {pid}") + except (OSError, subprocess.TimeoutExpired): + pass + + def _emergency_kill(self) -> None: + if not self._PID_FILE.exists(): + return + try: + pid = int(self._PID_FILE.read_text().strip()) + if pid > 0: + self._force_kill_process(pid) + except (ValueError, OSError): + pass + finally: + self._PID_FILE.unlink(missing_ok=True) + def _ensure_log_file(self) -> None: self._LOG_FILE.parent.mkdir(parents=True, exist_ok=True) if not self._LOG_FILE.exists(): @@ -136,123 +242,77 @@ class SingboxRunner: f' Allowed: {self._CONFIG_DIR}' ) - def _emergency_kill(self) -> None: - if not self._PID_FILE.exists(): - return - try: - pid = int(self._PID_FILE.read_text().strip()) - if pid > 0: - subprocess.run( - ['sudo', 'kill', '-9', str(pid)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, - timeout=5, - ) - except (ValueError, OSError, subprocess.TimeoutExpired): - pass - finally: - self._PID_FILE.unlink(missing_ok=True) +# ── Abstract base monitor ───────────────────────────────────────────────────── - def _kill_orphans(self) -> None: - try: - result = subprocess.run( - ['pgrep', '-x', 'sing-box'], - capture_output=True, - text=True, - ) +CAUSE_DISCONNECT = 'disconnect' +CAUSE_LEAK = 'leak' +CAUSE_DISPLACED = 'displaced' - if result.returncode != 0: - return - pids = [ - int(p) - for p in result.stdout.strip().splitlines() - if p.strip().isdigit() - ] +class SingboxProcessMonitor(ABC): + """Abstract monitor for sing-box process health. - if not pids: - return + Clients inherit this and implement event callbacks based on their + threading model (threading.Event for CLI, Qt signals for GUI). + """ - subprocess.run( - ['sudo', self._WRAPPER, '', 'stop'], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, - timeout=15, - ) + POLL_INTERVAL = 5 - deadline = time.monotonic() + 4.0 + def __init__(self, runner: SingboxRunner): + self._runner = runner + self._lock = threading.Lock() + self._running = False + self._stop_event = threading.Event() + self._last_poll_ts = None - while time.monotonic() < deadline: - check = subprocess.run( - ['pgrep', '-x', 'sing-box'], - capture_output=True, - ) + def stop(self): + with self._lock: + self._running = False + self._stop_event.set() - if check.returncode != 0: - return + @abstractmethod + def on_process_alive(self) -> None: + pass - time.sleep(0.3) + @abstractmethod + def on_process_dead(self) -> None: + pass - for pid in pids: - try: - subprocess.run( - ['sudo', 'kill', '-9', str(pid)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, - timeout=5, - ) - except (OSError, subprocess.TimeoutExpired): - pass + @abstractmethod + def on_monitor_stopped(self, cause: str) -> None: + pass - except (OSError, ValueError, subprocess.TimeoutExpired): - pass - try: - result = subprocess.run( - ['pgrep', '-x', 'sing-box'], - capture_output=True, - text=True, - ) + def _monitor_loop(self) -> None: + while True: + with self._lock: + if not self._running: + break - if result.returncode != 0: - return + interrupted = self._stop_event.wait(timeout=self.POLL_INTERVAL) + if interrupted: + break - pids = [ - int(p) - for p in result.stdout.strip().splitlines() - if p.strip().isdigit() - ] + alive = self._runner.is_running() + if alive: + self.on_process_alive() + else: + self.on_process_dead() + break - if not pids: - return + def run_blocking(self) -> None: + with self._lock: + self._running = True - subprocess.run( - ['sudo', self._WRAPPER, '', 'stop'], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, - timeout=15, - ) + self._stop_event.clear() + t = threading.Thread(target=self._monitor_loop, daemon=False) + t.start() - deadline = time.monotonic() + 4.0 + self._stop_event.wait() - while time.monotonic() < deadline: - check = subprocess.run( - ['pgrep', '-x', 'sing-box'], - capture_output=True, - ) + with self._lock: + self._running = False - if check.returncode != 0: - return - - time.sleep(0.3) - - except (OSError, ValueError, subprocess.TimeoutExpired): - pass \ No newline at end of file + t.join(timeout=5) + self._runner.stop() + self.on_monitor_stopped("user_requested") \ No newline at end of file -- 2.45.2 From 1a60511492dbc8221988437b970f495b6caed1a2 Mon Sep 17 00:00:00 2001 From: zenaku Date: Wed, 8 Jul 2026 11:14:41 -0500 Subject: [PATCH 48/51] refactor: align cli with core architecture --- .gitignore | 3 +- .../encrypted_proxy/observer_helpers.py | 25 ++- .../encrypted_proxy/killswitch_monitor.py | 143 ++++++++++++++++++ core/utils/encrypted_proxy/singbox.py | 13 ++ 4 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 core/utils/encrypted_proxy/killswitch_monitor.py diff --git a/.gitignore b/.gitignore index cecfc1b..bf7465e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ prototype_client.py .venv __pycache__ dist -.env \ No newline at end of file +.env +docs.md \ No newline at end of file diff --git a/core/services/encrypted_proxy/observer_helpers.py b/core/services/encrypted_proxy/observer_helpers.py index 9b4d458..228f732 100644 --- a/core/services/encrypted_proxy/observer_helpers.py +++ b/core/services/encrypted_proxy/observer_helpers.py @@ -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.""" def _update_connection_data(event) -> ConnectionStateData: + """Extract connection fields from event subject into a ConnectionStateData.""" d = event.subject or {} return ConnectionStateData( 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: + """Refresh all CLI status labels from current connection data.""" from cli.ui import ( 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: 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: + """Handle connecting event: show attempt spinner.""" try: cleanup_timer(state) 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) def on_connected(event) -> None: + """Handle connected event: update labels, monitor port, and start session timer.""" try: cleanup_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) _update_ui_labels(state['connection_data']) - d = event.subject or {} - if state['monitor'] is not None: - state['monitor'].set_connection_data(socks5_port=d.get('socks5_port')) + event_data = event.subject or {} + _update_monitor_port(event_data) if state['timer_state'] is not None: 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) def on_connected_token(event) -> None: + """Handle session token event: pass token to monitor.""" try: if state['monitor'] is not None: 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) def on_disconnected(event) -> None: + """Handle disconnected event: cleanup UI and show disconnect label.""" try: cleanup_all(state) 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) def on_error(event) -> None: + """Handle error event: cleanup UI and show error label.""" try: cleanup_all(state) 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) return { - 'on_connecting': on_connecting, - 'on_connected': on_connected, + 'on_connecting': on_connecting, + 'on_connected': on_connected, 'on_connected_token': on_connected_token, - 'on_disconnected': on_disconnected, - 'on_error': on_error, + 'on_disconnected': on_disconnected, + 'on_error': on_error, } \ No newline at end of file diff --git a/core/utils/encrypted_proxy/killswitch_monitor.py b/core/utils/encrypted_proxy/killswitch_monitor.py new file mode 100644 index 0000000..2d71c70 --- /dev/null +++ b/core/utils/encrypted_proxy/killswitch_monitor.py @@ -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") \ No newline at end of file diff --git a/core/utils/encrypted_proxy/singbox.py b/core/utils/encrypted_proxy/singbox.py index 35549b6..60de1dd 100644 --- a/core/utils/encrypted_proxy/singbox.py +++ b/core/utils/encrypted_proxy/singbox.py @@ -148,18 +148,31 @@ class SingboxRunner: # ── Kill helpers ────────────────────────────────────────────────────────── 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(): return + logger.debug("Found running sing-box processes, attempting graceful stop") + + # Phase 2: Graceful stop attempt via wrapper self._attempt_graceful_stop() + + # Phase 3: Wait for graceful shutdown to complete if self._wait_for_process_exit(self._ORPHAN_GRACEFUL_TIMEOUT_S): logger.info("sing-box stopped gracefully") return + + # Phase 4: Graceful stop failed — force kill remaining processes logger.warning("Graceful stop timeout, force killing remaining sing-box 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): logger.info("All sing-box processes terminated") return + logger.error("Failed to terminate all sing-box processes after force kill") def _has_running_processes(self) -> bool: -- 2.45.2 From cae72efee3a71e9af487dc1ee8de84f88c3425eb Mon Sep 17 00:00:00 2001 From: zenaku Date: Thu, 9 Jul 2026 09:44:08 -0500 Subject: [PATCH 49/51] feat(core): branch vless/hysteria2 immediately in ProfileController.enable() --- core/controllers/ProfileController.py | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/core/controllers/ProfileController.py b/core/controllers/ProfileController.py index cade914..7d08a5c 100644 --- a/core/controllers/ProfileController.py +++ b/core/controllers/ProfileController.py @@ -77,6 +77,31 @@ class ProfileController: if profile.is_system_profile(): + if profile.connection.needs_operator_proxy(): + from core.controllers.ConnectionController import ConnectionController + if not profile.has_operator_proxy_session(): + ProfileController.activate_subscription(profile, connection_observer=connection_observer) + from core.services.WebServiceApiService import WebServiceApiService + operator_proxy_session = ConnectionController.with_preferred_connection( + profile.subscription.billing_code, + profile.subscription.operator_id, + profile.connection.get_protocol(), + task=WebServiceApiService.post_operator_proxy, + connection_observer=connection_observer + ) + if operator_proxy_session is None: + raise ProfileActivationError('Could not obtain operator proxy session.') + profile.attach_operator_proxy_session(operator_proxy_session) + + ok = ConnectionController.establish_encrypted_proxy_connection( + profile, socks5_port=1080, observer=connection_observer + ) + if not ok: + raise ProfileActivationError('The profile could not be enabled.') + if profile_observer is not None: + profile_observer.notify('enabled', profile) + return + try: ConnectionController.establish_connection(profile, ignore=ignore, connection_observer=connection_observer) except ConnectionError: @@ -85,6 +110,12 @@ class ProfileController: if profile_observer is not None: profile_observer.notify('enabled', profile) + @staticmethod + def run_monitor(profile: Union[SessionProfile, SystemProfile], monitor) -> None: + + if profile.is_system_profile() and profile.connection.needs_operator_proxy(): + monitor.run_blocking() + @staticmethod def disable(profile: Union[SessionProfile, SystemProfile], explicitly: bool = True, ignore: tuple[type[Exception]] = (), profile_observer: ProfileObserver = None, connection_observer: ConnectionObserver = None): -- 2.45.2 From a4bbfa4ec533402d75ff3d17957e5b42c99f7269 Mon Sep 17 00:00:00 2001 From: zenaku Date: Thu, 9 Jul 2026 18:23:11 -0500 Subject: [PATCH 50/51] refactor(core/cli): route vless/hysteria2 through _enable_encrypted_proxy, scope KillswitchMonitor to encrypted proxy only --- core/controllers/ProfileController.py | 56 ++++++++++++------- core/models/BaseProfile.py | 4 +- core/services/WebServiceApiService.py | 4 +- .../encrypted_proxy/hysteria_service.py | 37 +++++++++++- .../services/encrypted_proxy/vless_service.py | 37 +++++++++++- core/utils/encrypted_proxy/singbox.py | 8 +-- 6 files changed, 111 insertions(+), 35 deletions(-) diff --git a/core/controllers/ProfileController.py b/core/controllers/ProfileController.py index 7d08a5c..37af907 100644 --- a/core/controllers/ProfileController.py +++ b/core/controllers/ProfileController.py @@ -77,31 +77,14 @@ class ProfileController: if profile.is_system_profile(): - if profile.connection.needs_operator_proxy(): - from core.controllers.ConnectionController import ConnectionController - if not profile.has_operator_proxy_session(): - ProfileController.activate_subscription(profile, connection_observer=connection_observer) - from core.services.WebServiceApiService import WebServiceApiService - operator_proxy_session = ConnectionController.with_preferred_connection( - profile.subscription.billing_code, - profile.subscription.operator_id, - profile.connection.get_protocol(), - task=WebServiceApiService.post_operator_proxy, - connection_observer=connection_observer - ) - if operator_proxy_session is None: - raise ProfileActivationError('Could not obtain operator proxy session.') - profile.attach_operator_proxy_session(operator_proxy_session) - - ok = ConnectionController.establish_encrypted_proxy_connection( - profile, socks5_port=1080, observer=connection_observer - ) - if not ok: - raise ProfileActivationError('The profile could not be enabled.') + if profile.connection.code in ('vless', 'hysteria2'): + ProfileController._enable_encrypted_proxy(profile, connection_observer=connection_observer) if profile_observer is not None: profile_observer.notify('enabled', profile) return + # WireGuard and other system profiles — legacy flow + from core.controllers.ConnectionController import ConnectionController try: ConnectionController.establish_connection(profile, ignore=ignore, connection_observer=connection_observer) except ConnectionError: @@ -110,6 +93,37 @@ class ProfileController: if profile_observer is not None: profile_observer.notify('enabled', profile) + @staticmethod + def _enable_encrypted_proxy(profile, connection_observer=None) -> None: + + from core.controllers.ConnectionController import ConnectionController + from core.controllers.SystemStateController import SystemStateController + from core.services.WebServiceApiService import WebServiceApiService + + if not profile.has_operator_proxy_session(): + ProfileController.activate_subscription(profile, connection_observer=connection_observer) + operator_proxy_session = ConnectionController.with_preferred_connection( + profile.subscription.billing_code, + profile.subscription.operator_id, + profile.connection.code, + task=WebServiceApiService.post_operator_proxy, + connection_observer=connection_observer + ) + if operator_proxy_session is None: + raise ProfileActivationError('Could not obtain operator proxy session.') + profile.attach_operator_proxy_session(operator_proxy_session) + + ConnectionController._signal_previous_process() + ok = ConnectionController.establish_encrypted_proxy_connection( + profile, socks5_port=1080, observer=connection_observer + ) + if not ok: + raise ProfileActivationError('The profile could not be enabled.') + + token = SystemStateController.create(profile.id) + if connection_observer is not None: + connection_observer.notify('connected_token', {'session_token': token}) + @staticmethod def run_monitor(profile: Union[SessionProfile, SystemProfile], monitor) -> None: diff --git a/core/models/BaseProfile.py b/core/models/BaseProfile.py index 75cd58d..aca6119 100644 --- a/core/models/BaseProfile.py +++ b/core/models/BaseProfile.py @@ -65,8 +65,8 @@ class BaseProfile(ABC): return type(self).__name__ == 'SystemProfile' def save(self: Self): - config_dict = self.to_dict() # Get dict, not JSON string - location_dict = self.location.to_dict() if self.location else None if self.location else None # this is from SQLAlchemy, and not JSON-models, that's why it's separate. + config_dict = self.to_dict() + location_dict = self.location.to_dict() if self.location else None config_dict["location"] = location_dict diff --git a/core/services/WebServiceApiService.py b/core/services/WebServiceApiService.py index 484f8e3..13e4d33 100644 --- a/core/services/WebServiceApiService.py +++ b/core/services/WebServiceApiService.py @@ -209,7 +209,7 @@ class WebServiceApiService: else: headers = None - return requests.get(Constants.SP_API_BASE_URL + path, headers=headers, proxies=proxies) + return requests.get(Constants.SP_API_BASE_URL + path, headers=headers, proxies=proxies, timeout=30) @staticmethod def __post(path, billing_code: Optional[str] = None, body: Optional[dict] = None, proxies: Optional[dict] = None): @@ -221,7 +221,7 @@ class WebServiceApiService: else: headers = None - return requests.post(Constants.SP_API_BASE_URL + path, headers=headers, json=body, proxies=proxies) + return requests.post(Constants.SP_API_BASE_URL + path, headers=headers, json=body, proxies=proxies, timeout=30) @staticmethod def get_cached_sync(proxies: Optional[dict] = None): diff --git a/core/services/encrypted_proxy/hysteria_service.py b/core/services/encrypted_proxy/hysteria_service.py index 44e30a0..b28fcca 100644 --- a/core/services/encrypted_proxy/hysteria_service.py +++ b/core/services/encrypted_proxy/hysteria_service.py @@ -134,10 +134,41 @@ def enable_hysteria(username: str, password: str, server_host: str, _cleanup_all() +def _force_kill_singbox() -> None: + import subprocess + try: + result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True) + if result.returncode == 0: + for pid in result.stdout.strip().splitlines(): + subprocess.run( + ['sudo', 'kill', '-9', pid.strip()], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5 + ) + except Exception: + pass + + def disable_hysteria(observer=None) -> bool: - revert_dns_on_tun() - SingboxRunner().stop() - killswitch.disarm() + try: + revert_dns_on_tun() + except Exception: + pass + try: + SingboxRunner().stop() + except Exception: + pass + try: + import subprocess, time + time.sleep(0.5) + result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True) + if result.returncode == 0: + _force_kill_singbox() + except Exception: + pass + try: + killswitch.disarm() + except Exception: + pass if observer: observer.notify("disconnected", {"tunnel_if": Constants.SINGBOX_TUN_IF}) return True \ No newline at end of file diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py index 3980db0..1a778cd 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -163,10 +163,41 @@ def enable_vless(vless_link: str, username: str, server_ip: str, _cleanup_all() +def _force_kill_singbox() -> None: + import subprocess + try: + result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True) + if result.returncode == 0: + for pid in result.stdout.strip().splitlines(): + subprocess.run( + ['sudo', 'kill', '-9', pid.strip()], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5 + ) + except Exception: + pass + + def disable_vless(observer=None) -> bool: - revert_dns_on_tun() - SingboxRunner().stop() - killswitch.disarm() + try: + revert_dns_on_tun() + except Exception: + pass + try: + SingboxRunner().stop() + except Exception: + pass + try: + import subprocess, time + time.sleep(0.5) + result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True) + if result.returncode == 0: + _force_kill_singbox() + except Exception: + pass + try: + killswitch.disarm() + except Exception: + pass if observer: observer.notify("disconnected", {"tunnel_if": Constants.SINGBOX_TUN_IF}) return True \ No newline at end of file diff --git a/core/utils/encrypted_proxy/singbox.py b/core/utils/encrypted_proxy/singbox.py index 60de1dd..1985a5c 100644 --- a/core/utils/encrypted_proxy/singbox.py +++ b/core/utils/encrypted_proxy/singbox.py @@ -24,8 +24,8 @@ class SingboxRunner: _START_POLL_S = 0.5 _START_INITIAL_S = 2.0 - _ORPHAN_GRACEFUL_TIMEOUT_S = 4.0 - _ORPHAN_FINAL_VERIFY_TIMEOUT_S = 2.0 + _ORPHAN_GRACEFUL_TIMEOUT_S = 2.0 + _ORPHAN_FINAL_VERIFY_TIMEOUT_S = 1.0 _ORPHAN_POLL_S = 0.3 def __init__(self): @@ -83,7 +83,7 @@ class SingboxRunner: stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, - timeout=15, + timeout=5, ) except (subprocess.TimeoutExpired, FileNotFoundError, OSError): logger.warning("Graceful stop failed, attempting emergency kill") @@ -190,7 +190,7 @@ class SingboxRunner: stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, - timeout=15, + timeout=5, ) except (OSError, subprocess.TimeoutExpired): pass -- 2.45.2 From 9c5636eb8cc399df0d9265724acd9e470318f44d Mon Sep 17 00:00:00 2001 From: zenaku Date: Wed, 15 Jul 2026 17:05:14 -0500 Subject: [PATCH 51/51] TunnelMonitor with is_tunnel_alive(), add WireGuardMonitor, arm/disarm killswitch on WireGuard connect/disconnect --- core/controllers/ConnectionController.py | 31 ++++++ .../encrypted_proxy/hysteria_service.py | 2 +- .../services/encrypted_proxy/vless_service.py | 2 +- core/utils/encrypted_proxy/killswitch.py | 11 +- .../encrypted_proxy/killswitch_monitor.py | 8 +- core/utils/encrypted_proxy/singbox.py | 18 +-- core/utils/wireguard/__init__.py | 0 core/utils/wireguard/wireguard_monitor.py | 104 ++++++++++++++++++ 8 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 core/utils/wireguard/__init__.py create mode 100644 core/utils/wireguard/wireguard_monitor.py diff --git a/core/controllers/ConnectionController.py b/core/controllers/ConnectionController.py index d122470..5f35851 100644 --- a/core/controllers/ConnectionController.py +++ b/core/controllers/ConnectionController.py @@ -441,6 +441,11 @@ class ConnectionController: if completed_successfully or not ConnectionController.system_uses_wireguard_interface(): subprocess.run(('nmcli', 'connection', 'delete', 'hv-ipv6-sink'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) ConnectionController.terminate_tor_connection() + try: + from core.utils.encrypted_proxy import killswitch + killswitch.disarm() + except Exception: + pass SystemState.dissolve() if connection_observer is not None: connection_observer.notify('disconnected', {}) @@ -549,6 +554,15 @@ class ConnectionController: except CalledProcessError: 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) if connection_observer is not None: connection_observer.notify('connected_token', {'session_token': token}) @@ -559,6 +573,23 @@ class ConnectionController: except ConnectionError: 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 def __with_tor_connection(*args, task: Callable[..., Any], connection_observer: Optional[ConnectionObserver] = None, **kwargs): diff --git a/core/services/encrypted_proxy/hysteria_service.py b/core/services/encrypted_proxy/hysteria_service.py index b28fcca..4ef3d97 100644 --- a/core/services/encrypted_proxy/hysteria_service.py +++ b/core/services/encrypted_proxy/hysteria_service.py @@ -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") 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: observer.notify("error", "Failed to arm kill switch") return False diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py index 1a778cd..6160bc3 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -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") 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: observer.notify("error", "Failed to arm kill switch") return False diff --git a/core/utils/encrypted_proxy/killswitch.py b/core/utils/encrypted_proxy/killswitch.py index 3629976..3c2375f 100644 --- a/core/utils/encrypted_proxy/killswitch.py +++ b/core/utils/encrypted_proxy/killswitch.py @@ -4,12 +4,11 @@ from core.Constants import Constants _C = Constants() -def arm(server_ip: str, tunnel_if: str) -> bool: - result = subprocess.run( - ["sudo", _C.KILLSWITCH_WRAPPER, "arm", server_ip, tunnel_if], - capture_output=True, - text=True, - ) +def arm(server_ip: str, tunnel_if: str, internal_subnet: str = None) -> bool: + cmd = ["sudo", _C.KILLSWITCH_WRAPPER, "arm", server_ip, tunnel_if] + if internal_subnet: + cmd.append(internal_subnet) + result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: print(f"[killswitch] Failed to arm: {result.stderr.strip()}") return False diff --git a/core/utils/encrypted_proxy/killswitch_monitor.py b/core/utils/encrypted_proxy/killswitch_monitor.py index 2d71c70..7d3670c 100644 --- a/core/utils/encrypted_proxy/killswitch_monitor.py +++ b/core/utils/encrypted_proxy/killswitch_monitor.py @@ -10,12 +10,13 @@ tunnel_state: dict = {"alive": True} 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" def __init__(self): - super().__init__(SingboxRunner()) + super().__init__() + self._runner = SingboxRunner() self._on_disconnect = None self._on_leak = None self._on_displaced = None @@ -43,6 +44,9 @@ class KillswitchMonitor(SingboxProcessMonitor): # ── Abstract impl ───────────────────────────────────────────────────────── + def is_tunnel_alive(self) -> bool: + return self._runner.is_running() + def on_process_alive(self) -> None: tunnel_state["alive"] = True diff --git a/core/utils/encrypted_proxy/singbox.py b/core/utils/encrypted_proxy/singbox.py index 1985a5c..3359d4e 100644 --- a/core/utils/encrypted_proxy/singbox.py +++ b/core/utils/encrypted_proxy/singbox.py @@ -270,12 +270,11 @@ class SingboxProcessMonitor(ABC): threading model (threading.Event for CLI, Qt signals for GUI). """ - POLL_INTERVAL = 5 + POLL_INTERVAL = 10 - def __init__(self, runner: SingboxRunner): - self._runner = runner - self._lock = threading.Lock() - self._running = False + def __init__(self): + self._lock = threading.Lock() + self._running = False self._stop_event = threading.Event() self._last_poll_ts = None @@ -284,6 +283,11 @@ class SingboxProcessMonitor(ABC): self._running = False self._stop_event.set() + @abstractmethod + def is_tunnel_alive(self) -> bool: + """Return True if the tunnel is healthy.""" + pass + @abstractmethod def on_process_alive(self) -> None: pass @@ -306,7 +310,7 @@ class SingboxProcessMonitor(ABC): if interrupted: break - alive = self._runner.is_running() + alive = self.is_tunnel_alive() if alive: self.on_process_alive() else: @@ -327,5 +331,5 @@ class SingboxProcessMonitor(ABC): self._running = False t.join(timeout=5) - self._runner.stop() + # tunnel teardown handled by subclass self.on_monitor_stopped("user_requested") \ No newline at end of file diff --git a/core/utils/wireguard/__init__.py b/core/utils/wireguard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/utils/wireguard/wireguard_monitor.py b/core/utils/wireguard/wireguard_monitor.py new file mode 100644 index 0000000..c8cf721 --- /dev/null +++ b/core/utils/wireguard/wireguard_monitor.py @@ -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") -- 2.45.2