Merge deploy into master: encrypted proxy killswitch + TUN optimization

This commit is contained in:
Zenaku 2026-06-25 15:44:12 -05:00
commit 59f997ef45
37 changed files with 1484 additions and 265 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ prototype_client.py
.venv
__pycache__
dist
.env

163
README.md
View file

@ -1,34 +1,163 @@
# sp-hydra-veil-core
# Hydra-Veil — June Major Update
The `sp-hydra-veil-core` library exposes core logic to higher-level components.
## Expanded Killswitch
## Build Instructions
`hydraveil-killswitch.sh` was added to the installer: a wrapper around `nftables` that blocks all traffic not routed through the tunnel. It defaults to `tun0`, but supports a configurable interface IP that the operator will send when creating the profile. The IP is currently hardcoded; it will be removed in the next commit.
### Presumptions
## One Connection at a Time
* Your system is configured to use the Simplified Privacy package registry [1].
First in, first out. When a new connection is established, the previous one receives a signal (`SIGUSR1`) and closes cleanly before the new one takes over.
### Prerequisites
## Unexpected Shutdowns
* `build`
* `twine`
If the terminal is closed or the machine is powered off, the process receives `SIGHUP` and performs a full disconnect: stops `sing-box`, clears state, and releases the network. No orphan processes.
To install them, activate your `venv`, if necessary, and run:
## Fewer Network Requests
```bash
pip install build twine
Unnecessary calls made when establishing and closing connections have been removed.
---
## Repositories
| Repo | Role |
|---|---|
| `core` | Business logic, controllers, models |
| `cli` | Command-line interface |
| `essentials` | Network modules (Tor, WireGuard, proxies) |
| `installer` | Installation scripts and killswitch |
---
---
# Hydra-Veil — Gran Actualización de Junio
## Killswitch ampliado
Se añadió `hydraveil-killswitch.sh` al installer: un wrapper sobre `nftables` que bloquea todo el tráfico que no pase por el túnel. Por defecto usa `tun0`, pero soporta una IP de interfaz configurable que el operador enviará al crear el perfil. Por ahora la IP está hardcodeada; se eliminará en el próximo commit.
## Una conexión a la vez
Primero en entrar, primero en salir. Cuando se establece una nueva conexión, la anterior recibe una señal (`SIGUSR1`) y se cierra limpiamente antes de que la nueva tome el control.
## Cierres inesperados
Si se cierra la terminal o se apaga el equipo, el proceso recibe `SIGHUP` y ejecuta la desconexión completa: para `sing-box`, limpia el estado y libera la red. Sin procesos huérfanos.
## Menos solicitudes de red
Se eliminaron llamadas innecesarias que se hacían al establecer y cerrar conexiones.
---
## Repositorios
| Repo | Rol |
|---|---|
| `core` | Lógica de negocio, controladores, modelos |
| `cli` | Interfaz de línea de comandos |
| `essentials` | Módulos de red (Tor, WireGuard, proxies) |
| `installer` | Scripts de instalación y killswitch |
---
---
## Uso rápido — Desde Core (innecesario, usar CLI)
## 1. Create billing code / Crear billing code
```python
python3 -c "
from core.services.WebServiceApiService import WebServiceApiService
subscription = WebServiceApiService.post_subscription(2, operator_id=<OPERATOR_ID>)
print('billing code:', subscription.billing_code)
"
```
### Build Steps
> Copy the printed `billing_code` — you'll need it in the connect step.
> Copiá el `billing_code` impreso, lo vas a necesitar en el paso de conexión.
* Activate your `venv`, if necessary, and run:
---
consultar estado de invoive
python3 -c "
from core.services.WebServiceApiService import WebServiceApiService
invoice = WebServiceApiService.get_invoice('BILLING_CODE_HERE')
print('status:', invoice.status)
"
## 2. Patch subscription / Parchear suscripción
```bash
rm -rf ./dist/*
python3 -m build
python3 -m twine upload --repository forgejo ./dist/*
sudo docker compose exec laravel php artisan tinker --execute="
\$sub = \App\Models\Subscription::orderBy('id', 'desc')->first();
\$sub->expires_at = '2027-12-31 23:59:59';
\$sub->duration = 720;
\$sub->payment_reference = 'bypass_' . uniqid();
\$sub->save();
echo 'OK' . PHP_EOL;
"
```
---
[1] https://forgejo.org/docs/v14.0/user/packages/pypi/#configuring-the-package-registry
## 3. Connect / Conectar
### VLESS
```python
python3 -c "
from core.services.WebServiceApiService import WebServiceApiService
from core.controllers.encrypted_proxy.VlessController import VlessController
from core.observers.EncryptedProxyObserver import EncryptedProxyObserver
observer = EncryptedProxyObserver()
observer.subscribe('connected', lambda e: print('connected:', e.subject))
observer.subscribe('error', lambda e: print('error:', e.subject))
observer.subscribe('disconnected', lambda e: print('disconnected:', e.subject))
session = WebServiceApiService.post_operator_proxy('MZKD-QWWI-1TS9-KHMD', 3, 'vless')
print('session:', session)
controller = VlessController(1080)
controller.enable(session.links[0], session.username, observer)
"
```
### Hysteria2
```python
python3 -c "
from core.services.WebServiceApiService import WebServiceApiService
from core.controllers.encrypted_proxy.HysteriaController import HysteriaController
from core.observers.EncryptedProxyObserver import EncryptedProxyObserver
observer = EncryptedProxyObserver()
observer.subscribe('connected', lambda e: print('connected:', e.subject))
observer.subscribe('error', lambda e: print('error:', e.subject))
observer.subscribe('disconnected', lambda e: print('disconnected:', e.subject))
session = WebServiceApiService.post_operator_proxy('MZKD-QWWI-1TS9-KHMD', 3, 'hysteria2')
print('session:', session)
controller = HysteriaController(1080)
controller.enable(session.username, session.password, session.operator_hysteria2_host, observer)
"
```
---
## 4. Disconnect / Desconectar
```python
python3 -c "
from core.controllers.encrypted_proxy.HysteriaController import HysteriaController
from core.observers.EncryptedProxyObserver import EncryptedProxyObserver
observer = EncryptedProxyObserver()
observer.subscribe('disconnected', lambda e: print('disconnected'))
controller = HysteriaController(1080)
controller.disable(observer)
"
```
sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf

View file

@ -5,53 +5,73 @@ import os
@dataclass(frozen=True)
class Constants:
# ticketing group:
TICKET_API_BASE_URL: Final[str] = os.environ.get(
TICKET_API_BASE_URL: Final[str] = os.environ.get(
"TICKET_API_BASE_URL", "https://ticket.hydraveil.net"
)
SP_API_BASE_URL: Final[str] = os.environ.get(
'SP_API_BASE_URL', 'https://fake.simplifiedprivacy.org/api/v1'
)
PING_URL: Final[str] = os.environ.get(
'PING_URL', 'https://fake.simplifiedprivacy.org/api/v1//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')
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')
# ── Paths base ────────────────────────────────────────────────────────────
HOME: Final[str] = os.path.expanduser('~')
SYSTEM_CONFIG_PATH: Final[str] = '/etc'
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'))
# ── XDG estándar ─────────────────────────────────────────────────────────
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_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'
# ── hydra-veil dirs ───────────────────────────────────────────────────────
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'
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_PROFILE_CONFIG_HOME: Final[str] = f'{HV_CONFIG_HOME}/profiles'
HV_PROFILE_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/profiles'
# ── sing-box ──────────────────────────────────────────────────────────────
SINGBOX_WRAPPER: Final[str] = os.environ.get(
'SINGBOX_WRAPPER', '/usr/local/bin/hydraveil-singbox'
)
SINGBOX_BIN: Final[str] = os.environ.get(
'SINGBOX_BIN', '/usr/bin/sing-box'
)
SINGBOX_CONFIG_DIR: Final[str] = f'{HV_DATA_HOME}/configs'
SINGBOX_PID_FILE: Final[str] = f'{HV_RUNTIME_DATA_HOME}/singbox.pid'
SINGBOX_LOG_FILE: Final[str] = f'{HV_RUNTIME_DATA_HOME}/singbox.log'
SINGBOX_TUN_IF: Final[str] = os.environ.get('SINGBOX_TUN_IF', 'tun0')
SINGBOX_INTERNAL_SUBNET: Final[str] = os.environ.get('SINGBOX_INTERNAL_SUBNET', '172.19.0.0/30')
SINGBOX_INTERNAL_ADDR: Final[str] = os.environ.get('SINGBOX_INTERNAL_ADDR', '172.19.0.1/30')
# 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"
# ── killswitch / dns wrappers ─────────────────────────────────────────────
KILLSWITCH_WRAPPER: Final[str] = os.environ.get(
'KILLSWITCH_WRAPPER', '/usr/local/bin/hydraveil-killswitch'
)
RESOLVECTL_WRAPPER: Final[str] = os.environ.get(
'RESOLVECTL_WRAPPER', '/usr/local/bin/hydraveil-resolvectl'
)
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'
VLESS_DNS_ENABLED: Final[bool] = os.environ.get('VLESS_DNS_ENABLED', 'true').lower() == 'true'
HYSTERIA2_DNS_ENABLED: Final[bool] = os.environ.get('HYSTERIA2_DNS_ENABLED', 'true').lower() == 'true'

View file

@ -1,102 +1,56 @@
class CommandNotFoundError(OSError):
def __init__(self, subject):
self.subject = subject
super().__init__(f"Command '{subject}' could not be found.")
class UnknownClientPathError(Exception):
pass
class UnknownClientVersionError(Exception):
pass
class UnknownConnectionTypeError(Exception):
pass
class UnknownTimeZoneError(Exception):
pass
class ConnectionTerminationError(Exception):
pass
class PolicyAssignmentError(Exception):
pass
class PolicyInstatementError(Exception):
pass
class PolicyRevocationError(Exception):
pass
class ProfileDeletionError(Exception):
pass
class ProfileModificationError(Exception):
pass
class ProfileStateConflictError(Exception):
pass
class ProfileActivationError(Exception):
pass
class ProfileDeactivationError(Exception):
pass
class UnsupportedApplicationVersionError(Exception):
pass
class ApplicationAlreadyInstalledError(Exception):
pass
class MissingLocationError(Exception):
pass
class MissingSubscriptionError(Exception):
pass
class InvalidSubscriptionError(Exception):
pass
class InvoiceNotFoundError(Exception):
pass
class InvoiceExpiredError(Exception):
pass
class InvoicePaymentFailedError(Exception):
pass
class ConnectionUnprotectedError(Exception):
pass
class FileIntegrityError(Exception):
pass
class EndpointVerificationError(Exception):
pass
class SingboxNotInstalledException(Exception):
pass
class PaymentRequiredError(Exception):
pass

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

@ -1,6 +1,5 @@
from collections.abc import Callable
from core.Constants import Constants
from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionUnprotectedError, ConnectionTerminationError, CommandNotFoundError
from core.controllers.ConfigurationController import ConfigurationController
from core.controllers.ProfileController import ProfileController
from core.controllers.SessionStateController import SessionStateController
@ -13,16 +12,19 @@ from core.services.WebServiceApiService import WebServiceApiService
from essentials.modules.TorModule import TorModule
from essentials.services.ConnectionService import ConnectionService
from pathlib import Path
from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionUnprotectedError, ConnectionTerminationError, CommandNotFoundError, PaymentRequiredError
from subprocess import CalledProcessError
from typing import Union, Optional, Any
import os
import random
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import time
from core.models.OperatorProxySession import OperatorProxySession
class ConnectionController:
@ -50,7 +52,10 @@ class ConnectionController:
@staticmethod
def establish_connection(profile: Union[SessionProfile, SystemProfile], ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None):
from core.controllers.ConnectionController import ConnectionController
ConnectionController.verify_singbox_installation()
connection = profile.connection
if connection.needs_proxy_configuration() and not profile.has_proxy_configuration():
@ -92,6 +97,29 @@ class ConnectionController:
raise MissingSubscriptionError()
if connection.needs_operator_proxy() and not profile.has_operator_proxy_session():
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 PaymentRequiredError()
profile.attach_operator_proxy_session(operator_proxy_session)
else:
raise MissingSubscriptionError()
if profile.is_session_profile():
try:
@ -158,6 +186,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:
@ -173,8 +219,73 @@ class ConnectionController:
return proxy_port_number or port_number
@staticmethod
def _signal_previous_process() -> None:
system_state = SystemStateController.get()
if system_state is None:
return
pid = system_state.pid
if pid is None or pid == os.getpid():
return
try:
os.kill(pid, signal.SIGUSR1)
time.sleep(0.5)
except (ProcessLookupError, PermissionError):
pass
@staticmethod
def establish_system_connection(profile: SystemProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None):
ConnectionController._signal_previous_process()
system_state = SystemStateController.get()
if system_state is not None:
try:
ConnectionController.terminate_system_connection(connection_observer=connection_observer)
except ConnectionTerminationError:
pass
if profile.connection.needs_operator_proxy():
operator_proxy_session = profile.get_operator_proxy_session()
protocol = profile.connection.get_protocol()
if protocol == 'vless':
from core.controllers.encrypted_proxy.VlessController import VlessController
from core.services.encrypted_proxy.vless_service import parse_vless_link
import socket
server_ip = operator_proxy_session.server_ip
if server_ip is None:
vless = parse_vless_link(operator_proxy_session.links[0])
server_ip = socket.gethostbyname(vless['host'])
ok = VlessController(1080).enable(
operator_proxy_session.links[0],
operator_proxy_session.username,
server_ip,
connection_observer
)
elif protocol == 'hysteria2':
from core.controllers.encrypted_proxy.HysteriaController import HysteriaController
import socket
server_ip = operator_proxy_session.server_ip
if server_ip is None:
server_ip = socket.gethostbyname(operator_proxy_session.operator_hysteria2_host)
ok = HysteriaController(1080).enable(
operator_proxy_session.username,
operator_proxy_session.password,
operator_proxy_session.operator_hysteria2_host,
server_ip,
connection_observer
)
else:
ok = False
if not ok:
raise ConnectionError('The connection could not be established.')
token = SystemStateController.create(profile.id)
if connection_observer is not None:
connection_observer.notify('connected_token', {'session_token': token})
return
if ConfigurationController.get_endpoint_verification_enabled():
ProfileController.verify_wireguard_endpoint(profile, ignore=ignore)
@ -183,31 +294,24 @@ class ConnectionController:
ConnectionController.__establish_system_connection(profile, connection_observer)
except ConnectionError:
try:
ConnectionController.terminate_system_connection()
except ConnectionTerminationError:
pass
raise ConnectionError('The connection could not be established.')
except CalledProcessError:
try:
ConnectionController.terminate_system_connection()
except ConnectionTerminationError:
pass
try:
ConnectionController.__establish_system_connection(profile, connection_observer)
except (ConnectionError, CalledProcessError):
try:
ConnectionController.terminate_system_connection()
except ConnectionTerminationError:
pass
raise ConnectionError('The connection could not be established.')
ConnectionController.terminate_tor_connection()
@ -297,22 +401,34 @@ class ConnectionController:
return subprocess.Popen(('proxychains4', '-f', proxychains_configuration_file_path, 'microsocks', '-p', str(proxy_port_number)), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
@staticmethod
def terminate_system_connection():
def terminate_system_connection(connection_observer: Optional[ConnectionObserver] = None):
system_state = SystemStateController.get()
if system_state is not None:
profile = ProfileController.get(system_state.profile_id)
if profile is not None and profile.connection.needs_operator_proxy():
protocol = profile.connection.get_protocol()
if protocol == 'vless':
from core.controllers.encrypted_proxy.VlessController import VlessController
VlessController(1080).disable(connection_observer)
elif protocol == 'hysteria2':
from core.controllers.encrypted_proxy.HysteriaController import HysteriaController
HysteriaController(1080).disable(connection_observer)
SystemState.dissolve()
return
if shutil.which('nmcli') is None:
raise CommandNotFoundError('nmcli')
if SystemStateController.exists():
process = subprocess.Popen(('nmcli', 'connection', 'delete', 'wg'), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8)
if completed_successfully or not ConnectionController.system_uses_wireguard_interface():
subprocess.run(('nmcli', 'connection', 'delete', 'hv-ipv6-sink'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
ConnectionController.terminate_tor_connection()
SystemState.dissolve()
if connection_observer is not None:
connection_observer.notify('disconnected', {})
else:
raise ConnectionTerminationError('The connection could not be terminated.')
@ -418,7 +534,9 @@ class ConnectionController:
except CalledProcessError:
raise ConnectionError('The connection could not be established.')
SystemStateController.create(profile.id)
token = SystemStateController.create(profile.id)
if connection_observer is not None:
connection_observer.notify('connected_token', {'session_token': token})
try:
ConnectionController.await_connection(connection_observer=connection_observer)
@ -491,3 +609,25 @@ class ConnectionController:
return True
return False
@staticmethod
def verify_singbox_installation():
import subprocess
import os
from core.Errors import SingboxNotInstalledException
singbox_binary = '/usr/bin/sing-box'
singbox_wrapper = '/usr/local/bin/hydraveil-singbox'
if not os.path.exists(singbox_binary):
raise SingboxNotInstalledException(f'sing-box binary not found at {singbox_binary}')
if not os.path.exists(singbox_wrapper):
raise SingboxNotInstalledException(f'sing-box wrapper not found at {singbox_wrapper}')
try:
subprocess.run([singbox_binary, 'version'], capture_output=True, timeout=5, check=True)
except subprocess.CalledProcessError:
raise SingboxNotInstalledException('sing-box binary check failed')
except subprocess.TimeoutExpired:
raise SingboxNotInstalledException('sing-box verification timeout')

View file

@ -1,4 +1,4 @@
from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionTerminationError, ProfileActivationError, ProfileDeactivationError, MissingLocationError, ConnectionUnprotectedError, EndpointVerificationError, ProfileStateConflictError
from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionTerminationError, ProfileActivationError, ProfileDeactivationError, MissingLocationError, ConnectionUnprotectedError, EndpointVerificationError, ProfileStateConflictError, PaymentRequiredError
from core.controllers.ApplicationController import ApplicationController
from core.controllers.ApplicationVersionController import ApplicationVersionController
from core.controllers.SessionStateController import SessionStateController
@ -86,51 +86,36 @@ class ProfileController:
profile_observer.notify('enabled', profile)
@staticmethod
def disable(profile: Union[SessionProfile, SystemProfile], explicitly: bool = True, ignore: tuple[type[Exception]] = (), profile_observer: ProfileObserver = None):
def disable(profile: Union[SessionProfile, SystemProfile], explicitly: bool = True, ignore: tuple[type[Exception]] = (), profile_observer: ProfileObserver = None, connection_observer: ConnectionObserver = None):
from core.controllers.ConnectionController import ConnectionController
if profile.is_session_profile():
if SessionStateController.exists(profile.id):
session_state = SessionStateController.get(profile.id)
if session_state is not None:
for port_number in session_state.network_port_numbers.tor:
ConnectionController.terminate_tor_session_connection(port_number)
session_state.dissolve(session_state.id)
if profile.is_system_profile():
subjects = ProfileController.get_all().values()
for subject in subjects:
if subject.is_session_profile():
if subject.connection.is_unprotected() and ProfileController.is_enabled(subject) and not ConnectionUnprotectedError in ignore:
raise ConnectionUnprotectedError('Disabling this system connection would leave one or more sessions exposed.')
if SystemStateController.exists():
system_state = SystemStateController.get()
if profile.id != system_state.profile_id:
raise ProfileDeactivationError('The profile could not be disabled.')
try:
ConnectionController.terminate_system_connection()
ConnectionController.terminate_system_connection(connection_observer=connection_observer)
except ConnectionTerminationError:
raise ProfileDeactivationError('The profile could not be disabled.')
if profile_observer is not None:
profile_observer.notify('disabled', profile, dict(
explicitly=explicitly,
))
profile_observer.notify('disabled', profile, dict(explicitly=explicitly))
time.sleep(1.0)
@ -156,13 +141,29 @@ class ProfileController:
if profile.has_subscription():
# Operator profiles (vless/hysteria2): check if already activated locally first.
# If not, poll the server — the payment may have been processed since last attempt.
if profile.subscription.operator_id is not None:
if profile.subscription.has_been_activated():
profile.save()
return
subscription = ConnectionController.with_preferred_connection(
profile.subscription.billing_code,
task=WebServiceApiService.get_subscription,
connection_observer=connection_observer
)
if subscription is not None:
profile.subscription = subscription
profile.save()
return
else:
raise PaymentRequiredError()
subscription = ConnectionController.with_preferred_connection(profile.subscription.billing_code, task=WebServiceApiService.get_subscription, connection_observer=connection_observer)
if subscription is not None:
profile.subscription = subscription
profile.save()
else:
raise InvalidSubscriptionError()
@ -310,4 +311,4 @@ class ProfileController:
return dict(
private=base64.b64encode(private_key).decode(),
public=base64.b64encode(public_key).decode()
)
)

View file

@ -5,7 +5,6 @@ from core.observers.ConnectionObserver import ConnectionObserver
from core.services.WebServiceApiService import WebServiceApiService
from typing import Union
class SubscriptionController:
@staticmethod
@ -18,4 +17,51 @@ class SubscriptionController:
def create(subscription_plan: SubscriptionPlan, profile: Union[SessionProfile, SystemProfile], connection_observer: ConnectionObserver = None):
from core.controllers.ConnectionController import ConnectionController
return ConnectionController.with_preferred_connection(subscription_plan.id, profile.location.id, task=WebServiceApiService.post_subscription, connection_observer=connection_observer)
if profile.location:
return ConnectionController.with_preferred_connection(
subscription_plan.id,
profile.location.id,
task=WebServiceApiService.post_subscription,
connection_observer=connection_observer
)
else:
return ConnectionController.with_preferred_connection(
subscription_plan.id,
operator_id=profile.connection.operator_id,
task=WebServiceApiService.post_subscription,
connection_observer=connection_observer
)
from core.controllers.ConnectionController import ConnectionController
if profile.location:
return ConnectionController.with_preferred_connection(
subscription_plan.id,
profile.location.id,
task=WebServiceApiService.post_subscription,
connection_observer=connection_observer
)
else:
return ConnectionController.with_preferred_connection(
subscription_plan.id,
task=WebServiceApiService.post_subscription,
connection_observer=connection_observer
)
from core.controllers.ConnectionController import ConnectionController
if profile.location:
# Para WireGuard (con location)
return ConnectionController.with_preferred_connection(
subscription_plan.id,
profile.location.id,
task=WebServiceApiService.post_subscription,
connection_observer=connection_observer
)
else:
return ConnectionController.with_preferred_connection(
subscription_plan.id,
task=WebServiceApiService.post_subscription,
connection_observer=connection_observer
)

View file

@ -1,3 +1,5 @@
import os
import uuid
from core.models.system.SystemState import SystemState
@ -13,7 +15,10 @@ class SystemStateController:
@staticmethod
def create(profile_id):
return SystemState(profile_id).save()
token = str(uuid.uuid4())
pid = os.getpid()
SystemState(profile_id, token, pid).save()
return token
@staticmethod
def update_or_create(system_state):
@ -21,4 +26,4 @@ class SystemStateController:
@staticmethod
def dissolve():
return SystemState.dissolve()
return SystemState.dissolve()

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,31 @@
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, server_ip: 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
if not server_ip:
if observer:
observer.notify("error", "Missing server_ip")
return False
return enable_hysteria(
username=username,
password=password,
server_host=server_host,
server_ip=server_ip,
socks5_port=self.socks5_port,
observer=observer,
)
def disable(self, observer=None) -> bool:
return disable_hysteria(observer=observer)

View file

@ -0,0 +1,29 @@
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, server_ip: 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
if not server_ip:
if observer:
observer.notify("error", "Missing server_ip")
return False
return enable_vless(
vless_link=vless_link,
username=username,
server_ip=server_ip,
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,20 @@
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
server_ip: Optional[str] = None # pre-resolved IP — avoids DNS leak at connect time

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

@ -53,6 +53,9 @@ class SubscriptionPlan(Model):
if connection.code == 'wireguard':
features_wireguard = True
if connection.code == 'operator':
features_proxy = True
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition)
return Model._query_one('SELECT * FROM subscription_plans WHERE features_proxy = ? AND features_wireguard = ? AND duration = ? LIMIT 1', SubscriptionPlan.factory, [features_proxy, features_wireguard, duration])
@ -76,6 +79,9 @@ class SubscriptionPlan(Model):
if connection.code == 'wireguard':
features_wireguard = True
if connection.code == 'operator':
features_proxy = True
return Model._query_all('SELECT * FROM subscription_plans WHERE features_proxy = ? AND features_wireguard = ?', SubscriptionPlan.factory, [features_proxy, features_wireguard])
@staticmethod
@ -94,4 +100,4 @@ class SubscriptionPlan(Model):
@staticmethod
def tuple_factory(subscription_plan):
return 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_plan.id, subscription_plan.code, subscription_plan.wireguard_session_limit, subscription_plan.duration, subscription_plan.price, subscription_plan.features_proxy, subscription_plan.features_wireguard

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

@ -1,15 +1,22 @@
from core.models.BaseConnection import BaseConnection
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class SystemConnection (BaseConnection):
class SystemConnection(BaseConnection):
operator_id: Optional[int] = field(default=None)
protocol: Optional[str] = field(default=None)
def __post_init__(self):
if self.code != 'wireguard':
if self.code not in ('wireguard', 'operator'):
raise ValueError('Invalid connection code.')
@staticmethod
def needs_proxy_configuration():
return False
def needs_operator_proxy(self):
return self.code == 'operator'
def get_protocol(self):
return self.protocol

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]
@ -19,33 +19,23 @@ class SystemProfile(BaseProfile):
return filepath
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.')
@ -61,7 +51,6 @@ class SystemProfile(BaseProfile):
return False
def address_security_incident(self):
super().address_security_incident()
self.__delete_wireguard_configuration()
@ -70,7 +59,6 @@ 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')
@ -84,19 +72,42 @@ class SystemProfile(BaseProfile):
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
completed_successfully = True
except:
completed_successfully = True

View file

@ -1,8 +1,8 @@
from core.Constants import Constants
from dataclasses import dataclass
from dataclasses import dataclass, field
from dataclasses_json import dataclass_json
from pathlib import Path
from typing import Self
from typing import Optional, Self
import json
import os
import pathlib
@ -11,6 +11,8 @@ import pathlib
@dataclass
class SystemState:
profile_id: int
session_token: Optional[str] = field(default=None)
pid: Optional[int] = field(default=None)
def save(self: Self):
@ -31,7 +33,6 @@ class SystemState:
system_state_file_contents = open(f'{SystemState.__get_state_path()}/system.json', 'r').read()
system_state_dict = json.loads(system_state_file_contents)
# noinspection PyUnresolvedReferences
return SystemState.from_dict(system_state_dict)
except (FileNotFoundError, ValueError, KeyError):
@ -53,4 +54,4 @@ class SystemState:
@staticmethod
def __get_state_path():
return Constants.HV_STATE_HOME
return Constants.HV_STATE_HOME

View file

@ -1,5 +1,9 @@
from essentials.observers.ConnectionObserver import ConnectionObserver as BaseConnectionObserver
class ConnectionObserver(BaseConnectionObserver):
pass
def __init__(self):
super().__init__()
self.on_connected = []
self.on_disconnected = []
self.on_error = []

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:
return None
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
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,37 +145,57 @@ class WebServiceApiService:
return Invoice(billing_code, invoice['status'], invoice['expires_at'], tuple[PaymentMethod](payment_methods))
else:
return None
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
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'),
data.get('server_ip'),
)
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
return None
@staticmethod
def __get(path, billing_code: Optional[str] = None, proxies: Optional[dict] = None):
@ -223,4 +231,4 @@ class WebServiceApiService:
if response.status_code == status_codes.OK:
return response.json()
else:
return None
return None

View file

@ -0,0 +1,26 @@
import subprocess
import time
from core.utils.encrypted_proxy.singbox import SingboxRunner
from core.utils.encrypted_proxy.dns import revert_dns_on_tun
from core.utils.encrypted_proxy import killswitch
def get_public_ip(timeout: int = 8) -> str:
endpoints = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
]
for url in endpoints:
try:
r = subprocess.run(
["curl", "-s", "--max-time", str(timeout), url],
capture_output=True,
timeout=timeout + 2,
)
ip = r.stdout.decode().strip()
if ip and "." in ip and not ip.startswith("unknown"):
return ip
except Exception:
continue
return "unknown"

View file

@ -0,0 +1,143 @@
import time
from pathlib import Path
from core.Constants import Constants
from core.utils.encrypted_proxy.singbox import SingboxRunner
from core.utils.encrypted_proxy.dns import wait_for_tun, set_dns_on_tun, revert_dns_on_tun
from core.utils.encrypted_proxy import killswitch
def build_hysteria_config(username: str, password: str,
server_host: str, socks5_port: int,
server_ip: str) -> dict:
return {
"dns": {
"servers": [{"tag": "tunnel-dns", "type": "udp", "server": "9.9.9.9"}],
"final": "tunnel-dns",
"strategy": "ipv4_only",
"independent_cache": True,
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": Constants.SINGBOX_TUN_IF,
"address": [Constants.SINGBOX_INTERNAL_ADDR],
"mtu": 9000,
"auto_route": True,
"stack": "gvisor",
},
{
"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": [Constants.SINGBOX_INTERNAL_SUBNET], "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": "tunnel-dns",
"auto_detect_interface": True,
},
}
def _cleanup_all() -> None:
try:
SingboxRunner().stop()
except Exception:
pass
try:
revert_dns_on_tun()
except Exception:
pass
try:
killswitch.disarm()
except Exception:
pass
time.sleep(1)
def enable_hysteria(username: str, password: str, server_host: str,
server_ip: str, socks5_port: int, observer=None) -> bool:
_cleanup_all()
runner = SingboxRunner()
config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json"
config = build_hysteria_config(username, password, server_host,
socks5_port, server_ip)
_connected = False
try:
runner.write_config(config_path, config)
if not runner.start(config_path):
if observer:
observer.notify("error", "sing-box not active after start")
return False
if not wait_for_tun(timeout=15.0):
if observer:
observer.notify("error", f"{Constants.SINGBOX_TUN_IF} did not appear after 15s")
return False
if not killswitch.arm(server_ip, Constants.SINGBOX_TUN_IF):
if observer:
observer.notify("error", "Failed to arm kill switch")
return False
# Fase 4 — DNS
_C = Constants()
dns_ok = set_dns_on_tun() if _C.HYSTERIA2_DNS_ENABLED else False
_connected = True
if observer:
observer.notify("connected", {
"tunnel_if": Constants.SINGBOX_TUN_IF,
"socks5_port": socks5_port,
"server_ip": server_ip,
"dns_enabled": _C.HYSTERIA2_DNS_ENABLED,
"dns_active": dns_ok,
})
return True
except Exception as e:
if observer:
observer.notify("error", str(e))
return False
finally:
if not _connected:
_cleanup_all()
def disable_hysteria(observer=None) -> bool:
revert_dns_on_tun()
SingboxRunner().stop()
killswitch.disarm()
if observer:
observer.notify("disconnected", {"tunnel_if": Constants.SINGBOX_TUN_IF})
return True

View file

@ -0,0 +1,172 @@
from urllib.parse import unquote
from pathlib import Path
import socket
import time
from core.Constants import Constants
from core.utils.encrypted_proxy.singbox import SingboxRunner
from core.utils.encrypted_proxy.dns import wait_for_tun, set_dns_on_tun, revert_dns_on_tun
from core.utils.encrypted_proxy import killswitch
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, server_ip: str) -> dict:
return {
"dns": {
"servers": [{"tag": "tunnel-dns", "type": "udp", "server": "9.9.9.9"}],
"final": "tunnel-dns",
"strategy": "ipv4_only",
"independent_cache": True,
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": Constants.SINGBOX_TUN_IF,
"address": [Constants.SINGBOX_INTERNAL_ADDR],
"mtu": 9000,
"auto_route": True,
"stack": "gvisor",
},
{
"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": server_ip,
"server_port": vless["port"],
"uuid": vless["uuid"],
"tls": {
"enabled": vless["security"] == "tls",
"server_name": vless["sni"],
"insecure": False,
},
"transport": {
"type": "ws",
"path": vless["path"],
"headers": {"Host": vless["ws_host"]},
},
},
],
"route": {
"rules": [
{"protocol": "dns", "action": "hijack-dns"},
{"ip_cidr": [Constants.SINGBOX_INTERNAL_SUBNET], "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": "tunnel-dns",
"auto_detect_interface": True,
},
}
def _cleanup_all() -> None:
try:
SingboxRunner().stop()
except Exception:
pass
try:
revert_dns_on_tun()
except Exception:
pass
try:
killswitch.disarm()
except Exception:
pass
time.sleep(1)
def enable_vless(vless_link: str, username: str, server_ip: str,
socks5_port: int, observer=None) -> bool:
_cleanup_all()
vless = parse_vless_link(vless_link)
runner = SingboxRunner()
config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json"
config = build_vless_config(vless, socks5_port, server_ip)
_connected = False
try:
runner.write_config(config_path, config)
if not runner.start(config_path):
if observer:
observer.notify("error", "sing-box not active after start")
return False
if not wait_for_tun(timeout=15.0):
if observer:
observer.notify("error", f"{Constants.SINGBOX_TUN_IF} did not appear after 15s")
return False
if not killswitch.arm(server_ip, Constants.SINGBOX_TUN_IF):
if observer:
observer.notify("error", "Failed to arm kill switch")
return False
_C = Constants()
dns_ok = set_dns_on_tun() if _C.VLESS_DNS_ENABLED else False
_connected = True
if observer:
observer.notify("connected", {
"tunnel_if": Constants.SINGBOX_TUN_IF,
"socks5_port": socks5_port,
"server_ip": server_ip,
"dns_enabled": _C.VLESS_DNS_ENABLED,
"dns_active": dns_ok,
})
return True
except Exception as e:
if observer:
observer.notify("error", str(e))
return False
finally:
if not _connected:
_cleanup_all()
def disable_vless(observer=None) -> bool:
revert_dns_on_tun()
SingboxRunner().stop()
killswitch.disarm()
if observer:
observer.notify("disconnected", {"tunnel_if": Constants.SINGBOX_TUN_IF})
return True

View file

View file

@ -0,0 +1,72 @@
import os
import subprocess
import time
from core.Constants import Constants
_C = Constants()
_SUDO_KW = dict(
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
env={**os.environ, "SUDO_ASKPASS": "/bin/false"},
text=True,
)
def is_resolved_active() -> bool:
try:
result = subprocess.run(
["systemctl", "is-active", "systemd-resolved"],
capture_output=True,
text=True,
timeout=5,
)
return result.stdout.strip() == "active"
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
def wait_for_tun(timeout: float = 15.0, interval: float = 0.5) -> bool:
elapsed = 0.0
while elapsed < timeout:
result = subprocess.run(
["ip", "link", "show", _C.SINGBOX_TUN_IF],
capture_output=True,
)
if result.returncode == 0:
return True
time.sleep(interval)
elapsed += interval
return False
def set_dns_on_tun() -> bool:
if not is_resolved_active():
print("[DNS] systemd-resolved is not active — DNS on tun skipped")
return False
result = subprocess.run(
["sudo", _C.RESOLVECTL_WRAPPER, "set", "9.9.9.9"],
**_SUDO_KW,
)
if result.returncode != 0:
print(f"[DNS] Failed to configure {_C.SINGBOX_TUN_IF}: {result.stderr.strip()}")
return False
return True
def revert_dns_on_tun() -> bool:
if not is_resolved_active():
return True
result = subprocess.run(
["sudo", _C.RESOLVECTL_WRAPPER, "revert"],
**_SUDO_KW,
)
if result.returncode != 0:
print(f"[DNS] Failed to revert {_C.SINGBOX_TUN_IF}: {result.stderr.strip()}")
return False
return True

View file

@ -0,0 +1,37 @@
import subprocess
from core.Constants import Constants
_C = Constants()
def arm(server_ip: str, tunnel_if: str) -> bool:
result = subprocess.run(
["sudo", _C.KILLSWITCH_WRAPPER, "arm", server_ip, tunnel_if],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"[killswitch] Failed to arm: {result.stderr.strip()}")
return False
return True
def disarm() -> bool:
result = subprocess.run(
["sudo", _C.KILLSWITCH_WRAPPER, "disarm"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"[killswitch] Failed to disarm: {result.stderr.strip()}")
return False
return True
def status() -> bool:
result = subprocess.run(
["sudo", _C.KILLSWITCH_WRAPPER, "status"],
capture_output=True,
text=True,
)
return result.returncode == 0 and result.stdout.strip() == "armed"

View file

@ -0,0 +1,74 @@
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:
endpoints = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
]
for url in endpoints:
try:
r = subprocess.run(
["curl", "-s", "--max-time", str(timeout), url],
capture_output=True,
timeout=timeout + 2,
)
ip = r.stdout.decode().strip()
if ip and not ip.startswith("unknown"):
return ip
except Exception:
continue
return "unknown"
def get_proxied_ip(socks5_port: int, timeout: int = 15) -> str:
endpoints = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
]
for url in endpoints:
try:
r = subprocess.run(
[
"curl", "-s",
"--max-time", str(timeout),
"--connect-timeout", "10",
"-x", f"socks5h://127.0.0.1:{socks5_port}",
url,
],
capture_output=True,
timeout=timeout + 2,
)
ip = r.stdout.decode().strip()
stderr = r.stderr.decode().strip()
if ip and not ip.startswith("<") and "." in ip:
return ip
if stderr:
print(f"[net] curl stderr ({url}): {stderr[:120]}")
except subprocess.TimeoutExpired:
print(f"[net] timeout via socks5 → {url}")
except Exception as e:
print(f"[net] error via socks5 → {url}: {e}")
return "unknown"
def verify_ip(socks5_port: int, retries: int = 5, delay: float = 3.0) -> str:
for attempt in range(1, retries + 1):
print(f"[net] verify_ip attempt {attempt}/{retries}...")
ip = get_proxied_ip(socks5_port)
if ip != "unknown":
return ip
if attempt < retries:
time.sleep(delay)
return "unknown"

View file

@ -0,0 +1,258 @@
import json
import os
import subprocess
import time
from pathlib import Path
from core.Constants import Constants
class SingboxRunner:
_WRAPPER = Constants.SINGBOX_WRAPPER
_PID_FILE = Path(Constants.SINGBOX_PID_FILE)
_LOG_FILE = Path(Constants.SINGBOX_LOG_FILE)
_CONFIG_DIR = Path(Constants.SINGBOX_CONFIG_DIR)
_START_TIMEOUT_S = 15.0
_START_POLL_S = 0.5
_START_INITIAL_S = 2.0
def start(self, config_path: Path) -> bool:
self.stop()
self._assert_config_safe(config_path)
self._ensure_log_file()
try:
subprocess.Popen(
['sudo', self._WRAPPER, str(config_path), 'start'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
)
except FileNotFoundError:
raise RuntimeError(
f'Wrapper not found: {self._WRAPPER}\n'
'Run the installer first.'
)
time.sleep(self._START_INITIAL_S)
deadline = time.monotonic() + self._START_TIMEOUT_S
while time.monotonic() < deadline:
if self.is_running():
return True
time.sleep(self._START_POLL_S)
raise RuntimeError(
f'sing-box did not start within {self._START_TIMEOUT_S}s.\n'
f'Last error:\n{self.get_last_error()}'
)
def stop(self) -> None:
self._kill_orphans()
try:
subprocess.run(
['sudo', self._WRAPPER, '', 'stop'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
timeout=15,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
self._emergency_kill()
def is_running(self) -> bool:
if not self._PID_FILE.exists():
try:
result = subprocess.run(
['pgrep', '-x', 'sing-box'],
capture_output=True,
text=True,
)
return result.returncode == 0
except OSError:
return False
try:
pid = int(self._PID_FILE.read_text().strip())
if pid <= 0:
raise ValueError
os.kill(pid, 0)
return True
except (ValueError, ProcessLookupError):
self._PID_FILE.unlink(missing_ok=True)
return False
except PermissionError:
return True
def get_last_error(self) -> str:
try:
result = subprocess.run(
['sudo', self._WRAPPER, '', 'log'],
capture_output=True,
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
)
return result.stdout.strip()
except Exception:
try:
lines = self._LOG_FILE.read_text(errors='replace').splitlines()
return '\n'.join(lines[-80:])
except OSError:
return ''
def write_config(self, config_path: Path, config: dict) -> None:
self._assert_config_safe(config_path)
config_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
tmp = config_path.with_suffix('.tmp')
try:
with open(tmp, 'w') as f:
json.dump(config, f, indent=2)
os.chmod(tmp, 0o600)
tmp.rename(config_path)
except Exception:
tmp.unlink(missing_ok=True)
raise
def _ensure_log_file(self) -> None:
self._LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
if not self._LOG_FILE.exists():
self._LOG_FILE.touch(mode=0o600)
def _assert_config_safe(self, config_path: Path) -> None:
try:
config_path.resolve().relative_to(self._CONFIG_DIR.resolve())
except ValueError:
raise ValueError(
f'Config outside the allowed directory.\n'
f' Config: {config_path}\n'
f' Allowed: {self._CONFIG_DIR}'
)
def _emergency_kill(self) -> None:
if not self._PID_FILE.exists():
return
try:
pid = int(self._PID_FILE.read_text().strip())
if pid > 0:
subprocess.run(
['sudo', 'kill', '-9', str(pid)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
timeout=5,
)
except (ValueError, OSError, subprocess.TimeoutExpired):
pass
finally:
self._PID_FILE.unlink(missing_ok=True)
def _kill_orphans(self) -> None:
try:
result = subprocess.run(
['pgrep', '-x', 'sing-box'],
capture_output=True,
text=True,
)
if result.returncode != 0:
return
pids = [
int(p)
for p in result.stdout.strip().splitlines()
if p.strip().isdigit()
]
if not pids:
return
subprocess.run(
['sudo', self._WRAPPER, '', 'stop'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
timeout=15,
)
deadline = time.monotonic() + 4.0
while time.monotonic() < deadline:
check = subprocess.run(
['pgrep', '-x', 'sing-box'],
capture_output=True,
)
if check.returncode != 0:
return
time.sleep(0.3)
for pid in pids:
try:
subprocess.run(
['sudo', 'kill', '-9', str(pid)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
timeout=5,
)
except (OSError, subprocess.TimeoutExpired):
pass
except (OSError, ValueError, subprocess.TimeoutExpired):
pass
try:
result = subprocess.run(
['pgrep', '-x', 'sing-box'],
capture_output=True,
text=True,
)
if result.returncode != 0:
return
pids = [
int(p)
for p in result.stdout.strip().splitlines()
if p.strip().isdigit()
]
if not pids:
return
subprocess.run(
['sudo', self._WRAPPER, '', 'stop'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
timeout=15,
)
deadline = time.monotonic() + 4.0
while time.monotonic() < deadline:
check = subprocess.run(
['pgrep', '-x', 'sing-box'],
capture_output=True,
)
if check.returncode != 0:
return
time.sleep(0.3)
except (OSError, ValueError, subprocess.TimeoutExpired):
pass

View file

@ -13,6 +13,7 @@ classifiers = [
]
dependencies = [
"cryptography ~= 46.0.3",
"python-dotenv ~= 1.0.0",
"dataclasses-json ~= 0.6.7",
"marshmallow ~= 3.26.1",
"pysocks ~= 1.7.1",