From a3cf476af445c247d4c63ff7c58f9c3af0c0b3e1 Mon Sep 17 00:00:00 2001 From: zenaku Date: Sat, 16 May 2026 00:36:11 -0500 Subject: [PATCH] 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