core-laravel-proxyTEST

This commit is contained in:
Zenaku 2026-05-16 00:36:11 -05:00
parent 75d026651a
commit a3cf476af4
24 changed files with 628 additions and 41 deletions

6
.env Normal file
View file

@ -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

View file

@ -1,57 +1,49 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Final from typing import Final
from dotenv import load_dotenv
import os 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) @dataclass(frozen=True)
class Constants: class Constants:
# ticketing group:
TICKET_API_BASE_URL: Final[str] = os.environ.get( TICKET_API_BASE_URL: Final[str] = os.environ.get(
"TICKET_API_BASE_URL", "https://ticket.hydraveil.net" "TICKET_API_BASE_URL", "https://ticket.hydraveil.net"
) )
SP_API_BASE_URL: Final[str] = _sp_api_base_url
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', f'{_sp_api_base_url}/health')
PING_URL: Final[str] = os.environ.get('PING_URL', 'https://api.hydraveil.net/api/v1/health')
CONNECTION_RETRY_INTERVAL: Final[int] = int(os.environ.get('CONNECTION_RETRY_INTERVAL', '5')) 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')) 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_PATH: Final[str] = os.environ.get('HV_CLIENT_PATH')
HV_CLIENT_VERSION_NUMBER: Final[str] = os.environ.get('HV_CLIENT_VERSION_NUMBER') HV_CLIENT_VERSION_NUMBER: Final[str] = os.environ.get('HV_CLIENT_VERSION_NUMBER')
HOME: Final[str] = os.path.expanduser('~') HOME: Final[str] = os.path.expanduser('~')
SYSTEM_CONFIG_PATH: Final[str] = '/etc' SYSTEM_CONFIG_PATH: Final[str] = '/etc'
CACHE_HOME: Final[str] = os.environ.get('XDG_CACHE_HOME', os.path.join(HOME, '.cache')) 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')) 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')) 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')) 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_SYSTEM_CONFIG_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/hydra-veil'
HV_CACHE_HOME: Final[str] = f'{CACHE_HOME}/hydra-veil' HV_CACHE_HOME: Final[str] = f'{CACHE_HOME}/hydra-veil'
HV_CONFIG_HOME: Final[str] = f'{CONFIG_HOME}/hydra-veil' HV_CONFIG_HOME: Final[str] = f'{CONFIG_HOME}/hydra-veil'
HV_DATA_HOME: Final[str] = f'{DATA_HOME}/hydra-veil' HV_DATA_HOME: Final[str] = f'{DATA_HOME}/hydra-veil'
HV_STATE_HOME: Final[str] = f'{STATE_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_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_CONFIG_HOME: Final[str] = f'{HV_CONFIG_HOME}/profiles'
HV_PROFILE_DATA_HOME: Final[str] = f'{HV_DATA_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_CONFIG_HOME: Final[str] = f"{HV_CONFIG_HOME}/ticketing"
HV_TICKETING_DATA_HOME: Final[str] = f"{HV_DATA_HOME}/ticket_data" HV_TICKETING_DATA_HOME: Final[str] = f"{HV_DATA_HOME}/ticket_data"
HV_APPLICATION_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/applications' HV_APPLICATION_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/applications'
HV_INCIDENT_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/incidents' HV_INCIDENT_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/incidents'
HV_RUNTIME_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/runtime' HV_RUNTIME_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/runtime'
HV_STORAGE_DATABASE_PATH: Final[str] = f'{HV_DATA_HOME}/storage.db' 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_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_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_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'

View file

@ -25,7 +25,7 @@ class ConfigurationController:
configuration = ConfigurationController.get() 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.') raise UnknownConnectionTypeError('The preferred connection type could not be determined.')
return configuration.connection return configuration.connection

View file

@ -23,6 +23,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import time import time
from core.models.OperatorProxySession import OperatorProxySession
class ConnectionController: class ConnectionController:
@ -65,6 +66,32 @@ class ConnectionController:
if connection.needs_wireguard_configuration() and not profile.has_wireguard_configuration(): 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 profile.has_subscription():
if not profile.subscription.has_been_activated(): if not profile.subscription.has_been_activated():
@ -150,6 +177,24 @@ class ConnectionController:
ConnectionController.establish_wireguard_session_connection(profile, session_directory, port_number) ConnectionController.establish_wireguard_session_connection(profile, session_directory, port_number)
session_state.network_port_numbers.wireguard.append(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: if profile.connection.masked:
while proxy_port_number is None or proxy_port_number == port_number: while proxy_port_number is None or proxy_port_number == port_number:
@ -164,7 +209,6 @@ class ConnectionController:
SessionStateController.update_or_create(session_state) SessionStateController.update_or_create(session_state)
return proxy_port_number or port_number return proxy_port_number or port_number
@staticmethod @staticmethod
def establish_system_connection(profile: SystemProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None): def establish_system_connection(profile: SystemProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None):

View file

@ -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,
)

View file

@ -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)

View file

@ -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)

View file

@ -15,3 +15,5 @@ class BaseConnection:
def is_system_connection(self): def is_system_connection(self):
return type(self).__name__ == 'SystemConnection' return type(self).__name__ == 'SystemConnection'
def needs_operator_proxy(self):
return False

View file

