Compare commits

...

16 commits

Author SHA1 Message Date
474a6961b3 fix connection 2026-05-25 01:09:38 +00:00
9aa3518fec fix connection 2026-05-25 01:08:27 +00:00
29bd0e713b fix conection 2026-05-25 01:07:30 +00:00
c93a8572a1 ffi 2026-05-25 01:06:53 +00:00
db8a0f0c74 fix 2xx and subdomain 2026-05-25 00:40:11 +00:00
3077e8d525 fix add subdomain 2026-05-25 00:39:16 +00:00
2e7fce9c1c add subdomain operator 2026-05-25 00:02:57 +00:00
0d5fdf2d66 fix 2xx 2026-05-25 00:01:52 +00:00
866dda72e7 fix system-profile 2026-05-23 15:15:15 +00:00
918c070f73 fix system-profile 2026-05-23 15:14:09 +00:00
a6d412b589 fix subdomain 2026-05-23 04:40:13 +00:00
93fc03c6ca fix subdomain 2026-05-23 04:39:37 +00:00
811b285fe6 add install.sh 2026-05-23 03:12:33 +00:00
8ef8f9e1c9 feat: use SystemProfile path for sing-box config storage 2026-05-23 03:03:23 +00:00
89705bbab7 feat: use SystemProfile path for sing-box config storage 2026-05-23 03:02:36 +00:00
a3cf476af4 core-laravel-proxyTEST 2026-05-16 00:36:11 -05:00
26 changed files with 792 additions and 132 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,26 @@
from core.services.encrypted_proxy.hysteria_service import enable_hysteria, disable_hysteria
class HysteriaController:
def __init__(self, socks5_port: int):
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,
socks5_port=self.socks5_port,
observer=observer,
)
def disable(self, observer=None) -> bool:
return disable_hysteria(observer=observer)

View file

@ -0,0 +1,24 @@
from core.services.encrypted_proxy.vless_service import enable_vless, disable_vless
class VlessController:
def __init__(self, socks5_port: int):
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,
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,19 @@
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
operator_domain: Optional[str] = None
operator_hysteria2_host: Optional[str] = None
operator_vless_host: Optional[str] = None

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

@ -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,32 +68,22 @@ 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):

View file

@ -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,38 +44,54 @@ 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.')

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
@ -18,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']))
@ -32,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']))
@ -46,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']))
@ -60,26 +55,22 @@ 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['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
@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']))
@ -88,60 +79,57 @@ 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']))
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)
if response.status_code == status_codes.CREATED:
return Subscription(response.headers['X-Billing-Code'])
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 200 <= response.status_code < 300:
return Subscription(response.headers['X-Billing-Code'], operator_id=operator_id)
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, 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
@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']
@ -157,36 +145,55 @@ class WebServiceApiService:
return Invoice(billing_code, invoice['status'], invoice['expires_at'], tuple[PaymentMethod](payment_methods))
else:
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
@staticmethod
def post_operator_proxy(billing_code: str, operator_id: int, protocol: str, proxies: Optional[dict] = None):
response = WebServiceApiService.__post('/subscriptions/current/operator-proxies', billing_code, {
'operator_id': operator_id,
'protocol': protocol,
}, proxies)
if 200 <= response.status_code < 300:
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'],
data['operator'].get('domain'),
data['operator'].get('hysteria2_host'),
data['operator'].get('vless_host'),
)
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
@staticmethod

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,102 @@
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
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",
"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": server_host,
"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,
socks5_port: int, observer=None) -> bool:
real_ip = get_real_ip()
runner = SingboxRunner()
runner.stop()
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)
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,126 @@
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
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 build_vless_config(vless: dict, socks5_port: int) -> dict:
return {
"dns": {
"servers": [{"tag": "local", "type": "udp", "server": "9.9.9.9"}],
"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": vless["sni"],
"server_port": 443,
"uuid": vless["uuid"],
"tls": {
"enabled": True,
"server_name": vless["sni"],
"insecure": False,
},
"transport": {
"type": "ws",
"path": vless["path"],
"headers": {"Host": vless["sni"]},
},
},
],
"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,
socks5_port: int, observer=None) -> bool:
vless = parse_vless_link(vless_link)
real_ip = get_real_ip()
runner = SingboxRunner()
runner.stop()
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)
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)

173
install.sh Normal file
View file

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