Merge deploy into master: encrypted proxy killswitch + TUN optimization
This commit is contained in:
commit
59f997ef45
37 changed files with 1484 additions and 265 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ prototype_client.py
|
||||||
.venv
|
.venv
|
||||||
__pycache__
|
__pycache__
|
||||||
dist
|
dist
|
||||||
|
.env
|
||||||
163
README.md
163
README.md
|
|
@ -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
|
||||||
|
|
@ -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'
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -50,7 +52,10 @@ 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
|
||||||
|
|
||||||
if connection.needs_proxy_configuration() and not profile.has_proxy_configuration():
|
if connection.needs_proxy_configuration() and not profile.has_proxy_configuration():
|
||||||
|
|
@ -92,6 +97,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:
|
||||||
|
|
@ -158,6 +186,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:
|
||||||
|
|
@ -173,8 +219,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)
|
||||||
|
|
@ -183,31 +294,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()
|
||||||
|
|
@ -297,22 +401,34 @@ class ConnectionController:
|
||||||
return subprocess.Popen(('proxychains4', '-f', proxychains_configuration_file_path, 'microsocks', '-p', str(proxy_port_number)), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
|
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.')
|
||||||
|
|
||||||
|
|
@ -418,7 +534,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)
|
||||||
|
|
@ -491,3 +609,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')
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
@ -310,4 +311,4 @@ class ProfileController:
|
||||||
return dict(
|
return dict(
|
||||||
private=base64.b64encode(private_key).decode(),
|
private=base64.b64encode(private_key).decode(),
|
||||||
public=base64.b64encode(public_key).decode()
|
public=base64.b64encode(public_key).decode()
|
||||||
)
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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):
|
||||||
|
|
@ -21,4 +26,4 @@ class SystemStateController:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def dissolve():
|
def dissolve():
|
||||||
return SystemState.dissolve()
|
return SystemState.dissolve()
|
||||||
17
core/controllers/encrypted_proxy/DisableController.py
Normal file
17
core/controllers/encrypted_proxy/DisableController.py
Normal 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,
|
||||||
|
)
|
||||||
31
core/controllers/encrypted_proxy/HysteriaController.py
Normal file
31
core/controllers/encrypted_proxy/HysteriaController.py
Normal 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)
|
||||||
29
core/controllers/encrypted_proxy/VlessController.py
Normal file
29
core/controllers/encrypted_proxy/VlessController.py
Normal 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)
|
||||||
0
core/controllers/encrypted_proxy/__init__.py
Normal file
0
core/controllers/encrypted_proxy/__init__.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
20
core/models/OperatorProxySession.py
Normal file
20
core/models/OperatorProxySession.py
Normal 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
|
||||||
|
|
@ -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(
|
||||||
|
|
@ -37,4 +44,4 @@ class Subscription:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _iso_format(datetime_instance: datetime):
|
def _iso_format(datetime_instance: datetime):
|
||||||
return datetime.isoformat(datetime_instance).replace('+00:00', 'Z')
|
return datetime.isoformat(datetime_instance).replace('+00:00', 'Z')
|
||||||
|
|
@ -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
|
||||||
|
|
@ -94,4 +100,4 @@ class SubscriptionPlan(Model):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def tuple_factory(subscription_plan):
|
def tuple_factory(subscription_plan):
|
||||||
return subscription_plan.id, subscription_plan.code, subscription_plan.wireguard_session_limit, subscription_plan.duration, subscription_plan.price, subscription_plan.features_proxy, subscription_plan.features_wireguard
|
return subscription_plan.id, subscription_plan.code, subscription_plan.wireguard_session_limit, subscription_plan.duration, subscription_plan.price, subscription_plan.features_proxy, subscription_plan.features_wireguard
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,36 +68,26 @@ 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):
|
||||||
Path(self.get_proxy_configuration_path()).unlink(missing_ok=True)
|
Path(self.get_proxy_configuration_path()).unlink(missing_ok=True)
|
||||||
|
|
||||||
def __delete_wireguard_configuration(self):
|
def __delete_wireguard_configuration(self):
|
||||||
Path(self.get_wireguard_configuration_path()).unlink(missing_ok=True)
|
Path(self.get_wireguard_configuration_path()).unlink(missing_ok=True)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -19,33 +19,23 @@ class SystemProfile(BaseProfile):
|
||||||
return filepath
|
return filepath
|
||||||
|
|
||||||
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.')
|
||||||
|
|
||||||
|
|
@ -61,7 +51,6 @@ class SystemProfile(BaseProfile):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
|
|
@ -70,7 +59,6 @@ class SystemProfile(BaseProfile):
|
||||||
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')
|
||||||
|
|
||||||
|
|
@ -84,19 +72,42 @@ class SystemProfile(BaseProfile):
|
||||||
|
|
||||||
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')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
process = subprocess.run(('pkexec', 'rm', '-rf', self.get_wireguard_configuration_path()), check=True)
|
process = subprocess.run(('pkexec', 'rm', '-rf', self.get_wireguard_configuration_path()), check=True)
|
||||||
|
|
||||||
completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8)
|
completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
completed_successfully = True
|
completed_successfully = True
|
||||||
except:
|
except:
|
||||||
completed_successfully = True
|
completed_successfully = True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
@ -53,4 +54,4 @@ class SystemState:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_state_path():
|
def __get_state_path():
|
||||||
return Constants.HV_STATE_HOME
|
return Constants.HV_STATE_HOME
|
||||||
|
|
@ -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 = []
|
||||||
8
core/observers/EncryptedProxyObserver.py
Normal file
8
core/observers/EncryptedProxyObserver.py
Normal 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 = []
|
||||||
|
|
@ -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):
|
||||||
|
|
@ -223,4 +231,4 @@ class WebServiceApiService:
|
||||||
if response.status_code == status_codes.OK:
|
if response.status_code == status_codes.OK:
|
||||||
return response.json()
|
return response.json()
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
0
core/services/encrypted_proxy/__init__.py
Normal file
0
core/services/encrypted_proxy/__init__.py
Normal file
26
core/services/encrypted_proxy/disable_service.py
Normal file
26
core/services/encrypted_proxy/disable_service.py
Normal 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"
|
||||||
143
core/services/encrypted_proxy/hysteria_service.py
Normal file
143
core/services/encrypted_proxy/hysteria_service.py
Normal 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
|
||||||
172
core/services/encrypted_proxy/vless_service.py
Normal file
172
core/services/encrypted_proxy/vless_service.py
Normal 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
|
||||||
0
core/utils/encrypted_proxy/__init__.py
Normal file
0
core/utils/encrypted_proxy/__init__.py
Normal file
72
core/utils/encrypted_proxy/dns.py
Normal file
72
core/utils/encrypted_proxy/dns.py
Normal 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
|
||||||
37
core/utils/encrypted_proxy/killswitch.py
Normal file
37
core/utils/encrypted_proxy/killswitch.py
Normal 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"
|
||||||
74
core/utils/encrypted_proxy/net.py
Normal file
74
core/utils/encrypted_proxy/net.py
Normal 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"
|
||||||
258
core/utils/encrypted_proxy/singbox.py
Normal file
258
core/utils/encrypted_proxy/singbox.py
Normal 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
|
||||||
|
|
@ -13,6 +13,7 @@ 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",
|
||||||
"pysocks ~= 1.7.1",
|
"pysocks ~= 1.7.1",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue