Compare commits

...

46 commits

Author SHA1 Message Date
aca954d364 small fix 20-06-26 2026-06-20 08:43:43 -05:00
7dca2744ce small fix 2026-06-19 06:42:08 -05:00
d6b122b975 June Major Update 2026-06-16 08:11:28 -05:00
c1fb1bc7c6 fix errors kill s. 2026-06-09 09:09:10 -05:00
76d3650979 fix hysteria and vless bug 2026-06-06 09:30:31 -05:00
9372fe4c26 remove .env.example 2026-06-05 18:45:13 -05:00
eff49cf1f7 radical change 2026-06-05 10:31:35 -05:00
399c66e3cb Verify Sing-box before the connection 2026-06-04 07:40:04 -05:00
ed0de4b2d9 Verify Sing-box before the connection 2026-06-04 07:38:55 -05:00
35d94908f4 Core configuration synchronized with the CLI 2026-06-03 17:54:29 -05:00
50bf6503d9 Actualizar lib/steps/singbox.sh 2026-06-02 18:24:02 +00:00
eaa28aada1 Actualizar lib/steps/repos.sh 2026-06-02 17:59:20 +00:00
2d958f9145 Actualizar lib/steps/repos.sh 2026-06-02 17:33:09 +00:00
24dacb2e33 Actualizar lib/steps/repos.sh 2026-06-02 17:02:54 +00:00
bf7bdfa4d7 Actualizar lib/steps/repos.sh 2026-06-02 17:00:21 +00:00
fde1d35f17 Actualizar lib/steps/repos.sh 2026-06-02 16:51:56 +00:00
dd7edfa4ed Actualizar lib/steps/repos.sh 2026-06-02 16:30:48 +00:00
58d7837c7f Actualizar lib/steps/repos.sh 2026-06-02 16:21:59 +00:00
c55706f573 Actualizar lib/steps/repos.sh 2026-06-02 16:11:04 +00:00
594f6a8d4b Actualizar lib/steps/repos.sh 2026-06-02 15:51:37 +00:00
55f2515874 Actualizar lib/steps.sh 2026-06-02 15:51:00 +00:00
5ef45ad6b4 2 july 2026 2026-06-02 07:53:09 -05:00
7387844371 Remove deleted files 2026-06-02 07:26:10 -05:00
bf8eb9d96c all support, core,cli,gui,essentiials 2026-06-02 07:19:50 -05:00
047c5b2ed0 all support, core,cli,gui,essentiials 2026-06-02 07:19:17 -05:00
693c3404ad Actualizar pyproject.toml 2026-05-29 13:05:19 +00:00
f38aa3b6f1 26-05-16-6:29 2026-05-26 18:29:24 -05:00
6849f1a46f Eliminar .env 2026-05-26 21:41:10 +00:00
c5c0e9837a add .env 2026-05-26 21:40:33 +00:00
86621021bc add installer 2026-05-26 16:31:16 -05:00
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
37 changed files with 1484 additions and 271 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ prototype_client.py
.venv .venv
__pycache__ __pycache__
dist 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` 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.
* `twine`
To install them, activate your `venv`, if necessary, and run: ## Fewer Network Requests
```bash Unnecessary calls made when establishing and closing connections have been removed.
pip install build twine
---
## 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 ```bash
rm -rf ./dist/* sudo docker compose exec laravel php artisan tinker --execute="
python3 -m build \$sub = \App\Models\Subscription::orderBy('id', 'desc')->first();
python3 -m twine upload --repository forgejo ./dist/* \$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) @dataclass(frozen=True)
class Constants: class Constants:
# ticketing group:
TICKET_API_BASE_URL: Final[str] = os.environ.get( TICKET_API_BASE_URL: Final[str] = os.environ.get(
"TICKET_API_BASE_URL", "https://ticket.hydraveil.net" "TICKET_API_BASE_URL", "https://ticket.hydraveil.net"
) )
SP_API_BASE_URL: Final[str] = 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') # ── Paths base ────────────────────────────────────────────────────────────
PING_URL: Final[str] = os.environ.get('PING_URL', 'https://api.hydraveil.net/api/v1/health') 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')) # ── XDG estándar ─────────────────────────────────────────────────────────
MAX_CONNECTION_ATTEMPTS: Final[int] = int(os.environ.get('MAX_CONNECTION_ATTEMPTS', '2')) 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') # ── hydra-veil dirs ───────────────────────────────────────────────────────
HV_CLIENT_VERSION_NUMBER: Final[str] = os.environ.get('HV_CLIENT_VERSION_NUMBER') HV_SYSTEM_CONFIG_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/hydra-veil'
HV_CACHE_HOME: Final[str] = f'{CACHE_HOME}/hydra-veil'
HOME: Final[str] = os.path.expanduser('~') HV_CONFIG_HOME: Final[str] = f'{CONFIG_HOME}/hydra-veil'
HV_DATA_HOME: Final[str] = f'{DATA_HOME}/hydra-veil'
SYSTEM_CONFIG_PATH: Final[str] = '/etc' HV_STATE_HOME: Final[str] = f'{STATE_HOME}/hydra-veil'
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_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' # ── sing-box ──────────────────────────────────────────────────────────────
HV_PROFILE_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/profiles' 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: # ── killswitch / dns wrappers ─────────────────────────────────────────────
HV_TICKETING_CONFIG_HOME: Final[str] = f"{HV_CONFIG_HOME}/ticketing" KILLSWITCH_WRAPPER: Final[str] = os.environ.get(
HV_TICKETING_DATA_HOME: Final[str] = f"{HV_DATA_HOME}/ticket_data" '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' VLESS_DNS_ENABLED: Final[bool] = os.environ.get('VLESS_DNS_ENABLED', 'true').lower() == 'true'
HV_INCIDENT_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/incidents' HYSTERIA2_DNS_ENABLED: Final[bool] = os.environ.get('HYSTERIA2_DNS_ENABLED', 'true').lower() == 'true'
HV_RUNTIME_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/runtime'
HV_STORAGE_DATABASE_PATH: Final[str] = f'{HV_DATA_HOME}/storage.db'
HV_CAPABILITY_POLICY_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/apparmor.d/hydra-veil'
HV_PRIVILEGE_POLICY_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/sudoers.d/hydra-veil'
HV_SESSION_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/sessions'
HV_TOR_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/tor'

View file

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

View file

@ -25,7 +25,7 @@ class ConfigurationController:
configuration = ConfigurationController.get() configuration = ConfigurationController.get()
if configuration is None or configuration.connection not in ('system', 'tor'): if configuration is None or configuration.connection not in ('system', 'tor', 'vless', 'hysteria2'):
raise UnknownConnectionTypeError('The preferred connection type could not be determined.') raise UnknownConnectionTypeError('The preferred connection type could not be determined.')
return configuration.connection return configuration.connection

View file

@ -1,6 +1,5 @@
from collections.abc import Callable from collections.abc import Callable
from core.Constants import Constants from core.Constants import Constants
from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionUnprotectedError, ConnectionTerminationError, CommandNotFoundError
from core.controllers.ConfigurationController import ConfigurationController from core.controllers.ConfigurationController import ConfigurationController
from core.controllers.ProfileController import ProfileController from core.controllers.ProfileController import ProfileController
from core.controllers.SessionStateController import SessionStateController from core.controllers.SessionStateController import SessionStateController
@ -13,16 +12,19 @@ from core.services.WebServiceApiService import WebServiceApiService
from essentials.modules.TorModule import TorModule from essentials.modules.TorModule import TorModule
from essentials.services.ConnectionService import ConnectionService from essentials.services.ConnectionService import ConnectionService
from pathlib import Path from pathlib import Path
from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionUnprotectedError, ConnectionTerminationError, CommandNotFoundError, PaymentRequiredError
from subprocess import CalledProcessError from subprocess import CalledProcessError
from typing import Union, Optional, Any from typing import Union, Optional, Any
import os import os
import random import random
import re import re
import shutil import shutil
import signal
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time import time
from core.models.OperatorProxySession import OperatorProxySession
class ConnectionController: class ConnectionController:
@ -43,6 +45,9 @@ class ConnectionController:
@staticmethod @staticmethod
def establish_connection(profile: Union[SessionProfile, SystemProfile], ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None): 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 connection = profile.connection
@ -85,6 +90,29 @@ class ConnectionController:
raise MissingSubscriptionError() 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(): if profile.is_session_profile():
try: try:
@ -150,6 +178,24 @@ class ConnectionController:
ConnectionController.establish_wireguard_session_connection(profile, session_directory, port_number) ConnectionController.establish_wireguard_session_connection(profile, session_directory, port_number)
session_state.network_port_numbers.wireguard.append(port_number) session_state.network_port_numbers.wireguard.append(port_number)
elif profile.connection.code in ('vless', 'hysteria2'):
if not profile.has_operator_proxy_session():
raise MissingSubscriptionError()
operator_proxy_session = profile.get_operator_proxy_session()
port_number = ConnectionService.get_random_available_port_number()
if profile.connection.code == 'vless':
from core.controllers.encrypted_proxy.VlessController import VlessController
VlessController.enable(operator_proxy_session, port_number)
session_state.network_port_numbers.vless.append(port_number)
elif profile.connection.code == 'hysteria2':
from core.controllers.encrypted_proxy.HysteriaController import HysteriaController
HysteriaController.enable(operator_proxy_session, port_number)
session_state.network_port_numbers.hysteria2.append(port_number)
if profile.connection.masked: if profile.connection.masked:
while proxy_port_number is None or proxy_port_number == port_number: while proxy_port_number is None or proxy_port_number == port_number:
@ -165,8 +211,73 @@ class ConnectionController:
return proxy_port_number or port_number 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 @staticmethod
def establish_system_connection(profile: SystemProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None): def establish_system_connection(profile: SystemProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None):
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(): if ConfigurationController.get_endpoint_verification_enabled():
ProfileController.verify_wireguard_endpoint(profile, ignore=ignore) ProfileController.verify_wireguard_endpoint(profile, ignore=ignore)
@ -175,31 +286,24 @@ class ConnectionController:
ConnectionController.__establish_system_connection(profile, connection_observer) ConnectionController.__establish_system_connection(profile, connection_observer)
except ConnectionError: except ConnectionError:
try: try:
ConnectionController.terminate_system_connection() ConnectionController.terminate_system_connection()
except ConnectionTerminationError: except ConnectionTerminationError:
pass pass
raise ConnectionError('The connection could not be established.') raise ConnectionError('The connection could not be established.')
except CalledProcessError: except CalledProcessError:
try: try:
ConnectionController.terminate_system_connection() ConnectionController.terminate_system_connection()
except ConnectionTerminationError: except ConnectionTerminationError:
pass pass
try: try:
ConnectionController.__establish_system_connection(profile, connection_observer) ConnectionController.__establish_system_connection(profile, connection_observer)
except (ConnectionError, CalledProcessError): except (ConnectionError, CalledProcessError):
try: try:
ConnectionController.terminate_system_connection() ConnectionController.terminate_system_connection()
except ConnectionTerminationError: except ConnectionTerminationError:
pass pass
raise ConnectionError('The connection could not be established.') raise ConnectionError('The connection could not be established.')
ConnectionController.terminate_tor_connection() ConnectionController.terminate_tor_connection()
@ -289,22 +393,34 @@ class ConnectionController:
return subprocess.Popen(('proxychains4', '-f', proxychains_configuration_file_path, 'microsocks', '-p', str(proxy_port_number)), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) return subprocess.Popen(('proxychains4', '-f', proxychains_configuration_file_path, 'microsocks', '-p', str(proxy_port_number)), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
@staticmethod @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: if shutil.which('nmcli') is None:
raise CommandNotFoundError('nmcli') raise CommandNotFoundError('nmcli')
if SystemStateController.exists(): if SystemStateController.exists():
process = subprocess.Popen(('nmcli', 'connection', 'delete', 'wg'), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) process = subprocess.Popen(('nmcli', 'connection', 'delete', 'wg'), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8) completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8)
if completed_successfully or not ConnectionController.system_uses_wireguard_interface(): if completed_successfully or not ConnectionController.system_uses_wireguard_interface():
subprocess.run(('nmcli', 'connection', 'delete', 'hv-ipv6-sink'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.run(('nmcli', 'connection', 'delete', 'hv-ipv6-sink'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
ConnectionController.terminate_tor_connection() ConnectionController.terminate_tor_connection()
SystemState.dissolve() SystemState.dissolve()
if connection_observer is not None:
connection_observer.notify('disconnected', {})
else: else:
raise ConnectionTerminationError('The connection could not be terminated.') raise ConnectionTerminationError('The connection could not be terminated.')
@ -410,7 +526,9 @@ class ConnectionController:
except CalledProcessError: except CalledProcessError:
raise ConnectionError('The connection could not be established.') 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: try:
ConnectionController.await_connection(connection_observer=connection_observer) ConnectionController.await_connection(connection_observer=connection_observer)
@ -483,3 +601,25 @@ class ConnectionController:
return True return True
return False 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.ApplicationController import ApplicationController
from core.controllers.ApplicationVersionController import ApplicationVersionController from core.controllers.ApplicationVersionController import ApplicationVersionController
from core.controllers.SessionStateController import SessionStateController from core.controllers.SessionStateController import SessionStateController
@ -86,51 +86,36 @@ class ProfileController:
profile_observer.notify('enabled', profile) profile_observer.notify('enabled', profile)
@staticmethod @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 from core.controllers.ConnectionController import ConnectionController
if profile.is_session_profile(): if profile.is_session_profile():
if SessionStateController.exists(profile.id): if SessionStateController.exists(profile.id):
session_state = SessionStateController.get(profile.id) session_state = SessionStateController.get(profile.id)
if session_state is not None: if session_state is not None:
for port_number in session_state.network_port_numbers.tor: for port_number in session_state.network_port_numbers.tor:
ConnectionController.terminate_tor_session_connection(port_number) ConnectionController.terminate_tor_session_connection(port_number)
session_state.dissolve(session_state.id) session_state.dissolve(session_state.id)
if profile.is_system_profile(): if profile.is_system_profile():
subjects = ProfileController.get_all().values() subjects = ProfileController.get_all().values()
for subject in subjects: for subject in subjects:
if subject.is_session_profile(): if subject.is_session_profile():
if subject.connection.is_unprotected() and ProfileController.is_enabled(subject) and not ConnectionUnprotectedError in ignore: 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.') raise ConnectionUnprotectedError('Disabling this system connection would leave one or more sessions exposed.')
if SystemStateController.exists(): if SystemStateController.exists():
system_state = SystemStateController.get() system_state = SystemStateController.get()
if profile.id != system_state.profile_id: if profile.id != system_state.profile_id:
raise ProfileDeactivationError('The profile could not be disabled.') raise ProfileDeactivationError('The profile could not be disabled.')
try: try:
ConnectionController.terminate_system_connection() ConnectionController.terminate_system_connection(connection_observer=connection_observer)
except ConnectionTerminationError: except ConnectionTerminationError:
raise ProfileDeactivationError('The profile could not be disabled.') raise ProfileDeactivationError('The profile could not be disabled.')
if profile_observer is not None: 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) time.sleep(1.0)
@ -156,13 +141,29 @@ class ProfileController:
if profile.has_subscription(): 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) subscription = ConnectionController.with_preferred_connection(profile.subscription.billing_code, task=WebServiceApiService.get_subscription, connection_observer=connection_observer)
if subscription is not None: if subscription is not None:
profile.subscription = subscription profile.subscription = subscription
profile.save() profile.save()
else: else:
raise InvalidSubscriptionError() raise InvalidSubscriptionError()

View file

@ -5,7 +5,6 @@ from core.observers.ConnectionObserver import ConnectionObserver
from core.services.WebServiceApiService import WebServiceApiService from core.services.WebServiceApiService import WebServiceApiService
from typing import Union from typing import Union
class SubscriptionController: class SubscriptionController:
@staticmethod @staticmethod
@ -18,4 +17,51 @@ class SubscriptionController:
def create(subscription_plan: SubscriptionPlan, profile: Union[SessionProfile, SystemProfile], connection_observer: ConnectionObserver = None): def create(subscription_plan: SubscriptionPlan, profile: Union[SessionProfile, SystemProfile], connection_observer: ConnectionObserver = None):
from core.controllers.ConnectionController import ConnectionController 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 from core.models.system.SystemState import SystemState
@ -13,7 +15,10 @@ class SystemStateController:
@staticmethod @staticmethod
def create(profile_id): 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 @staticmethod
def update_or_create(system_state): def update_or_create(system_state):

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): def is_system_connection(self):
return type(self).__name__ == 'SystemConnection' return type(self).__name__ == 'SystemConnection'
def needs_operator_proxy(self):
return False

View file

@ -6,6 +6,7 @@ _table_name: str = 'operators'
_table_definition: str = """ _table_definition: str = """
'id' int UNIQUE, 'id' int UNIQUE,
'name' varchar, 'name' varchar,
'type' varchar,
'public_key' varchar, 'public_key' varchar,
'nostr_public_key' varchar, 'nostr_public_key' varchar,
'nostr_profile_reference' varchar, 'nostr_profile_reference' varchar,
@ -17,11 +18,18 @@ _table_definition: str = """
class Operator(Model): class Operator(Model):
id: int id: int
name: str name: str
type: str
public_key: str public_key: str
nostr_public_key: str nostr_public_key: str
nostr_profile_reference: str nostr_profile_reference: str
nostr_attestation_event_reference: str nostr_attestation_event_reference: str
def is_external(self) -> bool:
return self.type == 'external'
def is_internal(self) -> bool:
return self.type == 'internal'
@staticmethod @staticmethod
def find_by_id(id: int): def find_by_id(id: int):
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition)
@ -44,7 +52,7 @@ class Operator(Model):
@staticmethod @staticmethod
def save_many(operators): def save_many(operators):
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition)
Model._insert_many('INSERT INTO operators VALUES(?, ?, ?, ?, ?, ?)', Operator.tuple_factory, operators) Model._insert_many('INSERT INTO operators VALUES(?, ?, ?, ?, ?, ?, ?)', Operator.tuple_factory, operators)
@staticmethod @staticmethod
def factory(cursor, row): def factory(cursor, row):
@ -53,4 +61,4 @@ class Operator(Model):
@staticmethod @staticmethod
def tuple_factory(operator): def tuple_factory(operator):
return operator.id, operator.name, operator.public_key, operator.nostr_public_key, operator.nostr_profile_reference, operator.nostr_attestation_event_reference return operator.id, operator.name, operator.type, operator.public_key, operator.nostr_public_key, operator.nostr_profile_reference, operator.nostr_attestation_event_reference

View file

@ -0,0 +1,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 @dataclass
class Subscription: class Subscription:
billing_code: str billing_code: str
operator_id: Optional[int] = field(
default=None,
metadata=config(
undefined=dataclasses_json.Undefined.EXCLUDE,
exclude=lambda value: value is None
)
)
expires_at: Optional[datetime] = field( expires_at: Optional[datetime] = field(
default=None, default=None,
metadata=config( metadata=config(

View file

@ -53,6 +53,9 @@ class SubscriptionPlan(Model):
if connection.code == 'wireguard': if connection.code == 'wireguard':
features_wireguard = True features_wireguard = True
if connection.code == 'operator':
features_proxy = True
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition)
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]) 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': if connection.code == 'wireguard':
features_wireguard = True 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]) return Model._query_all('SELECT * FROM subscription_plans WHERE features_proxy = ? AND features_wireguard = ?', SubscriptionPlan.factory, [features_proxy, features_wireguard])
@staticmethod @staticmethod

View file

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

View file

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

View file

@ -22,35 +22,24 @@ class SessionProfile(BaseProfile):
return self.connection is not None return self.connection is not None
def save(self): def save(self):
if 'application_version' in self._get_dirty_keys(): if 'application_version' in self._get_dirty_keys():
persistent_state_path = f'{self.get_data_path()}/persistent-state' persistent_state_path = f'{self.get_data_path()}/persistent-state'
if os.path.isdir(persistent_state_path): if os.path.isdir(persistent_state_path):
shutil.rmtree(persistent_state_path, ignore_errors=True) shutil.rmtree(persistent_state_path, ignore_errors=True)
if 'location' in self._get_dirty_keys(): if 'location' in self._get_dirty_keys():
self.__delete_proxy_configuration() self.__delete_proxy_configuration()
self.__delete_wireguard_configuration() self.__delete_wireguard_configuration()
super().save() super().save()
def attach_proxy_configuration(self, proxy_configuration): def attach_proxy_configuration(self, proxy_configuration):
proxy_configuration_file_contents = f'{proxy_configuration.to_json(indent=4)}\n' proxy_configuration_file_contents = f'{proxy_configuration.to_json(indent=4)}\n'
os.makedirs(Constants.HV_CONFIG_HOME, exist_ok=True) os.makedirs(Constants.HV_CONFIG_HOME, exist_ok=True)
proxy_configuration_file_path = self.get_proxy_configuration_path() proxy_configuration_file_path = self.get_proxy_configuration_path()
with open(proxy_configuration_file_path, 'w') as proxy_configuration_file: with open(proxy_configuration_file_path, 'w') as proxy_configuration_file:
proxy_configuration_file.write(proxy_configuration_file_contents) proxy_configuration_file.write(proxy_configuration_file_contents)
def attach_wireguard_configuration(self, wireguard_configuration): def attach_wireguard_configuration(self, wireguard_configuration):
wireguard_configuration_file_path = self.get_wireguard_configuration_path() wireguard_configuration_file_path = self.get_wireguard_configuration_path()
with open(wireguard_configuration_file_path, 'w') as wireguard_configuration_file: with open(wireguard_configuration_file_path, 'w') as wireguard_configuration_file:
wireguard_configuration_file.write(wireguard_configuration) wireguard_configuration_file.write(wireguard_configuration)
@ -61,19 +50,15 @@ class SessionProfile(BaseProfile):
return f'{self.get_config_path()}/wg.conf' return f'{self.get_config_path()}/wg.conf'
def get_proxy_configuration(self): def get_proxy_configuration(self):
try: try:
config_file_contents = open(self.get_proxy_configuration_path(), 'r').read() config_file_contents = open(self.get_proxy_configuration_path(), 'r').read()
except FileNotFoundError: except FileNotFoundError:
return None return None
try: try:
proxy_configuration = json.loads(config_file_contents) proxy_configuration = json.loads(config_file_contents)
except ValueError: except ValueError:
return None return None
proxy_configuration = ProxyConfiguration.from_dict(proxy_configuration) proxy_configuration = ProxyConfiguration.from_dict(proxy_configuration)
return proxy_configuration return proxy_configuration
def has_proxy_configuration(self): def has_proxy_configuration(self):
@ -83,32 +68,22 @@ class SessionProfile(BaseProfile):
return os.path.isfile(f'{self.get_config_path()}/wg.conf') return os.path.isfile(f'{self.get_config_path()}/wg.conf')
def address_security_incident(self): def address_security_incident(self):
super().address_security_incident() super().address_security_incident()
self.__delete_wireguard_configuration() self.__delete_wireguard_configuration()
def determine_timezone(self): def determine_timezone(self):
time_zone = None time_zone = None
if self.has_connection(): if self.has_connection():
if self.connection.needs_proxy_configuration(): if self.connection.needs_proxy_configuration():
if self.has_proxy_configuration(): if self.has_proxy_configuration():
time_zone = self.get_proxy_configuration().time_zone time_zone = self.get_proxy_configuration().time_zone
elif self.connection.needs_wireguard_configuration(): elif self.connection.needs_wireguard_configuration():
if self.has_wireguard_configuration(): if self.has_wireguard_configuration():
time_zone = self.get_wireguard_configuration_metadata('TZ') time_zone = self.get_wireguard_configuration_metadata('TZ')
if time_zone is None and self.has_location(): if time_zone is None and self.has_location():
time_zone = self.location.time_zone time_zone = self.location.time_zone
if time_zone is None: if time_zone is None:
raise UnknownTimeZoneError('The preferred time zone could not be determined.') raise UnknownTimeZoneError('The preferred time zone could not be determined.')
return time_zone return time_zone
def __delete_proxy_configuration(self): def __delete_proxy_configuration(self):

View file

@ -1,15 +1,22 @@
from core.models.BaseConnection import BaseConnection from core.models.BaseConnection import BaseConnection
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Optional
@dataclass @dataclass
class SystemConnection (BaseConnection): class SystemConnection(BaseConnection):
operator_id: Optional[int] = field(default=None)
protocol: Optional[str] = field(default=None)
def __post_init__(self): def __post_init__(self):
if self.code not in ('wireguard', 'operator'):
if self.code != 'wireguard':
raise ValueError('Invalid connection code.') raise ValueError('Invalid connection code.')
@staticmethod @staticmethod
def needs_proxy_configuration(): def needs_proxy_configuration():
return False 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 core.models.system.SystemConnection import SystemConnection
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
import json
import os import os
import shutil import shutil
import subprocess import subprocess
@dataclass @dataclass
class SystemProfile(BaseProfile): class SystemProfile(BaseProfile):
connection: Optional[SystemConnection] connection: Optional[SystemConnection]
@ -17,33 +17,23 @@ class SystemProfile(BaseProfile):
return self.__get_system_config_path(self.id) return self.__get_system_config_path(self.id)
def save(self): def save(self):
if 'location' in self._get_dirty_keys(): if 'location' in self._get_dirty_keys():
self.__delete_wireguard_configuration() self.__delete_wireguard_configuration()
super().save() super().save()
def attach_wireguard_configuration(self, wireguard_configuration): def attach_wireguard_configuration(self, wireguard_configuration):
if shutil.which('pkexec') is None: if shutil.which('pkexec') is None:
raise CommandNotFoundError('pkexec') raise CommandNotFoundError('pkexec')
wireguard_configuration_file_backup_path = f'{self.get_config_path()}/wg.conf.bak' 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: with open(wireguard_configuration_file_backup_path, 'w') as wireguard_configuration_file:
wireguard_configuration_file.write(wireguard_configuration) wireguard_configuration_file.write(wireguard_configuration)
wireguard_configuration_is_attached = False wireguard_configuration_is_attached = False
failed_attempt_count = 0 failed_attempt_count = 0
while not wireguard_configuration_is_attached and failed_attempt_count < 3: 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')) 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) wireguard_configuration_is_attached = not bool(os.waitpid(process.pid, 0)[1] >> 8)
if not wireguard_configuration_is_attached: if not wireguard_configuration_is_attached:
failed_attempt_count += 1 failed_attempt_count += 1
if not wireguard_configuration_is_attached: if not wireguard_configuration_is_attached:
raise ProfileModificationError('The WireGuard configuration could not be attached.') raise ProfileModificationError('The WireGuard configuration could not be attached.')
@ -54,38 +44,54 @@ class SystemProfile(BaseProfile):
return os.path.isfile(f'{self.get_system_config_path()}/wg.conf') return os.path.isfile(f'{self.get_system_config_path()}/wg.conf')
def address_security_incident(self): def address_security_incident(self):
super().address_security_incident() super().address_security_incident()
self.__delete_wireguard_configuration() self.__delete_wireguard_configuration()
def delete(self): def delete(self):
try: try:
self.__delete_wireguard_configuration() self.__delete_wireguard_configuration()
except ProfileModificationError: except ProfileModificationError:
raise ProfileDeletionError('The WireGuard configuration could not be deleted.') raise ProfileDeletionError('The WireGuard configuration could not be deleted.')
if shutil.which('pkexec') is None: if shutil.which('pkexec') is None:
raise CommandNotFoundError('pkexec') raise CommandNotFoundError('pkexec')
process = subprocess.Popen(('pkexec', 'rm', '-d', self.get_system_config_path())) process = subprocess.Popen(('pkexec', 'rm', '-d', self.get_system_config_path()))
completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8) completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8)
if not completed_successfully: if not completed_successfully:
raise ProfileDeletionError('The profile could not be deleted.') raise ProfileDeletionError('The profile could not be deleted.')
super().delete() 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): def __delete_wireguard_configuration(self):
if self.has_wireguard_configuration(): if self.has_wireguard_configuration():
if shutil.which('pkexec') is None: if shutil.which('pkexec') is None:
raise CommandNotFoundError('pkexec') raise CommandNotFoundError('pkexec')
process = subprocess.Popen(('pkexec', 'rm', '-d', self.get_wireguard_configuration_path())) process = subprocess.Popen(('pkexec', 'rm', '-d', self.get_wireguard_configuration_path()))
completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8) completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8)
if not completed_successfully: if not completed_successfully:
raise ProfileModificationError('The WireGuard configuration could not be deleted.') raise ProfileModificationError('The WireGuard configuration could not be deleted.')

View file

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

View file

@ -1,5 +1,9 @@
from essentials.observers.ConnectionObserver import ConnectionObserver as BaseConnectionObserver from essentials.observers.ConnectionObserver import ConnectionObserver as BaseConnectionObserver
class ConnectionObserver(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.ClientVersion import ClientVersion
from core.models.Location import Location from core.models.Location import Location
from core.models.Operator import Operator from core.models.Operator import Operator
from core.models.OperatorProxySession import OperatorProxySession
from core.models.Subscription import Subscription from core.models.Subscription import Subscription
from core.models.SubscriptionPlan import SubscriptionPlan from core.models.SubscriptionPlan import SubscriptionPlan
from core.models.invoice.Invoice import Invoice from core.models.invoice.Invoice import Invoice
@ -18,12 +19,10 @@ class WebServiceApiService:
@staticmethod @staticmethod
def get_applications(proxies: Optional[dict] = None): 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) response = WebServiceApiService.__get('/platforms/linux-x86_64/applications', None, proxies)
applications = [] applications = []
if response.status_code == status_codes.OK: if 200 <= response.status_code < 300:
for application in response.json()['data']: for application in response.json()['data']:
applications.append(Application(application['code'], application['name'], application['id'])) applications.append(Application(application['code'], application['name'], application['id']))
@ -32,12 +31,10 @@ class WebServiceApiService:
@staticmethod @staticmethod
def get_application_versions(code: str, proxies: Optional[dict] = None): 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) response = WebServiceApiService.__get(f'/platforms/linux-x86_64/applications/{code}/application-versions', None, proxies)
application_versions = [] application_versions = []
if response.status_code == status_codes.OK: if 200 <= response.status_code < 300:
for application_version in response.json()['data']: 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'])) 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 @staticmethod
def get_client_versions(proxies: Optional[dict] = None): 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) response = WebServiceApiService.__get('/platforms/linux-x86_64/appimage/client-versions', None, proxies)
client_versions = [] client_versions = []
if response.status_code == status_codes.OK: if 200 <= response.status_code < 300:
for client_version in response.json()['data']: 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'])) 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 @staticmethod
def get_operators(proxies: Optional[dict] = None): def get_operators(proxies: Optional[dict] = None):
from requests.status_codes import codes as status_codes
response = WebServiceApiService.__get('/operators', None, proxies) response = WebServiceApiService.__get('/operators', None, proxies)
operators = [] operators = []
if response.status_code == status_codes.OK: if 200 <= response.status_code < 300:
for operator in response.json()['data']: for operator in response.json()['data']:
operators.append(Operator(operator['id'], operator['name'], operator['public_key'], operator['nostr_public_key'], operator['nostr_profile_reference'], operator['nostr_attestation']['event_reference'])) operators.append(Operator(operator['id'], operator['name'], operator['type'], operator['public_key'], operator['nostr_public_key'], operator['nostr_profile_reference'], operator['nostr_attestation']['event_reference']))
return operators return operators
@staticmethod @staticmethod
def get_locations(proxies: Optional[dict] = None): def get_locations(proxies: Optional[dict] = None):
from requests.status_codes import codes as status_codes
response = WebServiceApiService.__get('/locations', None, proxies) response = WebServiceApiService.__get('/locations', None, proxies)
locations = [] locations = []
if response.status_code == status_codes.OK: if 200 <= response.status_code < 300:
for location in response.json()['data']: 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'])) 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 @staticmethod
def get_subscription_plans(proxies: Optional[dict] = None): def get_subscription_plans(proxies: Optional[dict] = None):
from requests.status_codes import codes as status_codes
response = WebServiceApiService.__get('/subscription-plans', None, proxies) response = WebServiceApiService.__get('/subscription-plans', None, proxies)
subscription_plans = [] subscription_plans = []
if response.status_code == status_codes.OK: if 200 <= response.status_code < 300:
for subscription_plan in response.json()['data']: 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'])) 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 return subscription_plans
@staticmethod @staticmethod
def post_subscription(subscription_plan_id, location_id, proxies: Optional[dict] = None): def post_subscription(subscription_plan_id, location_id=None, operator_id=None, proxies: Optional[dict] = None):
from requests.status_codes import codes as status_codes body = {'subscription_plan_id': subscription_plan_id}
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'])
if operator_id is not None:
body['operator_id'] = operator_id
else: 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 @staticmethod
def get_subscription(billing_code: str, proxies: Optional[dict] = None): 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 = billing_code.replace('-', '').upper()
billing_code_fragments = re.findall('....?', billing_code) billing_code_fragments = re.findall('....?', billing_code)
billing_code = '-'.join(billing_code_fragments) billing_code = '-'.join(billing_code_fragments)
response = WebServiceApiService.__get('/subscriptions/current', billing_code, proxies) 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'] 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 @staticmethod
def get_invoice(billing_code: str, proxies: Optional[dict] = None): 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) 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'] response_data = response.json()['data']
@ -157,37 +145,57 @@ class WebServiceApiService:
return Invoice(billing_code, invoice['status'], invoice['expires_at'], tuple[PaymentMethod](payment_methods)) return Invoice(billing_code, invoice['status'], invoice['expires_at'], tuple[PaymentMethod](payment_methods))
else: return None
return None
@staticmethod @staticmethod
def get_proxy_configuration(billing_code: str, proxies: Optional[dict] = None): 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) 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'] proxy_configuration = response.json()['data']
return ProxyConfiguration(proxy_configuration['ip_address'], proxy_configuration['port'], proxy_configuration['username'], proxy_configuration['password'], proxy_configuration['location']['time_zone']['code']) return ProxyConfiguration(proxy_configuration['ip_address'], proxy_configuration['port'], proxy_configuration['username'], proxy_configuration['password'], proxy_configuration['location']['time_zone']['code'])
else: 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 @staticmethod
def post_wireguard_session(country_code: str, location_code: str, billing_code: str, public_key: str, proxies: Optional[dict] = None): def post_wireguard_session(country_code: str, location_code: str, billing_code: str, public_key: str, proxies: Optional[dict] = None):
from requests.status_codes import codes as status_codes
response = WebServiceApiService.__post(f'/countries/{country_code}/locations/{location_code}/wireguard-sessions', billing_code, { response = WebServiceApiService.__post(f'/countries/{country_code}/locations/{location_code}/wireguard-sessions', billing_code, {
'public_key': public_key, 'public_key': public_key,
}, proxies) }, proxies)
if response.status_code == status_codes.CREATED: if 200 <= response.status_code < 300:
return response.text return response.text
else:
return None return None
@staticmethod @staticmethod
def __get(path, billing_code: Optional[str] = None, proxies: Optional[dict] = None): def __get(path, billing_code: Optional[str] = None, proxies: Optional[dict] = 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,9 +13,9 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"cryptography ~= 46.0.3", "cryptography ~= 46.0.3",
"python-dotenv ~= 1.0.0",
"dataclasses-json ~= 0.6.7", "dataclasses-json ~= 0.6.7",
"marshmallow ~= 3.26.1", "marshmallow ~= 3.26.1",
"psutil ~= 7.1.3",
"pysocks ~= 1.7.1", "pysocks ~= 1.7.1",
"python-dateutil ~= 2.9.0.post0", "python-dateutil ~= 2.9.0.post0",
"requests ~= 2.32.5", "requests ~= 2.32.5",