@ -6,6 +6,7 @@ _table_name: str = 'operators'
_table_definition: str = """ _table_definition: str = """
'id' int UNIQUE, 'id' int UNIQUE,
'name' varchar, 'name' varchar,
'type' varchar,
'public_key' varchar, 'public_key' varchar,
'nostr_public_key' varchar, 'nostr_public_key' varchar,
'nostr_profile_reference' varchar, 'nostr_profile_reference' varchar,
@ -17,11 +18,18 @@ _table_definition: str = """
class Operator(Model): class Operator(Model):
id: int id: int
name: str name: str
type: str
public_key: str public_key: str
nostr_public_key: str nostr_public_key: str
nostr_profile_reference: str nostr_profile_reference: str
nostr_attestation_event_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 @staticmethod
def find_by_id(id: int): def find_by_id(id: int):
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition)
@ -44,7 +52,7 @@ class Operator(Model):
@staticmethod @staticmethod
def save_many(operators): def save_many(operators):
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) 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 @staticmethod
def factory(cursor, row): def factory(cursor, row):
@ -53,4 +61,4 @@ class Operator(Model):
@staticmethod @staticmethod
def tuple_factory(operator): 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

View file

@ -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

View file

@ -10,6 +10,13 @@ import dataclasses_json
@dataclass @dataclass
class Subscription: class Subscription:
billing_code: str 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( expires_at: Optional[datetime] = field(
default=None, default=None,
metadata=config( metadata=config(
@ -37,4 +44,4 @@ class Subscription:
@staticmethod @staticmethod
def _iso_format(datetime_instance: datetime): def _iso_format(datetime_instance: datetime):
return datetime.isoformat(datetime_instance).replace('+00:00', 'Z') return datetime.isoformat(datetime_instance).replace('+00:00', 'Z')

View file

@ -6,11 +6,13 @@ class NetworkPortNumbers:
proxy: list[int] = field(default_factory=list) proxy: list[int] = field(default_factory=list)
wireguard: list[int] = field(default_factory=list) wireguard: list[int] = field(default_factory=list)
tor: 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 @property
def all(self): def all(self):
return self.proxy + self.wireguard + self.tor return self.proxy + self.wireguard + self.tor + self.vless + self.hysteria2
@property @property
def isolated(self): def isolated(self):
return self.proxy + self.wireguard return self.proxy + self.wireguard + self.vless + self.hysteria2

View file

@ -1,14 +1,12 @@
from core.models.BaseConnection import BaseConnection from core.models.BaseConnection import BaseConnection
from dataclasses import dataclass from dataclasses import dataclass
@dataclass @dataclass
class SessionConnection(BaseConnection): class SessionConnection(BaseConnection):
masked: bool = False masked: bool = False
def __post_init__(self): def __post_init__(self):
if self.code not in ('system', 'tor', 'wireguard', 'vless', 'hysteria2'):
if self.code not in ('system', 'tor', 'wireguard'):
raise ValueError('Invalid connection code.') raise ValueError('Invalid connection code.')
def is_unprotected(self): def is_unprotected(self):
@ -16,3 +14,9 @@ class SessionConnection(BaseConnection):
def needs_proxy_configuration(self): def needs_proxy_configuration(self):
return self.masked is True 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

View file

@ -116,3 +116,34 @@ class SessionProfile(BaseProfile):
def __delete_wireguard_configuration(self): def __delete_wireguard_configuration(self):
Path(self.get_wireguard_configuration_path()).unlink(missing_ok=True) 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())

View file

@ -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 = []

View file

@ -2,6 +2,7 @@ from core.Constants import Constants
from core.models.ClientVersion import ClientVersion from core.models.ClientVersion import ClientVersion
from core.models.Location import Location from core.models.Location import Location
from core.models.Operator import Operator from core.models.Operator import Operator
from core.models.OperatorProxySession import OperatorProxySession
from core.models.Subscription import Subscription from core.models.Subscription import Subscription
from core.models.SubscriptionPlan import SubscriptionPlan from core.models.SubscriptionPlan import SubscriptionPlan
from core.models.invoice.Invoice import Invoice from core.models.invoice.Invoice import Invoice
@ -67,7 +68,7 @@ class WebServiceApiService:
if response.status_code == status_codes.OK: if response.status_code == status_codes.OK:
for operator in response.json()['data']: 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 return operators
@ -100,17 +101,21 @@ class WebServiceApiService:
return subscription_plans return subscription_plans
@staticmethod @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 from requests.status_codes import codes as status_codes
response = WebServiceApiService.__post('/subscriptions', None, { body = {'subscription_plan_id': subscription_plan_id}
'subscription_plan_id': subscription_plan_id,
'location_id': location_id if operator_id is not None:
}, proxies) 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: 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: else:
return None return None
@ -127,9 +132,12 @@ class WebServiceApiService:
response = WebServiceApiService.__get('/subscriptions/current', billing_code, proxies) response = WebServiceApiService.__get('/subscriptions/current', billing_code, proxies)
if response.status_code == status_codes.OK: if response.status_code == status_codes.OK:
subscription = response.json()['data'] 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: else:
return None return None
@ -168,13 +176,38 @@ class WebServiceApiService:
response = WebServiceApiService.__get('/proxy-configurations/current', billing_code, proxies) response = WebServiceApiService.__get('/proxy-configurations/current', billing_code, proxies)
if response.status_code == status_codes.OK: if response.status_code == status_codes.OK:
proxy_configuration = response.json()['data'] 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']) return ProxyConfiguration(proxy_configuration['ip_address'], proxy_configuration['port'], proxy_configuration['username'], proxy_configuration['password'], proxy_configuration['location']['time_zone']['code'])
else: 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:
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 @staticmethod
def post_wireguard_session(country_code: str, location_code: str, billing_code: str, public_key: str, proxies: Optional[dict] = None): 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: else:
headers = None 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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

View file

@ -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"

View file

@ -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)