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

View file

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

View file

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

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):
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 = """
'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

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

View file

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

View file

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

View file

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

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.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):

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)