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
34 changed files with 766 additions and 143 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'
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

@ -13,14 +13,12 @@ from core.services.payment_phase.check_if_paid import _check_if_paid
from core.services.prepare_tickets.ticket_tracker import does_ticket_tracker_exist
from core.services.networking.send_data_to_server import send_data_to_server
from core.services.networking.make_url import make_url
# from core.utils.confirm_its_a_valid_key_choice import confirm_its_a_valid_key_choice
from core.utils.confirm_its_a_valid_key_choice import confirm_its_a_valid_key_choice
from core.services.helpers.valid_profile_quantity import valid_profile_quantity
from core.errors.exceptions import *
from core.errors.logger import logger
from core.controllers.tickets.TicketSyncController import sync_ticket_prices
from core.services.payment_phase.do_we_have_billing_id import do_we_have_billing_id
"""
Inputs: Which plan (key), which crypto, and how many profiles
@ -49,12 +47,6 @@ def initiate_payment(
invoice_data_object.add_error_code("already_exists")
return invoice_data_object
billing_id = do_we_have_billing_id()
if billing_id:
invoice_data_object.add_error_code("billing_code_exists")
invoice_data_object.temp_billing_code = billing_id
return invoice_data_object
rejected_choices = [None, "", False]
if how_many_profiles in rejected_choices:
@ -75,6 +67,18 @@ def initiate_payment(
invoice_data_object.add_error_code("no_keyplan")
return invoice_data_object
# confirm the key choice is among the choices from their sync file,
# and if not, then sync again, and try the results from that new file,
is_valid_key = confirm_its_a_valid_key_choice(which_key, ticket_observer)
if not is_valid_key:
sync_results = sync_ticket_prices(ticket_observer, connection_observer)
second_try_to_match = confirm_its_a_valid_key_choice(
which_key, ticket_observer
)
if not second_try_to_match:
invoice_data_object.add_error_code("invalid_key")
return invoice_data_object
# get & save the public key:
public_key_results = get_pub_key(which_key, connection_observer, "local")

View file

@ -51,9 +51,6 @@ def prepare_tickets(
ticket_observer.notify("failed_input", None)
return {"valid": False, "error_code": "failed_input"}
notification = "Preparing Cryptography Locally"
ticket_observer.notify("preparing", subject=notification)
# ok now we have the pre-reqs, let's use this high level orchestrator,
prep_results = ticket_prep_orchestrator(
how_many_profiles, ticket_observer, connection_observer

View file

@ -8,6 +8,7 @@ if TYPE_CHECKING:
from core.Constants import Constants
from core.observers.BaseObserver import BaseObserver
from core.services.networking.get_data_from_server import get_data_from_server
from core.services.helpers.save_sync_results import save_sync_results
from core.errors.logger import logger
@ -37,4 +38,7 @@ def sync_ticket_prices(
except:
return {"valid": False, "error_code": "sync_failed"}
did_it_save = save_sync_results(sync_results)
logger.debug(f"Inside the sync controller, did_it_save is {did_it_save}")
return sync_results

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(
@ -37,4 +44,4 @@ class Subscription:
@staticmethod
def _iso_format(datetime_instance: datetime):
return datetime.isoformat(datetime_instance).replace('+00:00', 'Z')
return datetime.isoformat(datetime_instance).replace('+00:00', 'Z')

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,36 +68,26 @@ class SessionProfile(BaseProfile):
return os.path.isfile(f'{self.get_config_path()}/wg.conf')
def address_security_incident(self):
super().address_security_incident()
self.__delete_wireguard_configuration()
def determine_timezone(self):
time_zone = None
if self.has_connection():
if self.connection.needs_proxy_configuration():
if self.has_proxy_configuration():
time_zone = self.get_proxy_configuration().time_zone
elif self.connection.needs_wireguard_configuration():
if self.has_wireguard_configuration():
time_zone = self.get_wireguard_configuration_metadata('TZ')
if time_zone is None and self.has_location():
time_zone = self.location.time_zone
if time_zone is None:
raise UnknownTimeZoneError('The preferred time zone could not be determined.')
return time_zone
def __delete_proxy_configuration(self):
Path(self.get_proxy_configuration_path()).unlink(missing_ok=True)
def __delete_wireguard_configuration(self):
Path(self.get_wireguard_configuration_path()).unlink(missing_ok=True)
Path(self.get_wireguard_configuration_path()).unlink(missing_ok=True)

View file

@ -4,64 +4,46 @@ 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]
def get_system_config_path(self):
filepath = self.__get_system_config_path(self.id)
the_id = self.id
return filepath
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.')
def get_wireguard_configuration_path(self):
filepath = f'{self.get_system_config_path()}/wg.conf'
return filepath
return f'{self.get_system_config_path()}/wg.conf'
def has_wireguard_configuration(self):
filepath = f'{self.get_system_config_path()}/wg.conf'
if os.path.isdir(os.path.dirname(filepath)):
return os.path.isfile(filepath)
else:
return False
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()
@ -70,40 +52,49 @@ class SystemProfile(BaseProfile):
self.__delete_wireguard_configuration()
except ProfileModificationError:
raise ProfileDeletionError('The WireGuard configuration could not be deleted.')
if shutil.which('pkexec') is None:
raise CommandNotFoundError('pkexec')
try:
process = subprocess.run(('pkexec', 'rm', '-rf', 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.')
except:
print("skipping the delete of the WG folder.")
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')
try:
process = subprocess.run(('pkexec', 'rm', '-rf', self.get_wireguard_configuration_path()), check=True)
completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8)
except subprocess.CalledProcessError as e:
completed_successfully = True
except:
completed_successfully = True
process = subprocess.Popen(('pkexec', 'rm', '-d', self.get_wireguard_configuration_path()))
completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8)
if not completed_successfully:
raise ProfileModificationError('The WireGuard configuration could not be deleted.')
@staticmethod
def __get_system_config_path(id: int):
config_path = f'{Constants.HV_SYSTEM_PROFILE_CONFIG_PATH}/{str(id)}'
return config_path
return f'{Constants.HV_SYSTEM_PROFILE_CONFIG_PATH}/{str(id)}'

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

@ -178,6 +178,8 @@ class WebServiceApiService:
data['operator']['id'],
data['operator']['name'],
data['operator'].get('domain'),
data['operator'].get('hysteria2_host'),
data['operator'].get('vless_host'),
)
return None

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

@ -96,7 +96,7 @@ def is_the_key_to_blame(
# invalid key
notification = f"Invalid Numbers on the public_key"
ticket_observer.notify("preparing", subject=notification)
return {"valid": False, "message": "invalid_key"}
return {"valid": True, "message": "invalid_key"}
new_public_key = get_new_pubkey_from_api(connection_observer)
@ -121,7 +121,7 @@ def is_the_key_to_blame(
if not result_of_comparison:
error_msg = "New key is the SAME as the old one."
ticket_observer.notify("preparing", subject=error_msg)
return {"valid": False, "comparison": "same"}
return {"valid": False, "message": "same"}
status_update = "New key is DIFFERENT from the old one!"
ticket_observer.notify("preparing", subject=status_update)
@ -142,9 +142,9 @@ def is_the_key_to_blame(
if quantity_results > 0:
logger.debug("Therefore, the new key works.")
return {"valid": True, "comparison": "different"}
return {"valid": True, "comparison": "different", "matters": False}
else:
logger.debug(
"Therefore, the new key doesn't help. It is different, but also produces invalid blind signatures."
)
return {"valid": False, "comparison": "different"}
return {"valid": False, "comparison": "different", "matters": False}

View file

@ -1,13 +0,0 @@
from core.utils.basic_operations.write_or_read_from_json import get_value_from_json_file
from core.Constants import Constants
def do_we_have_billing_id() -> str | None:
try:
billing_folder = Constants.HV_TICKETING_CONFIG_HOME
filepath = f"{billing_folder}/billing_choices.json"
billing_id = get_value_from_json_file(filepath, "temp_billing_code")
return billing_id
except:
return None

View file

@ -37,7 +37,7 @@ def get_from_server_and_save(
if status == True:
# extract:
public_key = public_key_results.get("data", False)
public_key = public_key_results.get("valid", False)
# save it:
did_it_save = write_string_to_text_file(public_key, file_path)

View file

@ -37,8 +37,6 @@ def ticket_prep_orchestrator(
}
# assuming we actually saved the unblinding factors,
notification = "Sending Blinded Package to the Server.."
ticket_observer.notify("preparing", subject=notification)
# then send the entire blinded list to the server to sign:
blind_signatures = send_blind_commitments(
@ -58,13 +56,9 @@ def ticket_prep_orchestrator(
else:
# regardless of the outcome of the verification, save all blind sigs, just in case, because the user can't get them again,
notification = "Saving the Server's Blind Replies"
ticket_observer.notify("preparing", subject=notification)
did_they_ALL_save = save_ALL_blind_sigs(blind_signatures)
# verify the server's blind signatures against the public key,
notification = "Evaluating the Server's Blind Replies"
ticket_observer.notify("preparing", subject=notification)
failed_validations = validate_blind_signatures(
blind_signatures, ticket_observer, connection_observer
)
@ -74,8 +68,6 @@ def ticket_prep_orchestrator(
# did verification of any of the blind sigs fail?
how_many_failed = len(failed_validations)
if how_many_failed >= 1:
notification = f"Verification failed for {how_many_failed} blind signatures."
ticket_observer.notify("preparing", subject=notification)
logger.debug(
f"Verification failed for {how_many_failed} blind signatures."
)
@ -87,8 +79,6 @@ def ticket_prep_orchestrator(
}
# Unblind the signatures & combine with unblinded commitment:
notification = f"Unblinding Signatures & Preparing Tickets..."
ticket_observer.notify("preparing", subject=notification)
did_prep_work = unblind_ALL_tickets(
blind_signatures, ticket_observer, connection_observer
)

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"

View file

@ -1,6 +1,6 @@
[project]
name = "sp-hydra-veil-core"
version = "2.3.4"
version = "2.3.0"
authors = [
{ name = "Simplified Privacy" },
]
@ -15,6 +15,7 @@ dependencies = [
"cryptography ~= 46.0.3",
"dataclasses-json ~= 0.6.7",
"marshmallow ~= 3.26.1",
"psutil ~= 7.1.3",
"pysocks ~= 1.7.1",
"python-dateutil ~= 2.9.0.post0",
"requests ~= 2.32.5",