Compare commits
110 commits
fix/wiregu
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b29a44cf95 | |||
| ced69bfa4f | |||
| f038f7a8da | |||
| 44558fb514 | |||
| fcf469bd03 | |||
| 0983d1a68d | |||
| 39efcaa174 | |||
| a69b87817c | |||
| 8968d89c5c | |||
| 7fa65f80ba | |||
| 90e7ee8da5 | |||
| 85db628921 | |||
| 6d8b457593 | |||
| e1221d4c45 | |||
| e18fec84f2 | |||
| 07f0cf8007 | |||
| fe19044928 | |||
| bdfb933548 | |||
| 5923cc8a7f | |||
| c8811c93bb | |||
| bdb4a285de | |||
| 24c4648054 | |||
| ff54373b0f | |||
| 6dac98835f | |||
| a78747e8e5 | |||
| 5ab57ab287 | |||
| 829feb5939 | |||
| 31488f790e | |||
| 1695e0314e | |||
| 73c74d339f | |||
| 1d8c508c52 | |||
| d9bee758a8 | |||
| b5e72ae7f6 | |||
| 951802fe5c | |||
| 01bd5f8d3f | |||
| b84bb2eefa | |||
| c291517b7f | |||
| c3b50460cd | |||
| 2a6b1eacd5 | |||
| 9d0d140467 | |||
| 3b1bf630f7 | |||
| 061df01a0d | |||
| 320037a2b1 | |||
| 94c4155d60 | |||
| 983924303d | |||
| 4fe26db1db | |||
| c4802d3c7d | |||
| 83857a0dcd | |||
| e981a748e5 | |||
| 1b609a9e5e | |||
| 8f184272b4 | |||
| 8da4978498 | |||
| a5f57f4f5e | |||
| b70818f774 | |||
| 46c8721a1a | |||
| 0e64351b5a | |||
| ed055f338d | |||
| fb43228f2f | |||
| 3db70541ab | |||
| d68ae02335 | |||
| bf22f764d0 | |||
| 26b2562415 | |||
| 5377b7348b | |||
| 46a84661a0 | |||
| bae5bfe7f3 | |||
| 7bde05d797 | |||
| 28e0bba1d8 | |||
| a76ef50ed7 | |||
| 19b3925bd1 | |||
| e37d351d4c | |||
| cb11e11e49 | |||
| c67f12a306 | |||
| 3afd4a8f9b | |||
| 1baaa4042c | |||
| 4d6f810100 | |||
| dbff960aad | |||
| ff493455b3 | |||
| 208cb29f77 | |||
| c4ffe9be3c | |||
| 9ba6d7e783 | |||
| bd34f456b5 | |||
| d8273171b5 | |||
| a116597a38 | |||
| 2ccbdba06b | |||
| 9740e0f53b | |||
| fd7949cea4 | |||
| fe3b7ad59e | |||
| 703fe57c12 | |||
| 00d6fc7738 | |||
| 5235f168a3 | |||
| 532a82f685 | |||
| c75026c834 | |||
| 4fb84b47d0 | |||
| 3977a5b695 | |||
| a085586448 | |||
| a9b6b9ffe3 | |||
| 0e9d82d2bb | |||
| 54c5ef9bf4 | |||
| 9ebad2cc7b | |||
| 1722905e62 | |||
| f919957e48 | |||
| 64d15ddd86 | |||
| 9358106d74 | |||
| b7cba470d3 | |||
| 632cc16ba1 | |||
| cc61784502 | |||
| 882f374937 | |||
| c6a189c360 | |||
| f7a05f8143 | |||
| a1409fd1d5 |
207 changed files with 13823 additions and 3982 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,8 +1,14 @@
|
|||
.env
|
||||
.dev
|
||||
prototype_client.py
|
||||
.idea
|
||||
.venv
|
||||
__pycache__
|
||||
__pycache__/
|
||||
dist
|
||||
.env
|
||||
docs.md
|
||||
.mypy_cache
|
||||
*.c
|
||||
*.so
|
||||
*.o
|
||||
*.egg-info/
|
||||
core/services/crypto/cython/build
|
||||
|
|
|
|||
171
README.md
171
README.md
|
|
@ -1,163 +1,34 @@
|
|||
# Hydra-Veil — June Major Update
|
||||
# sp-hydra-veil-core
|
||||
|
||||
## Expanded Killswitch
|
||||
The `sp-hydra-veil-core` library exposes core logic to higher-level components.
|
||||
|
||||
`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.
|
||||
## Build Instructions
|
||||
|
||||
## One Connection at a Time
|
||||
### Presumptions
|
||||
|
||||
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.
|
||||
* Your system is configured to use the Simplified Privacy package registry [1].
|
||||
|
||||
## Unexpected Shutdowns
|
||||
### Prerequisites
|
||||
|
||||
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.
|
||||
* `build`
|
||||
* `twine`
|
||||
|
||||
## Fewer Network Requests
|
||||
|
||||
Unnecessary calls made when establishing and closing connections have been removed.
|
||||
|
||||
---
|
||||
|
||||
## Repositories
|
||||
|
||||
| Repo | Role |
|
||||
|---|---|
|
||||
| `core` | Business logic, controllers, models |
|
||||
| `cli` | Command-line interface |
|
||||
| `essentials` | Network modules (Tor, WireGuard, proxies) |
|
||||
| `installer` | Installation scripts and killswitch |
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# Hydra-Veil — Gran Actualización de Junio
|
||||
|
||||
## Killswitch ampliado
|
||||
|
||||
Se añadió `hydraveil-killswitch.sh` al installer: un wrapper sobre `nftables` que bloquea todo el tráfico que no pase por el túnel. Por defecto usa `tun0`, pero soporta una IP de interfaz configurable que el operador enviará al crear el perfil. Por ahora la IP está hardcodeada; se eliminará en el próximo commit.
|
||||
|
||||
## Una conexión a la vez
|
||||
|
||||
Primero en entrar, primero en salir. Cuando se establece una nueva conexión, la anterior recibe una señal (`SIGUSR1`) y se cierra limpiamente antes de que la nueva tome el control.
|
||||
|
||||
## Cierres inesperados
|
||||
|
||||
Si se cierra la terminal o se apaga el equipo, el proceso recibe `SIGHUP` y ejecuta la desconexión completa: para `sing-box`, limpia el estado y libera la red. Sin procesos huérfanos.
|
||||
|
||||
## Menos solicitudes de red
|
||||
|
||||
Se eliminaron llamadas innecesarias que se hacían al establecer y cerrar conexiones.
|
||||
|
||||
---
|
||||
|
||||
## Repositorios
|
||||
|
||||
| Repo | Rol |
|
||||
|---|---|
|
||||
| `core` | Lógica de negocio, controladores, modelos |
|
||||
| `cli` | Interfaz de línea de comandos |
|
||||
| `essentials` | Módulos de red (Tor, WireGuard, proxies) |
|
||||
| `installer` | Scripts de instalación y killswitch |
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## Uso rápido — Desde Core (innecesario, usar CLI)
|
||||
|
||||
## 1. Create billing code / Crear billing code
|
||||
|
||||
```python
|
||||
python3 -c "
|
||||
from core.services.WebServiceApiService import WebServiceApiService
|
||||
subscription = WebServiceApiService.post_subscription(2, operator_id=<OPERATOR_ID>)
|
||||
print('billing code:', subscription.billing_code)
|
||||
"
|
||||
```
|
||||
|
||||
> 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.
|
||||
|
||||
---
|
||||
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
|
||||
To install them, activate your `venv`, if necessary, and run:
|
||||
|
||||
```bash
|
||||
sudo docker compose exec laravel php artisan tinker --execute="
|
||||
\$sub = \App\Models\Subscription::orderBy('id', 'desc')->first();
|
||||
\$sub->expires_at = '2027-12-31 23:59:59';
|
||||
\$sub->duration = 720;
|
||||
\$sub->payment_reference = 'bypass_' . uniqid();
|
||||
\$sub->save();
|
||||
echo 'OK' . PHP_EOL;
|
||||
"
|
||||
pip install build twine
|
||||
```
|
||||
|
||||
### Build Steps
|
||||
|
||||
* Activate your `venv`, if necessary, and run:
|
||||
|
||||
```bash
|
||||
rm -rf ./dist/*
|
||||
python3 -m build
|
||||
python3 -m twine upload --repository forgejo ./dist/*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
[1] https://forgejo.org/docs/v14.0/user/packages/pypi/#configuring-the-package-registry
|
||||
|
|
|
|||
24
assets/yaml_mappings/application_versions.yaml
Normal file
24
assets/yaml_mappings/application_versions.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
fields:
|
||||
- name: id
|
||||
path: ['id']
|
||||
required: true
|
||||
|
||||
- name: application_code
|
||||
path: ['application', 'code']
|
||||
required: true
|
||||
|
||||
- name: version_number
|
||||
path: ['version_number']
|
||||
required: true
|
||||
|
||||
- name: format_revision
|
||||
path: ['format_revision']
|
||||
|
||||
- name: download_path
|
||||
path: ['download_path']
|
||||
|
||||
- name: released_at
|
||||
path: ['released_at']
|
||||
|
||||
- name: file_hash
|
||||
path: ['file_hash']
|
||||
3
broken_pipe_test.py
Normal file
3
broken_pipe_test.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from core.utils.run_commands import test_broken_pipe_on_process_death
|
||||
|
||||
test_broken_pipe_on_process_death()
|
||||
145
change_log.md
Normal file
145
change_log.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Major Change Log:
|
||||
|
||||
# Use Ticket Orchestration
|
||||
### Aug 23, 2026
|
||||
Large changes to the flow for using tickets. More formalized updating of JSON with explicit tools, instead of generic JSON functions. Orchestration returns Return objects, which are then formatted to dict for the GUI.
|
||||
<br/>
|
||||
|
||||
# Manual WG Enable
|
||||
### Aug 23, 2026
|
||||
Manual Explicit Wireguard enable via nmcli for Improved Cross-Linux distro support.
|
||||
<br/>
|
||||
|
||||
# Cython Introduced
|
||||
### Aug 22, 2026
|
||||
Cython for Miller's Loop Cryptography first introduced, which dramatically reduces verification times. Includes a setup.py option for manual builds if desired. README for manual compile if desired. And a fallback import using the legacy python-only system.
|
||||
<br/>
|
||||
|
||||
# Vless Introduced
|
||||
### Aug 20, 2026
|
||||
Vless is now working. Config Parsing and setup is stable, and increased the wait time for connection testing. But this is a temporary solution. The real answer is not a static time check, but a dynamic result reading for when to test. Which then has it's own timeout time
|
||||
<br/>
|
||||
|
||||
# Assassin Introduced
|
||||
### Aug 18, 2026
|
||||
Assassin mode Introduced! The assassin_tools allows for creation/use of a session profile with random attributes, ephemeral wireguard config, and coordinates using a ticket for it. This tools module also allows for wiping the assassin's ticket & wireguard config on disable. Additionally, this git commit sees ProfileController using these assassin_tools functions for a working implementation if the profile is marked "assassin=True". Also the associated eco-system of ApplicationController starting apps, the existing non-ticket legacy subscription handlers, and even GUI needed to be adjusted to allow for this new assassin flow. Finally, the generic JSON utilities have been improved to make this feature more robust and reliable.
|
||||
<br/>
|
||||
|
||||
# Respawn on Delete
|
||||
### Aug 15, 2026
|
||||
When a profile is deleted, it now automatically respawns the ticket if it's a ticket profile. Also fixed some bugs with fetching the server's public key with the APIResponse object being used, when it expected a regular dictionary. This is a left-over from the prior transition to API objects.
|
||||
<br/>
|
||||
|
||||
# Codes/Tickets
|
||||
### Aug 15, 2026
|
||||
The profile object now has a ticket field. It's now none by default. Also the profile object is passed in, for the use of tickets, both for GUI and core.
|
||||
<br/>
|
||||
|
||||
# Wipe & Respawn Codes/Tickets
|
||||
### Aug 14, 2026
|
||||
Introduced Ticket/Billing-Code Respawn, with dual paths on the ticket prep orchestration, it's helper functions, and the main prep controller. This introduced the functions, which were tested with a demo CLI client and local server, and appears to be stable. Server-side endpoints will be pushed as well. Note: This update couples profile slot ids more closely with which ticket slot id is being used for it, but they are not yet officially tied. In the future, they should be.
|
||||
<br/>
|
||||
|
||||
# Reduce Redundancy
|
||||
### Aug 13, 2026
|
||||
Isolated download file modules to their own module, then had both the install application versions (browsers), and install dependencies both use that same module.
|
||||
<br/>
|
||||
|
||||
# Singbox Setup
|
||||
### Aug 12, 2026
|
||||
Prepared Singbox setup modules, which includes installation, download, move to sudo folder, and sudo setup scripts. Added a `Dependency` model, endpoint, and the ability to sync that model. (Related note: Server-side prepared the endpoint, and stocked with real data.) And also the sync service modules were adjusted to handle new data types more smoothly, before they had errors. As part of that sync flow change, the CachedSync metadata model was transitioned to ints instead of strings, with default 0 values. This should in theory migrate all clients without further changes needed.
|
||||
|
||||
### Additional Changes:
|
||||
ApplicationController transitioned to the HTTPx Client system, instead of using requests library proxy for Tor. Also the GUI's observers now do strings to be more neutral to what object type is being downloaded (application_version objects vs dependency).
|
||||
<br/>
|
||||
|
||||
# Singbox Orchestration
|
||||
### Aug 10, 2026
|
||||
Significant progress made on singbox flow, we now have a working:
|
||||
|
||||
1) Configure via singbox_configure
|
||||
2) Enable/disable directly via singbox module
|
||||
3) Enable/disable with full orchestration via singbox_runner
|
||||
4) process id utils isolated
|
||||
5) Singbox takedown uses JSON & process id, but not yet coordinating a mismatch.
|
||||
|
||||
Also in this update:
|
||||
Transition traditional wireguard subscription post/get requests to the new HTTPx system.
|
||||
|
||||
|
||||
# WG Renegotiation
|
||||
### Aug 7, 2026
|
||||
Wireguard renegotiation now flows through the new HTTPx modules
|
||||
<br/>
|
||||
|
||||
# EncryptedProxy Configs
|
||||
### Aug 6, 2026
|
||||
EncryptedProxy configuration path setup, but not yet functional. Pydantic models for each protocol are being setup.
|
||||
<br/>
|
||||
|
||||
# Transition
|
||||
### Aug 6, 2026
|
||||
1) Transition ticket prep from old requests-based GET/POST system to the new httpx one.
|
||||
2) Adjusted server-side for application_versions, and then the client to match it. It now has a single endpoint, instead of 4 different ones.
|
||||
3) Have Tor bootstrap use a new config, but same data folder on first attempt.
|
||||
<br/>
|
||||
|
||||
# Sync on No Database Start
|
||||
### August 5, 2026
|
||||
Added a forced sync at startup if there's no DB pre-existing, to handle a bug in GUI with no application versions. Isolated application_version database calls to their own module. And fixed a bug in SyncController to catch if application_version updates and split them into the different tables.
|
||||
</br>
|
||||
|
||||
# Sync Now Integrated & ApplicationVersion Model Transition
|
||||
### August 4, 2026
|
||||
Sync from the main app streamlines into the Connect module, to do async bulk sync. Additionally ApplicationVersion was transitioned from manual SQL to the ORM, for easier compatibility with the newer sync system. The side effect functions of this have been tested to be stable.
|
||||
</br>
|
||||
|
||||
# Connect Module & Async
|
||||
### August 3, 2026
|
||||
Introduced Connect Module, which manages HTTPx Clients, solves network/DNS issues, and coordinates Tor Bootstraps. Introduced async & single endpoint full workflows. This version is stable and tested for DNS & Tor problems. Also modified Configuration to be Pydantic, instead of @dataclass_json, and changed the connection type to enums. Further, there's a new enum function to get the new enum types, but the legacy function exists for backwards compatability.
|
||||
|
||||
# HTTPx Client, ThreadPool, & Tor Management
|
||||
### August 2, 2026
|
||||
Elaborate HTTPx feature-rich strategy with HTTPx Client Reuse, Tor Management, Tor bootstrap monitor & retry. HTTPx clients can be managed across modules and reused. There's an elaborate HTTPx retry strategy based on status codes. Tor management tries to use the existing Tor default ports, then checks the Tor config, before finally bootstrapping. There is now a ThreadPool futures executor, which even works with Tor, to batch API requests.
|
||||
|
||||
|
||||
# Singbox
|
||||
### July 29, 2026
|
||||
Introduced the Singbox Enable Utility & Singbox Sudo Script
|
||||
<br/>
|
||||
|
||||
# 2.4.3
|
||||
### Systemwide Killswitch Introduced for Enable
|
||||
|
||||
### July 23, 2026
|
||||
Separation of performing subprocess commands, parsing them for error strings, and handling those errors as enum types. Systemwide error handling was introduced via a wrapper to all systemwide functions requiring the sudo scripts.
|
||||
<br/>
|
||||
|
||||
### July 20, 2026
|
||||
Introduced IP-interface-based killswitch support for systemwide wireguard, on enable only. This version is stable and working for enable only, with a "test" script in the fileslot. And fixed GUI's object save creation. Additionally rolled back Connection data models to previous non-enum version.
|
||||
<br/>
|
||||
|
||||
# 2.4.2
|
||||
### Surgery on ConnectionController
|
||||
Huge separation of ConnectionController & ProfileController into naked function modules for enabling a connection, registering wireguard keys, coordinating proxy configs, subscriptions, endpoint verification, and the logistical flow. Additionally enums were used for type checking, instead of polymorphic functions. This version is stable and confirmed working for systemwide and session wireguard, but not yet proxies.
|
||||
<br/>
|
||||
|
||||
# 2.4.1
|
||||
### Enums for Profile types.
|
||||
July 18, 2026
|
||||
Introduce Base profile types on load, which gradually will switch over on save/edits. Transition Connections to using Enum instead of raw strings. End goal here is to transition ConnectionController to use the Enums.
|
||||
<br/>
|
||||
|
||||
# 2.4.0
|
||||
July 15, 2026
|
||||
Robust Database Error Handling & Migrations System. For New Client upgrades (Operational Errors) & Old Clients with New JSON keys from an API (TypeErrors). We also added an SQL generic handling wrapper for use across any project or function.
|
||||
<br/>
|
||||
|
||||
# 2.3.9
|
||||
July 11, 2026
|
||||
Features: Revamp of GET/POST API requests for both Tor and Clearweb.
|
||||
Why: Move towards more clear Tor API error feedback. And reduce redundancy.
|
||||
Status: Stable, works for both clear/tor and GET/POST.
|
||||
Still Needs: UI feedback, DNS Tor improvements
|
||||
<br/>
|
||||
|
||||
|
|
@ -6,75 +6,86 @@ import os
|
|||
@dataclass(frozen=True)
|
||||
class Constants:
|
||||
|
||||
DB_VERSION_THIS_APP_WANTS = 1
|
||||
DB_VERSION_THIS_APP_WANTS = 2
|
||||
|
||||
# Fallback for development (running outside AppImage)
|
||||
fallback_non_appimage = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# appimage home
|
||||
APPDIR_HOME: Final[str] = os.environ.get('APPDIR', fallback_non_appimage)
|
||||
|
||||
# ── API ─────────────────────────────────────────────
|
||||
|
||||
# ticketing group:
|
||||
TICKET_API_BASE_URL: Final[str] = os.environ.get(
|
||||
TICKET_API_BASE_URL: Final[str] = os.environ.get(
|
||||
"TICKET_API_BASE_URL", "https://ticket.hydraveil.net"
|
||||
)
|
||||
SP_API_BASE_URL: Final[str] = os.environ.get(
|
||||
'SP_API_BASE_URL', 'https://fake.simplifiedprivacy.org/api/v1'
|
||||
)
|
||||
PING_URL: Final[str] = os.environ.get(
|
||||
'PING_URL', 'https://fake.simplifiedprivacy.org/api/v1//health'
|
||||
)
|
||||
CONNECTION_RETRY_INTERVAL: Final[int] = int(os.environ.get('CONNECTION_RETRY_INTERVAL', '5'))
|
||||
MAX_CONNECTION_ATTEMPTS: Final[int] = int(os.environ.get('MAX_CONNECTION_ATTEMPTS', '2'))
|
||||
HV_CLIENT_PATH: Final[str] = os.environ.get('HV_CLIENT_PATH')
|
||||
HV_CLIENT_VERSION_NUMBER: Final[str] = os.environ.get('HV_CLIENT_VERSION_NUMBER')
|
||||
|
||||
# ── Paths base ────────────────────────────────────────────────────────────
|
||||
HOME: Final[str] = os.path.expanduser('~')
|
||||
SYSTEM_CONFIG_PATH: Final[str] = '/etc'
|
||||
SP_API_BASE_URL: Final[str] = os.environ.get('SP_API_BASE_URL', 'https://api.hydraveil.net/api/v1')
|
||||
PING_URL: Final[str] = os.environ.get('PING_URL', 'https://api.hydraveil.net/api/v1/health')
|
||||
|
||||
# ── XDG estándar ─────────────────────────────────────────────────────────
|
||||
CACHE_HOME: Final[str] = os.environ.get('XDG_CACHE_HOME', os.path.join(HOME, '.cache'))
|
||||
CONFIG_HOME: Final[str] = os.environ.get('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
|
||||
DATA_HOME: Final[str] = os.environ.get('XDG_DATA_HOME', os.path.join(HOME, '.local/share'))
|
||||
STATE_HOME: Final[str] = os.environ.get('XDG_STATE_HOME', os.path.join(HOME, '.local/state'))
|
||||
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'))
|
||||
|
||||
# ── hydra-veil dirs ───────────────────────────────────────────────────────
|
||||
HV_SYSTEM_CONFIG_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/hydra-veil'
|
||||
HV_CACHE_HOME: Final[str] = f'{CACHE_HOME}/hydra-veil'
|
||||
HV_CONFIG_HOME: Final[str] = f'{CONFIG_HOME}/hydra-veil'
|
||||
HV_DATA_HOME: Final[str] = f'{DATA_HOME}/hydra-veil'
|
||||
HV_STATE_HOME: Final[str] = f'{STATE_HOME}/hydra-veil'
|
||||
HV_CLIENT_PATH: Final[str] = os.environ.get('HV_CLIENT_PATH')
|
||||
HV_CLIENT_VERSION_NUMBER: Final[str] = os.environ.get('HV_CLIENT_VERSION_NUMBER')
|
||||
|
||||
HOME: Final[str] = os.path.expanduser('~')
|
||||
|
||||
SYSTEM_CONFIG_PATH: Final[str] = '/etc'
|
||||
|
||||
CACHE_HOME: Final[str] = os.environ.get('XDG_CACHE_HOME', os.path.join(HOME, '.cache'))
|
||||
CONFIG_HOME: Final[str] = os.environ.get('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
|
||||
DATA_HOME: Final[str] = os.environ.get('XDG_DATA_HOME', os.path.join(HOME, '.local/share'))
|
||||
STATE_HOME: Final[str] = os.environ.get('XDG_STATE_HOME', os.path.join(HOME, '.local/state'))
|
||||
|
||||
HV_SYSTEM_CONFIG_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/hydra-veil'
|
||||
|
||||
HV_CACHE_HOME: Final[str] = f'{CACHE_HOME}/hydra-veil'
|
||||
HV_CONFIG_HOME: Final[str] = f'{CONFIG_HOME}/hydra-veil'
|
||||
HV_DATA_HOME: Final[str] = f'{DATA_HOME}/hydra-veil'
|
||||
HV_STATE_HOME: Final[str] = f'{STATE_HOME}/hydra-veil'
|
||||
|
||||
HV_SYSTEM_PROFILE_CONFIG_PATH: Final[str] = f'{HV_SYSTEM_CONFIG_PATH}/profiles'
|
||||
HV_PROFILE_CONFIG_HOME: Final[str] = f'{HV_CONFIG_HOME}/profiles'
|
||||
HV_PROFILE_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/profiles'
|
||||
HV_TICKETING_CONFIG_HOME: Final[str] = f'{HV_CONFIG_HOME}/ticketing'
|
||||
HV_TICKETING_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/ticket_data'
|
||||
HV_APPLICATION_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/applications'
|
||||
HV_INCIDENT_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/incidents'
|
||||
HV_RUNTIME_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/runtime'
|
||||
HV_STORAGE_DATABASE_PATH: Final[str] = f'{HV_DATA_HOME}/storage.db'
|
||||
HV_CAPABILITY_POLICY_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/apparmor.d/hydra-veil'
|
||||
HV_PRIVILEGE_POLICY_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/sudoers.d/hydra-veil'
|
||||
HV_SESSION_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/sessions'
|
||||
HV_TOR_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/tor'
|
||||
|
||||
HV_PROFILE_CONFIG_HOME: Final[str] = f'{HV_CONFIG_HOME}/profiles'
|
||||
HV_PROFILE_DATA_HOME: Final[str] = f'{HV_DATA_HOME}/profiles'
|
||||
|
||||
# ── ticketing ─────────────────────────────────────────────
|
||||
HV_TICKETING_CONFIG_HOME: Final[str] = f"{HV_CONFIG_HOME}/ticketing"
|
||||
HV_TICKETING_DATA_HOME: Final[str] = f"{HV_DATA_HOME}/ticket_data"
|
||||
TICKET_TRACKER_PATH = f"{HV_TICKETING_CONFIG_HOME}/ticket_tracker.json"
|
||||
# ── end of ticketing ──────────────────────────────────────
|
||||
|
||||
|
||||
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'
|
||||
|
||||
# ── killswitch / dns wrappers ─────────────────────────────────────────────
|
||||
KILLSWITCH_WRAPPER: Final[str] = os.environ.get(
|
||||
'KILLSWITCH_WRAPPER', '/opt/hydra-veil/killswitch'
|
||||
)
|
||||
|
||||
# ── sing-box ──────────────────────────────────────────────────────────────
|
||||
SINGBOX_WRAPPER: Final[str] = os.environ.get(
|
||||
'SINGBOX_WRAPPER', '/usr/local/bin/hydraveil-singbox'
|
||||
)
|
||||
SINGBOX_BIN: Final[str] = os.environ.get(
|
||||
'SINGBOX_BIN', '/usr/bin/sing-box'
|
||||
)
|
||||
SINGBOX_CONFIG_DIR: Final[str] = f'{HV_DATA_HOME}/configs'
|
||||
SINGBOX_PID_FILE: Final[str] = f'{HV_RUNTIME_DATA_HOME}/singbox.pid'
|
||||
SINGBOX_LOG_FILE: Final[str] = f'{HV_RUNTIME_DATA_HOME}/singbox.log'
|
||||
SINGBOX_TUN_IF: Final[str] = os.environ.get('SINGBOX_TUN_IF', 'tun0')
|
||||
SINGBOX_INTERNAL_SUBNET: Final[str] = os.environ.get('SINGBOX_INTERNAL_SUBNET', '172.19.0.0/30')
|
||||
SINGBOX_INTERNAL_ADDR: Final[str] = os.environ.get('SINGBOX_INTERNAL_ADDR', '172.19.0.1/30')
|
||||
SINGBOX_DEFAULT_DNS: Final[str] = "9.9.9.9"
|
||||
SINGBOX_OUTPUT: Final[str] = f"{HV_DATA_HOME}/sing-box-output.txt"
|
||||
SUDO_TARGET_FOLDER: Final[str] = "/opt/hydra-veil"
|
||||
# ── Tor ─────────────────────────────────────────────
|
||||
DEFAULT_TOR_PORT = 9050
|
||||
|
||||
# ── killswitch / dns wrappers ─────────────────────────────────────────────
|
||||
KILLSWITCH_WRAPPER: Final[str] = os.environ.get(
|
||||
'KILLSWITCH_WRAPPER', '/usr/local/bin/hydraveil-killswitch'
|
||||
)
|
||||
RESOLVECTL_WRAPPER: Final[str] = os.environ.get(
|
||||
'RESOLVECTL_WRAPPER', '/usr/local/bin/hydraveil-resolvectl'
|
||||
)
|
||||
|
||||
VLESS_DNS_ENABLED: Final[bool] = os.environ.get('VLESS_DNS_ENABLED', 'true').lower() == 'true'
|
||||
HYSTERIA2_DNS_ENABLED: Final[bool] = os.environ.get('HYSTERIA2_DNS_ENABLED', 'true').lower() == 'true'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,56 +1,102 @@
|
|||
class CommandNotFoundError(OSError):
|
||||
|
||||
def __init__(self, subject):
|
||||
|
||||
self.subject = subject
|
||||
super().__init__(f"Command '{subject}' could not be found.")
|
||||
|
||||
|
||||
class UnknownClientPathError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownClientVersionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownConnectionTypeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownTimeZoneError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ConnectionTerminationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PolicyAssignmentError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PolicyInstatementError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PolicyRevocationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProfileDeletionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProfileModificationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProfileStateConflictError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProfileActivationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProfileDeactivationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnsupportedApplicationVersionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ApplicationAlreadyInstalledError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingLocationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingSubscriptionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidSubscriptionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvoiceNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvoiceExpiredError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvoicePaymentFailedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ConnectionUnprotectedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FileIntegrityError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class EndpointVerificationError(Exception):
|
||||
pass
|
||||
class SingboxNotInstalledException(Exception):
|
||||
pass
|
||||
class PaymentRequiredError(Exception):
|
||||
pass
|
||||
1
core/assets/__init__.py
Normal file
1
core/assets/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
57
core/assets/sudo_scripts/dns
Normal file
57
core/assets/sudo_scripts/dns
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "[killswitch] ERROR: must be run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTION="${1:-}"
|
||||
IFACE="${2:-}"
|
||||
DNS="${3:-}"
|
||||
|
||||
|
||||
_check_prereqs() {
|
||||
[[ -z "$DNS" ]] && { echo "Error: DNS IP required" >&2; exit 1; }
|
||||
|
||||
# ✓ Check it's a valid IPv4 address:
|
||||
if ! [[ "$DNS" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
|
||||
echo "Error: Invalid IP: $DNS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ✓ checks interface exists before configuring
|
||||
if ! ip link show "$IFACE" &>/dev/null; then
|
||||
echo "Error: Interface $IFACE not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
_set_dns() {
|
||||
resolvectl dns "$IFACE" "$DNS" || {
|
||||
echo "ERROR: Failed to set DNS to $DNS" >&2
|
||||
return 1
|
||||
}
|
||||
resolvectl domain "$IFACE" '~.' || {
|
||||
echo "ERROR: Failed to set domain routing" >&2
|
||||
return 1
|
||||
}
|
||||
resolvectl default-route "$IFACE" true || {
|
||||
echo "ERROR: Failed to set default route" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
if [[ "$ACTION" == "set" ]]; then
|
||||
_check_prereqs
|
||||
_set_dns
|
||||
fi
|
||||
|
||||
# if [[ "$ACTION" == "revert" ]]; then
|
||||
# resolvectl revert "$IFACE" 2>/dev/null || true
|
||||
|
||||
# fi
|
||||
|
||||
if [[ "$ACTION" == "revert" ]]; then
|
||||
resolvectl revert "$IFACE" || true
|
||||
fi
|
||||
120
core/assets/sudo_scripts/firewall
Normal file
120
core/assets/sudo_scripts/firewall
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "[killswitch] ERROR: must be run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTION="${1:-}"
|
||||
SERVER_IP="${2:-}"
|
||||
TUNNEL_IF="${3:-}"
|
||||
INTERNAL_SUBNET="${4:-}"
|
||||
TABLE="hydraveil"
|
||||
|
||||
if [[ "$ACTION" != "arm" && "$ACTION" != "disarm" && "$ACTION" != "status" ]]; then
|
||||
echo "[killswitch] ERROR: invalid action '$ACTION'. Usage: arm <server_ip> <tunnel_if> [internal_subnet] | disarm | status" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
_default_iface() {
|
||||
ip route show default 2>/dev/null | awk 'NR==1{print $5}'
|
||||
}
|
||||
|
||||
_log() {
|
||||
local level="$1"; shift
|
||||
echo "[killswitch] [$level] $* — $(date '+%Y-%m-%d %H:%M:%S')" >&2
|
||||
}
|
||||
|
||||
if [[ "$ACTION" == "status" ]]; then
|
||||
if nft list table inet "$TABLE" &>/dev/null 2>&1; then
|
||||
echo "armed"
|
||||
else
|
||||
echo "disarmed"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$ACTION" == "disarm" ]]; then
|
||||
if nft list table inet "$TABLE" &>/dev/null 2>&1; then
|
||||
nft delete table inet "$TABLE"
|
||||
_log "INFO" "disarmed"
|
||||
else
|
||||
_log "INFO" "already disarmed"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -z "$SERVER_IP" ]] && { _log "ERROR" "server_ip required for arm"; exit 1; }
|
||||
[[ -z "$TUNNEL_IF" ]] && { _log "ERROR" "tunnel_if required for arm"; exit 1; }
|
||||
|
||||
if ! [[ "$SERVER_IP" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
|
||||
_log "ERROR" "invalid IPv4: $SERVER_IP"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IFS='.' read -r o1 o2 o3 o4 <<< "$SERVER_IP"
|
||||
for oct in "$o1" "$o2" "$o3" "$o4"; do
|
||||
if (( oct > 255 )); then
|
||||
_log "ERROR" "invalid IPv4 octet ($oct) in $SERVER_IP"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
WAN_IFACE=$(_default_iface)
|
||||
if [[ -z "$WAN_IFACE" ]]; then
|
||||
_log "ERROR" "could not detect primary network interface"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INTERNAL_RULE=""
|
||||
if [[ -n "$INTERNAL_SUBNET" ]]; then
|
||||
INTERNAL_RULE=" ip daddr ${INTERNAL_SUBNET} accept"
|
||||
fi
|
||||
|
||||
nft list table inet "$TABLE" &>/dev/null 2>&1 && nft delete table inet "$TABLE" || true
|
||||
|
||||
nft -f - << NFTEOF
|
||||
table inet ${TABLE} {
|
||||
chain output {
|
||||
type filter hook output priority filter; policy drop;
|
||||
oifname "lo" accept
|
||||
ether type arp drop
|
||||
ip6 daddr != ::1 drop
|
||||
ip daddr 224.0.0.0/4 drop
|
||||
ip daddr 255.255.255.255 drop
|
||||
ip daddr 192.168.1.0/24 drop
|
||||
|
||||
oifname "${WAN_IFACE}" ip daddr ${SERVER_IP} accept
|
||||
oifname "${TUNNEL_IF}" accept
|
||||
log prefix "hydraveil-drop " drop
|
||||
}
|
||||
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
iifname "lo" accept
|
||||
|
||||
ether type arp drop
|
||||
ip6 saddr != ::1 drop
|
||||
ip daddr 224.0.0.0/4 drop
|
||||
ip daddr 255.255.255.255 drop
|
||||
|
||||
ct state established,related accept
|
||||
iifname "${WAN_IFACE}" ip saddr ${SERVER_IP} accept
|
||||
iifname "${TUNNEL_IF}" accept
|
||||
|
||||
log prefix "hydraveil-drop " drop
|
||||
}
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
}
|
||||
}
|
||||
NFTEOF
|
||||
|
||||
|
||||
_log "INFO" "armed — wan=${WAN_IFACE} server=${SERVER_IP} tunnel=${TUNNEL_IF} internal=${INTERNAL_SUBNET:-none}"
|
||||
exit 0
|
||||
|
||||
|
||||
|
||||
|
||||
103
core/assets/sudo_scripts/setup.sh
Normal file
103
core/assets/sudo_scripts/setup.sh
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
#!/bin/bash
|
||||
|
||||
ORIGINAL_FOLDER="${ORIGINAL_FOLDER:-$(pwd)}"
|
||||
TARGET_FOLDER="${TARGET_FOLDER:-/opt/hydra-veil}"
|
||||
LINUX_USER="${LINUX_USER:-$SUDO_USER}"
|
||||
|
||||
add_sudoers_rule() {
|
||||
sudo tee /etc/sudoers.d/zzzzzzzzzzzzzzzzzzz > /dev/null <<EOF
|
||||
${LINUX_USER} ALL=(root) NOPASSWD: /opt/hydra-veil/firewall
|
||||
${LINUX_USER} ALL=(root) NOPASSWD: /opt/hydra-veil/dns
|
||||
${LINUX_USER} ALL=(root) NOPASSWD: /opt/hydra-veil/singbox_wrapper
|
||||
EOF
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Error: This script must be run as root (use sudo)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate environment variables
|
||||
if [[ -z "$ORIGINAL_FOLDER" ]]; then
|
||||
echo "Error: ORIGINAL_FOLDER environment variable not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$TARGET_FOLDER" ]]; then
|
||||
echo "Error: TARGET_FOLDER environment variable not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate that source folder exists
|
||||
if [[ ! -d "$ORIGINAL_FOLDER" ]]; then
|
||||
echo "Error: ORIGINAL_FOLDER does not exist: $ORIGINAL_FOLDER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create target folder if it doesn't exist
|
||||
if [[ ! -d "$TARGET_FOLDER" ]]; then
|
||||
echo "Creating target folder: $TARGET_FOLDER"
|
||||
mkdir -p "$TARGET_FOLDER"
|
||||
fi
|
||||
|
||||
# Get the setup script's own filename, because we'll exclude it from being copied,
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
echo "Starting file copy from $ORIGINAL_FOLDER to $TARGET_FOLDER"
|
||||
echo "Script name to exclude: $SCRIPT_NAME"
|
||||
echo ""
|
||||
|
||||
# Counter for tracking progress
|
||||
COPIED=0
|
||||
SKIPPED=0
|
||||
|
||||
# Loop through all files in source folder
|
||||
while IFS= read -r -d '' file; do
|
||||
filename="$(basename "$file")"
|
||||
|
||||
# Skip if this is the script itself
|
||||
if [[ "$filename" == "$SCRIPT_NAME" ]]; then
|
||||
echo "⊘ Skipping: $filename (this script)"
|
||||
((SKIPPED++))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Copy the file
|
||||
if cp "$file" "$TARGET_FOLDER/"; then
|
||||
echo "✓ Copied: $filename"
|
||||
((COPIED++))
|
||||
|
||||
# Set permissions to 755
|
||||
if chmod 755 "$TARGET_FOLDER/$filename"; then
|
||||
echo " └─ chmod 755 applied"
|
||||
else
|
||||
echo " └─ WARNING: chmod 755 failed for $filename" >&2
|
||||
fi
|
||||
else
|
||||
echo "✗ ERROR: Failed to copy $filename" >&2
|
||||
((SKIPPED++))
|
||||
fi
|
||||
|
||||
done < <(find "$ORIGINAL_FOLDER" -maxdepth 1 -type f -print0)
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Copy complete!"
|
||||
echo "Files copied: $COPIED"
|
||||
echo "Files skipped: $SKIPPED"
|
||||
echo "Target folder: $TARGET_FOLDER"
|
||||
echo "=========================================="
|
||||
|
||||
# Try to apply the rules, if not emit 1
|
||||
if add_sudoers_rule; then
|
||||
echo "Operation successful"
|
||||
else
|
||||
echo "Operation failed - sudoers rule not applied"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
|
||||
|
||||
36
core/assets/sudo_scripts/singbox_wrapper
Normal file
36
core/assets/sudo_scripts/singbox_wrapper
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
SINGBOX_BIN="/opt/hydra-veil/sing-box"
|
||||
LOG_FILE=$VAR1
|
||||
|
||||
ACTION="${1:-}"
|
||||
PROFILE_OR_PID="${2:-}"
|
||||
|
||||
run_binary() {
|
||||
"$SINGBOX_BIN" run -c /etc/hydra-veil/profiles/"$PROFILE_OR_PID"/proxy.json >> "$LOG_FILE" 2>&1 &
|
||||
_new_pid=$!
|
||||
echo "$_new_pid"
|
||||
}
|
||||
# get result in python: pid = int(result.stdout.strip())
|
||||
|
||||
gracefully_close() {
|
||||
sudo kill -TERM "$PROFILE_OR_PID" 2>/dev/null || true
|
||||
}
|
||||
|
||||
forcefully_kill() {
|
||||
sudo kill -TERM "$PROFILE_OR_PID" 2>/dev/null || true
|
||||
}
|
||||
|
||||
|
||||
if [[ "$ACTION" == "arm" ]]; then
|
||||
run_binary
|
||||
fi
|
||||
|
||||
if [[ "$ACTION" == "disarm" ]]; then
|
||||
gracefully_close
|
||||
fi
|
||||
|
||||
if [[ "$ACTION" == "kill" ]]; then
|
||||
forcefully_kill
|
||||
fi
|
||||
1
core/assets/yaml_mappings/__init__.py
Normal file
1
core/assets/yaml_mappings/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
24
core/assets/yaml_mappings/application_versions.yaml
Normal file
24
core/assets/yaml_mappings/application_versions.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
fields:
|
||||
- name: id
|
||||
path: ['id']
|
||||
required: true
|
||||
|
||||
- name: application_code
|
||||
path: ['application', 'code']
|
||||
required: true
|
||||
|
||||
- name: version_number
|
||||
path: ['version_number']
|
||||
required: true
|
||||
|
||||
- name: format_revision
|
||||
path: ['format_revision']
|
||||
|
||||
- name: download_path
|
||||
path: ['download_path']
|
||||
|
||||
- name: released_at
|
||||
path: ['released_at']
|
||||
|
||||
- name: file_hash
|
||||
path: ['file_hash']
|
||||
54
core/assets/yaml_mappings/locations.yaml
Normal file
54
core/assets/yaml_mappings/locations.yaml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
fields:
|
||||
- name: country_code
|
||||
path: ['country', 'code']
|
||||
required: true
|
||||
|
||||
- name: code # this is city code
|
||||
path: ['code']
|
||||
required: true
|
||||
|
||||
- name: id
|
||||
path: ['id']
|
||||
|
||||
- name: country_name
|
||||
path: ['country', 'name']
|
||||
|
||||
- name: name # this is CITY name
|
||||
path: ['name']
|
||||
|
||||
- name: time_zone
|
||||
path: ['time_zone', 'code']
|
||||
|
||||
- name: operator_id
|
||||
path: ['operator_id']
|
||||
required: true
|
||||
|
||||
- name: provider_name
|
||||
path: ['provider', 'name']
|
||||
|
||||
- name: is_proxy_capable
|
||||
path: ['is_proxy_capable']
|
||||
|
||||
- name: is_wireguard_capable
|
||||
path: ['is_wireguard_capable']
|
||||
required: true
|
||||
|
||||
- name: is_hysteria2_capable
|
||||
path: ['is_hysteria2_capable']
|
||||
|
||||
- name: is_vless_capable
|
||||
path: ['is_vless_capable']
|
||||
|
||||
|
||||
# original version:
|
||||
# locations.append((
|
||||
# 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']))
|
||||
8
core/assets/yaml_mappings/mapping_one.yaml
Normal file
8
core/assets/yaml_mappings/mapping_one.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fields:
|
||||
- name: group_a_code
|
||||
path: ['group_a', 'code']
|
||||
- name: code
|
||||
path: ['code']
|
||||
- name: id
|
||||
path: ['id']
|
||||
required: true
|
||||
19
core/assets/yaml_mappings/operators.yaml
Normal file
19
core/assets/yaml_mappings/operators.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
fields:
|
||||
- name: id
|
||||
path: ['id']
|
||||
required: true
|
||||
|
||||
- name: name
|
||||
path: ['name']
|
||||
|
||||
- name: public_key # this is ed25519
|
||||
path: ['public_key']
|
||||
|
||||
- name: nostr_public_key
|
||||
path: ['nostr_public_key']
|
||||
|
||||
- name: nostr_profile_reference
|
||||
path: ['nostr_profile_reference']
|
||||
|
||||
- name: nostr_attestation_event_reference
|
||||
path: ['nostr_attestation', 'event_reference']
|
||||
|
|
@ -2,7 +2,10 @@ from core.Constants import Constants
|
|||
from core.Errors import CommandNotFoundError
|
||||
from core.controllers.SessionStateController import SessionStateController
|
||||
from core.models.session.Application import Application
|
||||
from core.models.session.ApplicationVersion import ApplicationVersion
|
||||
from core.models.orm_models.ApplicationVersion import ApplicationVersion
|
||||
from core.observers.ConnectionObserver import ConnectionObserver
|
||||
from core.observers.TicketObserver import TicketObserver
|
||||
|
||||
from core.models.session.SessionProfile import SessionProfile
|
||||
from core.models.session.SessionState import SessionState
|
||||
from core.observers.ProfileObserver import ProfileObserver
|
||||
|
|
@ -30,7 +33,7 @@ class ApplicationController:
|
|||
return Application.all()
|
||||
|
||||
@staticmethod
|
||||
def launch(version: ApplicationVersion, profile: SessionProfile, port_number: int = None, asynchronous: bool = False, profile_observer: Optional[ProfileObserver] = None):
|
||||
def launch(version: ApplicationVersion, profile: SessionProfile, port_number: int = None, asynchronous: bool = False, profile_observer: Optional[ProfileObserver] = None, connection_observer: ConnectionObserver = None, ticket_observer: TicketObserver = None):
|
||||
|
||||
from core.controllers.ProfileController import ProfileController
|
||||
|
||||
|
|
@ -96,7 +99,7 @@ class ApplicationController:
|
|||
if not fork_process_id:
|
||||
|
||||
ApplicationController.__run_process(initialization_file_path, profile, display, session_state)
|
||||
ProfileController.disable(profile, False, profile_observer=profile_observer)
|
||||
ProfileController.disable(profile, False, profile_observer=profile_observer, ticket_observer=ticket_observer, connection_observer=connection_observer)
|
||||
|
||||
time.sleep(1.0)
|
||||
sys.exit()
|
||||
|
|
@ -104,7 +107,7 @@ class ApplicationController:
|
|||
else:
|
||||
|
||||
ApplicationController.__run_process(initialization_file_path, profile, display, session_state)
|
||||
ProfileController.disable(profile, False, profile_observer=profile_observer)
|
||||
ProfileController.disable(profile, False, profile_observer=profile_observer, ticket_observer=ticket_observer, connection_observer=connection_observer)
|
||||
|
||||
@staticmethod
|
||||
def _sync(proxies: Optional[dict] = None):
|
||||
|
|
|
|||
|
|
@ -1,31 +1,56 @@
|
|||
from core.services.networking.httpx import httpx_client
|
||||
from core.services.networking.httpx import connect
|
||||
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.services.helpers.download_file import download_file_and_verify
|
||||
|
||||
from core.controllers.ConfigurationController import ConfigurationController
|
||||
from core.models.Configuration import Configuration, ConnectionChoice
|
||||
|
||||
from core.Errors import FileIntegrityError, UnsupportedApplicationVersionError, ApplicationAlreadyInstalledError
|
||||
from core.controllers.ApplicationController import ApplicationController
|
||||
from core.models.session.Application import Application
|
||||
from core.models.session.ApplicationVersion import ApplicationVersion
|
||||
# from core.models.session.ApplicationVersion import ApplicationVersion
|
||||
from core.models.orm_models.ApplicationVersion import ApplicationVersion
|
||||
from core.models.manage.wrapper import safe_db_operation, WrapperRollback
|
||||
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||
from core.models.orm_calls.application_version_calls import get_application_version, execute_get_all
|
||||
|
||||
from core.observers.ApplicationVersionObserver import ApplicationVersionObserver
|
||||
from core.observers.ConnectionObserver import ConnectionObserver
|
||||
from core.services.WebServiceApiService import WebServiceApiService
|
||||
from core.errors.logger import logger
|
||||
|
||||
# import httpx
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
import hashlib
|
||||
import shutil
|
||||
import tarfile
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
import os
|
||||
|
||||
class ApplicationVersionController:
|
||||
|
||||
@staticmethod
|
||||
def get(application_code: str, version_number: str):
|
||||
return ApplicationVersion.find(application_code, version_number)
|
||||
return get_application_version(application_code, version_number)
|
||||
|
||||
@staticmethod
|
||||
def get_all(application: Optional[Application] = None):
|
||||
return ApplicationVersion.all(application)
|
||||
database_object = execute_get_all()
|
||||
if database_object.valid:
|
||||
return database_object.data
|
||||
else:
|
||||
logger.error(f"[Application Version Controller] Got invalid SQL Query which could not be solved by the wrapper, with error message {database_object.message} and type {database_object.error_type}")
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def install(application_version: ApplicationVersion, reinstall: bool = False, application_version_observer: Optional[ApplicationVersionObserver] = None, connection_observer: Optional[ConnectionObserver] = None):
|
||||
|
||||
if not application_version.is_supported():
|
||||
if not application_version.is_supported:
|
||||
raise UnsupportedApplicationVersionError('The application version in question is not supported.')
|
||||
|
||||
if reinstall:
|
||||
|
|
@ -34,8 +59,12 @@ class ApplicationVersionController:
|
|||
if application_version.is_installed():
|
||||
raise ApplicationAlreadyInstalledError('The application in question is already installed.')
|
||||
|
||||
from core.controllers.ConnectionController import ConnectionController
|
||||
ConnectionController.with_preferred_connection(application_version, task=ApplicationVersionController.__install, application_version_observer=application_version_observer, connection_observer=connection_observer)
|
||||
# this used to go through "with_preferred_connection", but now re-uses the same HTTPx client as sync,
|
||||
ApplicationVersionController.__install(application_version, application_version_observer, connection_observer)
|
||||
|
||||
# legacy:
|
||||
# from core.controllers.ConnectionController import ConnectionController
|
||||
# ConnectionController.with_preferred_connection(application_version, task=ApplicationVersionController.__install, application_version_observer=application_version_observer, connection_observer=connection_observer)
|
||||
|
||||
@staticmethod
|
||||
def uninstall(application_version: ApplicationVersion):
|
||||
|
|
@ -58,45 +87,30 @@ class ApplicationVersionController:
|
|||
ApplicationVersion.save_many(application_versions)
|
||||
|
||||
@staticmethod
|
||||
def __install(application_version: ApplicationVersion, application_version_observer: Optional[ApplicationVersionObserver] = None, proxies: Optional[dict] = None):
|
||||
|
||||
import requests
|
||||
def __install(application_version: ApplicationVersion, application_version_observer: Optional[ApplicationVersionObserver] = None, connection_observer: Optional[ConnectionObserver] = None):
|
||||
target_app_name = application_version.application_code.capitalize()
|
||||
target_app_version = application_version.version_number
|
||||
download_path = application_version.download_path
|
||||
target_file_hash = application_version.file_hash
|
||||
|
||||
if application_version_observer is not None:
|
||||
application_version_observer.notify('downloading', application_version)
|
||||
application_version_observer.notify('downloading', f"Downloading {target_app_name} {target_app_version}. Connecting..")
|
||||
|
||||
if proxies is not None:
|
||||
response = requests.get(application_version.download_path, stream=True, proxies=proxies)
|
||||
else:
|
||||
response = requests.get(application_version.download_path, stream=True)
|
||||
download_result = download_file_and_verify(
|
||||
target_app_name=target_app_name,
|
||||
download_path=download_path,
|
||||
target_file_hash=target_file_hash,
|
||||
application_version_observer=application_version_observer,
|
||||
target_app_version=target_app_version,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
|
||||
response_size = int(response.headers.get('Content-Length', 0))
|
||||
response_buffer = BytesIO()
|
||||
|
||||
block_size = 1024
|
||||
bytes_written = 0
|
||||
|
||||
for data in response.iter_content(block_size):
|
||||
|
||||
bytes_written += len(data)
|
||||
response_buffer.write(data)
|
||||
progress = (bytes_written / response_size) * 100 if response_size > 0 else 0
|
||||
|
||||
if application_version_observer is not None:
|
||||
|
||||
application_version_observer.notify('download_progressing', application_version, dict(
|
||||
progress=progress
|
||||
))
|
||||
|
||||
application_version_observer.notify('downloaded', application_version)
|
||||
response_buffer.seek(0)
|
||||
|
||||
file_hash = ApplicationVersionController.__calculate_file_hash(response_buffer)
|
||||
|
||||
if file_hash != application_version.file_hash:
|
||||
raise FileIntegrityError('Application version file integrity could not be verified.')
|
||||
################################################
|
||||
# IT WORKED - SAVE IT
|
||||
################################################
|
||||
if download_result.valid:
|
||||
response_buffer = download_result.data
|
||||
file_hash = download_result.message
|
||||
|
||||
with tarfile.open(fileobj=response_buffer, mode = 'r:gz') as tar_file:
|
||||
tar_file.extractall(application_version.get_installation_path())
|
||||
|
|
@ -104,20 +118,93 @@ class ApplicationVersionController:
|
|||
with open(f'{application_version.get_installation_path()}/.sha3-512', 'w') as hash_file:
|
||||
hash_file.write(f'{file_hash}\n')
|
||||
|
||||
################################################
|
||||
# FAILED
|
||||
################################################
|
||||
else:
|
||||
raise ConnectionError('The application version could not be downloaded.')
|
||||
if download_result.error_type == ResultError.CONNECTION:
|
||||
raise ConnectionError(f'Could not connect, to download {target_app_name}.')
|
||||
elif download_result.error_type == ResultError.INVALID_INPUT:
|
||||
raise FileIntegrityError('Application version file integrity could not be verified.')
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __calculate_file_hash(file):
|
||||
|
||||
hasher = hashlib.sha3_512()
|
||||
buffer = file.read(65536)
|
||||
# legacy:
|
||||
|
||||
while len(buffer) > 0:
|
||||
# @staticmethod
|
||||
# def __calculate_file_hash(file):
|
||||
|
||||
hasher.update(buffer)
|
||||
buffer = file.read(65536)
|
||||
# hasher = hashlib.sha3_512()
|
||||
# buffer = file.read(65536)
|
||||
|
||||
file.seek(0)
|
||||
# while len(buffer) > 0:
|
||||
|
||||
return hasher.hexdigest()
|
||||
# hasher.update(buffer)
|
||||
# buffer = file.read(65536)
|
||||
|
||||
# file.seek(0)
|
||||
|
||||
# return hasher.hexdigest()
|
||||
|
||||
|
||||
# def _get_httpx_client(target_app_name: str, connection_observer: Optional[ConnectionObserver]) -> httpx.Client:
|
||||
# connection_type = ConfigurationController.get_connection_enum()
|
||||
# did_it_work = connect.make_client(connection_type, connection_observer) # always gets boolean
|
||||
|
||||
# if not did_it_work:
|
||||
# raise ConnectionError(f'Could not connect, to download {target_app_name}.')
|
||||
# else:
|
||||
# client = httpx_client.get_http_session()
|
||||
# logger.info(f"client type is {type(client)}")
|
||||
# return client
|
||||
|
||||
# @staticmethod
|
||||
# def get_all(application: Optional[Application] = None):
|
||||
# return ApplicationVersion.all(application)
|
||||
|
||||
# legacy:
|
||||
# application_version_observer.notify('downloading', application_version)
|
||||
|
||||
################################################
|
||||
# SETUP HTTP CLIENT
|
||||
################################################
|
||||
# client = httpx_client.get_http_session()
|
||||
# logger.info(f"client type is {type(client)}")
|
||||
# if client is None:
|
||||
# client = _get_httpx_client(target_app_name=target_app_name, connection_observer=connection_observer)
|
||||
|
||||
# ################################################
|
||||
# # GET THE DATA
|
||||
# ################################################
|
||||
# download_path = application_version.download_path
|
||||
# logger.info(f"download_path is {download_path}")
|
||||
# with client.stream('GET', download_path) as response:
|
||||
# logger.info("doing the stream...")
|
||||
# if response.status_code == 200:
|
||||
# response_size = int(response.headers.get('Content-Length', 0))
|
||||
# response_buffer = BytesIO()
|
||||
|
||||
# block_size = 1024
|
||||
# bytes_written = 0
|
||||
# for data in response.iter_bytes(block_size):
|
||||
|
||||
# bytes_written += len(data)
|
||||
# response_buffer.write(data)
|
||||
# progress = (bytes_written / response_size) * 100 if response_size > 0 else 0
|
||||
|
||||
# if application_version_observer is not None:
|
||||
# application_version_observer.notify('download_progressing', f"Downloading {target_app_name} {progress:.2f}% v: {target_app_version}")
|
||||
# else:
|
||||
# raise ConnectionError('The application version could not be downloaded.')
|
||||
|
||||
# application_version_observer.notify('downloaded', f"Downloaded {target_app_name} {target_app_version}")
|
||||
# response_buffer.seek(0)
|
||||
|
||||
################################################
|
||||
# VERIFY THE HASH
|
||||
################################################
|
||||
# file_hash = ApplicationVersionController.__calculate_file_hash(response_buffer)
|
||||
|
||||
# if file_hash != application_version.file_hash:
|
||||
# raise FileIntegrityError('Application version file integrity could not be verified.')
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
from core.models.manage.session_management import init_session, close_session
|
||||
from core.services.sync.sync_service import coordinate_cache_sync, save_metadata
|
||||
from core.services.sync.orm_methods.sync_one_orm import sync_one_orm_model
|
||||
|
||||
|
||||
from core.errors.logger import logger
|
||||
|
||||
# ORM models that can be sync'ed:
|
||||
|
|
@ -16,8 +18,6 @@ from core.controllers.ApplicationController import ApplicationController
|
|||
from core.controllers.ApplicationVersionController import ApplicationVersionController
|
||||
from core.controllers.ClientVersionController import ClientVersionController
|
||||
from core.controllers.ConfigurationController import ConfigurationController
|
||||
# from core.controllers.LocationController import LocationController
|
||||
# from core.controllers.OperatorController import OperatorController
|
||||
from core.controllers.SubscriptionPlanController import SubscriptionPlanController
|
||||
from core.observers.ClientObserver import ClientObserver
|
||||
from core.observers.ConnectionObserver import ConnectionObserver
|
||||
|
|
@ -27,7 +27,13 @@ import pathlib
|
|||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
def evaluate_errors(final_result: dict, client_observer: Optional[ClientObserver] = None) -> str:
|
||||
if not final_result["success"] and client_observer:
|
||||
category = final_result["category"]
|
||||
client_observer.notify('synchronizing', f'{category} Error! Check error logs')
|
||||
time.sleep(2) # so they can see it.
|
||||
|
||||
class ClientController:
|
||||
|
||||
|
|
@ -53,86 +59,86 @@ class ClientController:
|
|||
|
||||
return not ClientVersionController.is_latest(version)
|
||||
|
||||
@staticmethod
|
||||
def sync(client_observer: ClientObserver = None, connection_observer: ConnectionObserver = None):
|
||||
# @staticmethod
|
||||
# def legacy_sync(client_observer: ClientObserver = None, connection_observer: ConnectionObserver = None):
|
||||
# if client_observer is not None:
|
||||
# client_observer.notify('synchronizing', "Fetching list of new data ..")
|
||||
|
||||
if client_observer is not None:
|
||||
client_observer.notify('synchronizing', "Fetching list of new data ..")
|
||||
# result = coordinate_cache_sync(client_observer, connection_observer)
|
||||
|
||||
result = coordinate_cache_sync(ClientObserver, ConnectionObserver)
|
||||
# # Outright Error:
|
||||
# if not result["success"]:
|
||||
# error_msg = result["error"]
|
||||
# if client_observer is not None:
|
||||
# client_observer.notify('synchronizing', f'Error! {error_msg}')
|
||||
# return
|
||||
|
||||
# logger.info(f"We got a Result from the API of {result}")
|
||||
# # Same:
|
||||
# changed_tables = result["changed_tables"]
|
||||
# if not changed_tables:
|
||||
# if client_observer is not None:
|
||||
# client_observer.notify('synchronized')
|
||||
# return
|
||||
|
||||
# Outright Error:
|
||||
if not result["success"]:
|
||||
error_msg = result["error"]
|
||||
if client_observer is not None:
|
||||
client_observer.notify('synchronizing', f'Error! {error_msg}')
|
||||
return
|
||||
# # We only make it past this point if there's New Data
|
||||
|
||||
# Same:
|
||||
changed_tables = result["changed_tables"]
|
||||
if not changed_tables:
|
||||
if client_observer is not None:
|
||||
client_observer.notify('synchronized')
|
||||
return
|
||||
# # flag for after the save,
|
||||
# data_was_saved = False
|
||||
|
||||
# We only make it past this point if there's New Data
|
||||
# # Fetch and update the real data (no longer metadata)...
|
||||
|
||||
# flag for after the save,
|
||||
data_was_saved = False
|
||||
# # =================== ORM BASED MODELS ==================
|
||||
# """
|
||||
# Note: for the new ORM based models,
|
||||
# it does the Tor/system check in the API call itself.
|
||||
# """
|
||||
|
||||
# Fetch and update the real data (no longer metadata)...
|
||||
# if "locations" in changed_tables:
|
||||
# logger.info("Sync of Locations")
|
||||
# if client_observer is not None:
|
||||
# client_observer.notify('synchronizing', 'Fetching Locations List..')
|
||||
|
||||
# =================== ORM BASED MODELS ==================
|
||||
"""
|
||||
Note: for the new ORM based models,
|
||||
it does the Tor/system check in the API call itself.
|
||||
"""
|
||||
# final_result = sync_one_orm_model(Location, "locations")
|
||||
# evaluate_errors(final_result)
|
||||
|
||||
if "locations" in changed_tables:
|
||||
logger.info("Sync of Locations")
|
||||
if client_observer is not None:
|
||||
client_observer.notify('synchronizing', 'Fetching Locations List..')
|
||||
|
||||
final_result = sync_one_orm_model(Location, "locations")
|
||||
# if "operators" in changed_tables:
|
||||
# logger.info("Sync of Operators")
|
||||
# if client_observer is not None:
|
||||
# client_observer.notify('synchronizing', 'Fetching Operators List..')
|
||||
|
||||
if "operators" in changed_tables:
|
||||
logger.info("Sync of Operators")
|
||||
if client_observer is not None:
|
||||
client_observer.notify('synchronizing', 'Fetching Operators List..')
|
||||
# final_result_two = sync_one_orm_model(Operator, "operators")
|
||||
# evaluate_errors(final_result_two)
|
||||
|
||||
final_result_two = sync_one_orm_model(Operator, "operators")
|
||||
# # =================== MANUAL-SQL BASED MODELS ==================
|
||||
# try:
|
||||
# from core.controllers.ConnectionController import ConnectionController
|
||||
# ConnectionController.with_preferred_connection(task=ClientController.__sync, changed_tables=changed_tables, client_observer=client_observer, connection_observer=connection_observer)
|
||||
|
||||
# =================== MANUAL-SQL BASED MODELS ==================
|
||||
try:
|
||||
from core.controllers.ConnectionController import ConnectionController
|
||||
ConnectionController.with_preferred_connection(task=ClientController.__sync, changed_tables=changed_tables, client_observer=client_observer, connection_observer=connection_observer)
|
||||
# # We set the flag to true,
|
||||
# # the reason we use a flag, and don't just save it right here,
|
||||
# # is because we want to isolate the success (or failure) of the real data,
|
||||
# # from the potential failure of the ORM session metadata.
|
||||
# data_was_saved = True
|
||||
|
||||
# We set the flag to true,
|
||||
# the reason we use a flag, and don't just save it right here,
|
||||
# is because we want to isolate the success (or failure) of the real data,
|
||||
# from the potential failure of the ORM session metadata.
|
||||
data_was_saved = True
|
||||
# except:
|
||||
# # sync failed here,
|
||||
# if client_observer is not None:
|
||||
# client_observer.notify('synchronizing', 'Fetch Failed, but you can use old data.')
|
||||
# finally:
|
||||
# if data_was_saved:
|
||||
# filtered_metadata = result["filtered_metadata"] # from the top of the function
|
||||
# save_successful = save_metadata(filtered_metadata) # the "save_data" function is inside sync_service
|
||||
|
||||
except:
|
||||
# sync failed here,
|
||||
if client_observer is not None:
|
||||
client_observer.notify('synchronizing', 'Fetch Failed, but you can use old data.')
|
||||
finally:
|
||||
if data_was_saved:
|
||||
filtered_metadata = result["filtered_metadata"] # from the top of the function
|
||||
save_successful = save_metadata(filtered_metadata) # the "save_data" function is inside sync_service
|
||||
# if client_observer is None:
|
||||
# logger.error("Error: No client_observer to update the UI, the final part of the sync function skipped")
|
||||
# return # can't update their UI
|
||||
|
||||
if client_observer is None:
|
||||
logger.error("Error: No client_observer to update the UI, the final part of the sync function skipped")
|
||||
return # can't update their UI
|
||||
|
||||
if save_successful:
|
||||
logger.info("Metadata Saved Successfully")
|
||||
client_observer.notify('synchronized', "Fetch & Save Complete!")
|
||||
else:
|
||||
client_observer.notify('synchronizing', "Saving List of Metadata Failed.")
|
||||
# if save_successful:
|
||||
# logger.info("Metadata Saved Successfully")
|
||||
# client_observer.notify('synchronized', "Fetch & Save Complete!")
|
||||
# else:
|
||||
# client_observer.notify('synchronizing', "Saving List of Metadata Failed.")
|
||||
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -150,40 +156,40 @@ class ClientController:
|
|||
return path
|
||||
|
||||
|
||||
@staticmethod
|
||||
def __sync(changed_tables: list, client_observer: Optional[ClientObserver] = None, proxies: Optional[dict] = None):
|
||||
# @staticmethod
|
||||
# def __sync(changed_tables: list, client_observer: Optional[ClientObserver] = None, proxies: Optional[dict] = None):
|
||||
|
||||
if "applications" in changed_tables:
|
||||
logger.info("Sync applications..")
|
||||
if client_observer is not None:
|
||||
client_observer.notify('synchronizing', 'Fetching Browser List..')
|
||||
# noinspection PyProtectedMember
|
||||
ApplicationController._sync(proxies=proxies)
|
||||
# if "applications" in changed_tables:
|
||||
# logger.info("Sync applications..")
|
||||
# if client_observer is not None:
|
||||
# client_observer.notify('synchronizing', 'Fetching Browser List..')
|
||||
# # noinspection PyProtectedMember
|
||||
# ApplicationController._sync(proxies=proxies)
|
||||
|
||||
if "application_versions" in changed_tables:
|
||||
logger.info("Sync Application Versions..")
|
||||
if client_observer is not None:
|
||||
client_observer.notify('synchronizing', 'Fetching Browser Version List..')
|
||||
# noinspection PyProtectedMember
|
||||
ApplicationVersionController._sync(proxies=proxies)
|
||||
# if "application_versions" in changed_tables:
|
||||
# logger.info("Sync Application Versions..")
|
||||
# if client_observer is not None:
|
||||
# client_observer.notify('synchronizing', 'Fetching Browser Version List..')
|
||||
# # noinspection PyProtectedMember
|
||||
# ApplicationVersionController._sync(proxies=proxies)
|
||||
|
||||
if "client_version" in changed_tables:
|
||||
logger.info("Sync of client version")
|
||||
if client_observer is not None:
|
||||
client_observer.notify('synchronizing', 'Fetching Client Version List..')
|
||||
# noinspection PyProtectedMember
|
||||
ClientVersionController._sync(proxies=proxies)
|
||||
# if "client_version" in changed_tables:
|
||||
# logger.info("Sync of client version")
|
||||
# if client_observer is not None:
|
||||
# client_observer.notify('synchronizing', 'Fetching Client Version List..')
|
||||
# # noinspection PyProtectedMember
|
||||
# ClientVersionController._sync(proxies=proxies)
|
||||
|
||||
if "subscriptions" in changed_tables:
|
||||
logger.info("Sync of Subscriptions")
|
||||
if client_observer is not None:
|
||||
client_observer.notify('synchronizing', 'Fetching Subscription List..')
|
||||
# noinspection PyProtectedMember
|
||||
SubscriptionPlanController._sync(proxies=proxies)
|
||||
# if "subscriptions" in changed_tables:
|
||||
# logger.info("Sync of Subscriptions")
|
||||
# if client_observer is not None:
|
||||
# client_observer.notify('synchronizing', 'Fetching Subscription List..')
|
||||
# # noinspection PyProtectedMember
|
||||
# SubscriptionPlanController._sync(proxies=proxies)
|
||||
|
||||
ConfigurationController.update_last_synced_at()
|
||||
# ConfigurationController.update_last_synced_at()
|
||||
|
||||
logger.info("Real Data Fetch Completed Successfully")
|
||||
# logger.info("Real Data Fetch Completed Successfully")
|
||||
|
||||
@staticmethod
|
||||
def __update(client_observer: Optional[ClientObserver] = None, proxies: Optional[dict] = None):
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
from core.Errors import UnknownConnectionTypeError
|
||||
from core.models.Configuration import Configuration
|
||||
from core.models.Configuration import Configuration, ConnectionChoice
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
|
||||
class ConfigurationController:
|
||||
_config: Optional[Configuration] = None
|
||||
|
||||
@staticmethod
|
||||
def get():
|
||||
return Configuration.get()
|
||||
if ConfigurationController._config is None:
|
||||
ConfigurationController._config = Configuration.get()
|
||||
return ConfigurationController._config
|
||||
|
||||
# return Configuration.get()
|
||||
|
||||
@staticmethod
|
||||
def reload_from_disk():
|
||||
ConfigurationController._config = None
|
||||
|
||||
@staticmethod
|
||||
def get_or_new():
|
||||
|
|
@ -24,19 +34,35 @@ class ConfigurationController:
|
|||
def get_connection():
|
||||
|
||||
configuration = ConfigurationController.get()
|
||||
return configuration.connection.value
|
||||
|
||||
if configuration is None or configuration.connection not in ('system', 'tor', 'vless', 'hysteria2'):
|
||||
raise UnknownConnectionTypeError('The preferred connection type could not be determined.')
|
||||
@staticmethod
|
||||
def get_connection_enum():
|
||||
|
||||
configuration = ConfigurationController.get()
|
||||
return configuration.connection
|
||||
|
||||
@staticmethod
|
||||
def set_connection(connection: Optional[str] = None):
|
||||
def set_connection(connection_string: Optional[str] = None):
|
||||
|
||||
configuration = ConfigurationController.get_or_new()
|
||||
|
||||
if connection_string == "tor":
|
||||
connection = ConnectionChoice.TOR
|
||||
elif connection_string == "system":
|
||||
connection = ConnectionChoice.SYSTEM
|
||||
else:
|
||||
raise UnknownConnectionTypeError(f'The choice of {connection_string} is not valid.')
|
||||
|
||||
configuration.connection = connection
|
||||
configuration.save()
|
||||
|
||||
@staticmethod
|
||||
def set_connection_enum(connection_enum: ConnectionChoice) -> bool:
|
||||
configuration = ConfigurationController.get_or_new()
|
||||
configuration.connection = connection_enum
|
||||
configuration.save()
|
||||
|
||||
@staticmethod
|
||||
def get_auto_sync_enabled():
|
||||
|
||||
|
|
@ -84,10 +110,45 @@ class ConfigurationController:
|
|||
@staticmethod
|
||||
def update_last_synced_at():
|
||||
|
||||
configuration = ConfigurationController.get_or_new()
|
||||
configuration = ConfigurationController.get()
|
||||
configuration.last_synced_at = datetime.now(timezone.utc)
|
||||
configuration.save()
|
||||
|
||||
@staticmethod
|
||||
def update_or_create(configuration):
|
||||
configuration.save()
|
||||
|
||||
@staticmethod
|
||||
def change_firewall(new_value):
|
||||
configuration = ConfigurationController.get()
|
||||
configuration.firewall = new_value
|
||||
configuration.save()
|
||||
|
||||
@staticmethod
|
||||
def get_firewall_setting():
|
||||
configuration = ConfigurationController.get()
|
||||
return configuration.firewall
|
||||
|
||||
|
||||
@staticmethod
|
||||
def change_dns(new_value):
|
||||
configuration = ConfigurationController.get()
|
||||
configuration.dns = new_value
|
||||
configuration.save()
|
||||
|
||||
@staticmethod
|
||||
def get_dns_setting():
|
||||
configuration = ConfigurationController.get()
|
||||
return configuration.dns
|
||||
|
||||
@staticmethod
|
||||
def get_singbox_version():
|
||||
configuration = ConfigurationController.get()
|
||||
return configuration.singbox
|
||||
|
||||
@staticmethod
|
||||
def update_singbox_version(new_value) -> bool:
|
||||
configuration = ConfigurationController.get()
|
||||
configuration.singbox = new_value
|
||||
configuration.save()
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
from core.errors.exceptions import *
|
||||
from core.errors.logger import logger
|
||||
|
||||
from core.services.networking.general_connection_tools.testing_evaluating import await_connection, system_uses_wireguard_interface, await_network_interface
|
||||
from core.services.networking.systemwide.systemwide_wireguard import establish_system_connection, terminate_system_connection
|
||||
from core.services.keys_and_verifications.endpoint_verification import verify_wireguard_endpoint
|
||||
from core.observers.TicketObserver import TicketObserver
|
||||
|
||||
|
||||
from collections.abc import Callable
|
||||
from core.Constants import Constants
|
||||
from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionUnprotectedError, ConnectionTerminationError, CommandNotFoundError
|
||||
from core.controllers.ConfigurationController import ConfigurationController
|
||||
from core.controllers.ProfileController import ProfileController
|
||||
# from core.controllers.ProfileController import ProfileController
|
||||
from core.controllers.SessionStateController import SessionStateController
|
||||
from core.controllers.SystemStateController import SystemStateController
|
||||
from core.models.BaseProfile import ProfileType
|
||||
from core.models.session.SessionProfile import SessionProfile
|
||||
from core.models.system.SystemProfile import SystemProfile
|
||||
from core.models.system.SystemState import SystemState
|
||||
|
|
@ -15,19 +23,17 @@ from core.services.WebServiceApiService import WebServiceApiService
|
|||
from essentials.modules.TorModule import TorModule
|
||||
from essentials.services.ConnectionService import ConnectionService
|
||||
from pathlib import Path
|
||||
from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionUnprotectedError, ConnectionTerminationError, CommandNotFoundError, PaymentRequiredError
|
||||
from subprocess import CalledProcessError
|
||||
from typing import Union, Optional, Any
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from core.models.OperatorProxySession import OperatorProxySession
|
||||
from enum import Enum
|
||||
# import sys
|
||||
# import re
|
||||
|
||||
|
||||
class ConnectionController:
|
||||
|
|
@ -53,114 +59,9 @@ class ConnectionController:
|
|||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def establish_connection(profile: Union[SessionProfile, SystemProfile], ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None):
|
||||
from core.controllers.ConnectionController import ConnectionController
|
||||
|
||||
ConnectionController.verify_singbox_installation()
|
||||
|
||||
connection = profile.connection
|
||||
|
||||
# needs_proxy_configuration comes from the child connection models
|
||||
# for the system it returns false blindly. for the session is checks if the connection type is masked.
|
||||
if connection.needs_proxy_configuration() and not profile.has_proxy_configuration():
|
||||
|
||||
if profile.has_subscription():
|
||||
|
||||
if not profile.subscription.has_been_activated():
|
||||
ProfileController.activate_subscription(profile, connection_observer=connection_observer)
|
||||
|
||||
proxy_configuration = ConnectionController.with_preferred_connection(profile.subscription.billing_code, task=WebServiceApiService.get_proxy_configuration, connection_observer=connection_observer)
|
||||
|
||||
if proxy_configuration is None:
|
||||
raise InvalidSubscriptionError()
|
||||
|
||||
profile.attach_proxy_configuration(proxy_configuration)
|
||||
|
||||
else:
|
||||
raise MissingSubscriptionError()
|
||||
|
||||
# The needs_wireguard_configuration comes from the connection model, and can be transitioned to get it directly from the object's data
|
||||
# The has_wireguard_configuration comes from each polymorph object doing an os check on if the wg config exists
|
||||
if connection.needs_wireguard_configuration() and not profile.has_wireguard_configuration():
|
||||
|
||||
if profile.has_subscription():
|
||||
|
||||
if not profile.subscription.has_been_activated():
|
||||
ProfileController.activate_subscription(profile, connection_observer=connection_observer)
|
||||
|
||||
ProfileController.register_wireguard_session(profile, connection_observer=connection_observer)
|
||||
|
||||
else:
|
||||
|
||||
if profile.is_system_profile():
|
||||
|
||||
if ConnectionController.system_uses_wireguard_interface() and SystemStateController.exists():
|
||||
|
||||
try:
|
||||
ConnectionController.terminate_system_connection()
|
||||
except ConnectionTerminationError:
|
||||
pass
|
||||
|
||||
raise MissingSubscriptionError()
|
||||
|
||||
if connection.needs_operator_proxy() and not profile.has_operator_proxy_session():
|
||||
|
||||
if profile.has_subscription():
|
||||
|
||||
if not profile.subscription.has_been_activated():
|
||||
ProfileController.activate_subscription(profile, connection_observer=connection_observer)
|
||||
|
||||
operator_proxy_session = ConnectionController.with_preferred_connection(
|
||||
profile.subscription.billing_code,
|
||||
profile.subscription.operator_id,
|
||||
connection.get_protocol(),
|
||||
task=WebServiceApiService.post_operator_proxy,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
|
||||
if operator_proxy_session is None:
|
||||
raise PaymentRequiredError()
|
||||
|
||||
profile.attach_operator_proxy_session(operator_proxy_session)
|
||||
|
||||
else:
|
||||
raise MissingSubscriptionError()
|
||||
|
||||
if profile.is_session_profile():
|
||||
|
||||
try:
|
||||
return ConnectionController.establish_session_connection(profile, ignore=ignore, connection_observer=connection_observer)
|
||||
|
||||
except ConnectionError:
|
||||
|
||||
if ConnectionController.__should_renegotiate(profile):
|
||||
|
||||
ProfileController.register_wireguard_session(profile, connection_observer=connection_observer)
|
||||
return ConnectionController.establish_session_connection(profile, ignore=ignore, connection_observer=connection_observer)
|
||||
|
||||
else:
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
|
||||
if profile.is_system_profile():
|
||||
|
||||
try:
|
||||
return ConnectionController.establish_system_connection(profile, ignore=ignore, connection_observer=connection_observer)
|
||||
|
||||
except ConnectionError:
|
||||
|
||||
if ConnectionController.__should_renegotiate(profile):
|
||||
|
||||
ProfileController.register_wireguard_session(profile, connection_observer=connection_observer)
|
||||
return ConnectionController.establish_system_connection(profile, ignore=ignore, connection_observer=connection_observer)
|
||||
|
||||
else:
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def establish_session_connection(profile: SessionProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None):
|
||||
def establish_session_connection(profile: SessionProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None, ticket_observer: Optional[TicketObserver] = None):
|
||||
|
||||
session_directory = tempfile.mkdtemp(prefix='hv-')
|
||||
session_state = SessionStateController.get_or_new(profile.id)
|
||||
|
|
@ -171,12 +72,13 @@ class ConnectionController:
|
|||
# this is a check from SessionConnection of if there's a systemwide with mask
|
||||
if profile.connection.is_unprotected():
|
||||
|
||||
if not ConnectionController.system_uses_wireguard_interface():
|
||||
if not system_uses_wireguard_interface():
|
||||
|
||||
if not ConnectionUnprotectedError in ignore:
|
||||
raise ConnectionUnprotectedError('Connection unprotected while the system is not using a WireGuard interface.')
|
||||
else:
|
||||
ProfileController.disable(profile)
|
||||
from core.controllers.ProfileController import ProfileController
|
||||
ProfileController.disable(profile=profile, connection_observer=connection_observer, ticket_observer=ticket_observer)
|
||||
|
||||
if profile.connection.code == 'tor':
|
||||
|
||||
|
|
@ -188,30 +90,12 @@ class ConnectionController:
|
|||
elif profile.connection.code == 'wireguard':
|
||||
|
||||
if ConfigurationController.get_endpoint_verification_enabled():
|
||||
ProfileController.verify_wireguard_endpoint(profile, ignore=ignore)
|
||||
verify_wireguard_endpoint(profile, ignore=ignore)
|
||||
|
||||
port_number = ConnectionService.get_random_available_port_number()
|
||||
ConnectionController.establish_wireguard_session_connection(profile, session_directory, port_number)
|
||||
session_state.network_port_numbers.wireguard.append(port_number)
|
||||
|
||||
elif profile.connection.code in ('vless', 'hysteria2'):
|
||||
|
||||
if not profile.has_operator_proxy_session():
|
||||
raise MissingSubscriptionError()
|
||||
|
||||
operator_proxy_session = profile.get_operator_proxy_session()
|
||||
port_number = ConnectionService.get_random_available_port_number()
|
||||
|
||||
if profile.connection.code == 'vless':
|
||||
from core.controllers.encrypted_proxy.VlessController import VlessController
|
||||
VlessController.enable(operator_proxy_session, port_number)
|
||||
session_state.network_port_numbers.vless.append(port_number)
|
||||
|
||||
elif profile.connection.code == 'hysteria2':
|
||||
from core.controllers.encrypted_proxy.HysteriaController import HysteriaController
|
||||
HysteriaController.enable(operator_proxy_session, port_number)
|
||||
session_state.network_port_numbers.hysteria2.append(port_number)
|
||||
|
||||
if profile.connection.masked:
|
||||
|
||||
while proxy_port_number is None or proxy_port_number == port_number:
|
||||
|
|
@ -221,139 +105,42 @@ class ConnectionController:
|
|||
session_state.network_port_numbers.proxy.append(proxy_port_number)
|
||||
|
||||
if not profile.connection.is_unprotected():
|
||||
ConnectionController.await_connection(proxy_port_number or port_number, connection_observer=connection_observer)
|
||||
await_connection(proxy_port_number or port_number, connection_observer=connection_observer)
|
||||
|
||||
SessionStateController.update_or_create(session_state)
|
||||
|
||||
return proxy_port_number or port_number
|
||||
|
||||
@staticmethod
|
||||
def _signal_previous_process() -> None:
|
||||
|
||||
system_state = SystemStateController.get()
|
||||
if system_state is None:
|
||||
return
|
||||
pid = system_state.pid
|
||||
if pid is None or pid == os.getpid():
|
||||
return
|
||||
try:
|
||||
os.kill(pid, signal.SIGUSR1)
|
||||
time.sleep(0.5)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def establish_system_connection(profile: SystemProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None):
|
||||
ConnectionController._signal_previous_process()
|
||||
|
||||
system_state = SystemStateController.get()
|
||||
if system_state is not None:
|
||||
try:
|
||||
ConnectionController.terminate_system_connection(connection_observer=connection_observer)
|
||||
except ConnectionTerminationError:
|
||||
pass
|
||||
|
||||
if profile.connection.needs_operator_proxy():
|
||||
ok = ConnectionController.establish_encrypted_proxy_connection(
|
||||
profile, socks5_port=1080, observer=connection_observer
|
||||
)
|
||||
if not ok:
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
token = SystemStateController.create(profile.id)
|
||||
if connection_observer is not None:
|
||||
connection_observer.notify('connected_token', {'session_token': token})
|
||||
return
|
||||
|
||||
if ConfigurationController.get_endpoint_verification_enabled():
|
||||
ProfileController.verify_wireguard_endpoint(profile, ignore=ignore)
|
||||
|
||||
try:
|
||||
ConnectionController.__establish_system_connection(profile, connection_observer)
|
||||
|
||||
except ConnectionError:
|
||||
try:
|
||||
ConnectionController.terminate_system_connection()
|
||||
except ConnectionTerminationError:
|
||||
pass
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
|
||||
except CalledProcessError:
|
||||
try:
|
||||
ConnectionController.terminate_system_connection()
|
||||
except ConnectionTerminationError:
|
||||
pass
|
||||
try:
|
||||
ConnectionController.__establish_system_connection(profile, connection_observer)
|
||||
except (ConnectionError, CalledProcessError):
|
||||
try:
|
||||
ConnectionController.terminate_system_connection()
|
||||
except ConnectionTerminationError:
|
||||
pass
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
|
||||
ConnectionController.terminate_tor_connection()
|
||||
time.sleep(1.0)
|
||||
|
||||
@staticmethod
|
||||
def establish_encrypted_proxy_connection(profile, socks5_port: int = 1080, observer=None) -> bool:
|
||||
"""Shared encrypted proxy connection logic for CLI and GUI."""
|
||||
import socket
|
||||
operator_proxy_session = profile.get_operator_proxy_session()
|
||||
protocol = profile.connection.code
|
||||
|
||||
if protocol == 'vless':
|
||||
from core.controllers.encrypted_proxy.VlessController import VlessController
|
||||
from core.services.encrypted_proxy.vless_service import parse_vless_link
|
||||
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'])
|
||||
return VlessController(socks5_port).enable(
|
||||
operator_proxy_session.links[0],
|
||||
operator_proxy_session.username,
|
||||
server_ip,
|
||||
observer
|
||||
)
|
||||
elif protocol == 'hysteria2':
|
||||
from core.controllers.encrypted_proxy.HysteriaController import HysteriaController
|
||||
server_ip = operator_proxy_session.server_ip
|
||||
if server_ip is None:
|
||||
server_ip = socket.gethostbyname(operator_proxy_session.operator_hysteria2_host)
|
||||
return HysteriaController(socks5_port).enable(
|
||||
operator_proxy_session.username,
|
||||
operator_proxy_session.password,
|
||||
operator_proxy_session.operator_hysteria2_host,
|
||||
server_ip,
|
||||
observer
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def establish_tor_connection(connection_observer: Optional[ConnectionObserver] = None):
|
||||
try:
|
||||
tor_module = TorModule(Constants.HV_TOR_STATE_HOME)
|
||||
tor_module.start_service(connection_observer)
|
||||
|
||||
tor_module = TorModule(Constants.HV_TOR_STATE_HOME)
|
||||
tor_module.start_service(connection_observer)
|
||||
for session_state in SessionStateController.all():
|
||||
|
||||
for session_state in SessionStateController.all():
|
||||
for port_number in session_state.network_port_numbers.tor:
|
||||
tor_module.create_session(port_number)
|
||||
except:
|
||||
if connection_observer is not None:
|
||||
connection_observer.notify('custom_message', "Tor Can't Initialize")
|
||||
|
||||
for port_number in session_state.network_port_numbers.tor:
|
||||
tor_module.create_session(port_number)
|
||||
# @staticmethod
|
||||
# def terminate_tor_connection():
|
||||
|
||||
@staticmethod
|
||||
def terminate_tor_connection():
|
||||
|
||||
tor_module = TorModule(Constants.HV_TOR_STATE_HOME)
|
||||
tor_module.stop_service()
|
||||
# tor_module = TorModule(Constants.HV_TOR_STATE_HOME)
|
||||
# tor_module.stop_service()
|
||||
|
||||
@staticmethod
|
||||
def establish_tor_session_connection(port_number: int, connection_observer: Optional[ConnectionObserver] = None):
|
||||
try:
|
||||
tor_module = TorModule(Constants.HV_TOR_STATE_HOME)
|
||||
tor_module.create_session(port_number, connection_observer)
|
||||
except TorServiceInitializationError as e:
|
||||
logger.error(f"TorServiceInitializationError. Tor Can't Start: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Tor Can't Start: {e}")
|
||||
if connection_observer is not None:
|
||||
connection_observer.notify('custom_message', "Tor Can't Initialize")
|
||||
|
||||
@staticmethod
|
||||
def terminate_tor_session_connection(port_number: int):
|
||||
|
|
@ -415,43 +202,9 @@ class ConnectionController:
|
|||
|
||||
return subprocess.Popen(('proxychains4', '-f', proxychains_configuration_file_path, 'microsocks', '-p', str(proxy_port_number)), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
|
||||
|
||||
@staticmethod
|
||||
def terminate_system_connection(connection_observer: Optional[ConnectionObserver] = None):
|
||||
system_state = SystemStateController.get()
|
||||
if system_state is not None:
|
||||
profile = ProfileController.get(system_state.profile_id)
|
||||
if profile is not None and profile.connection.needs_operator_proxy():
|
||||
protocol = profile.connection.get_protocol()
|
||||
if protocol == 'vless':
|
||||
from core.controllers.encrypted_proxy.VlessController import VlessController
|
||||
VlessController(1080).disable(connection_observer)
|
||||
elif protocol == 'hysteria2':
|
||||
from core.controllers.encrypted_proxy.HysteriaController import HysteriaController
|
||||
HysteriaController(1080).disable(connection_observer)
|
||||
SystemState.dissolve()
|
||||
return
|
||||
|
||||
if shutil.which('nmcli') is None:
|
||||
raise CommandNotFoundError('nmcli')
|
||||
|
||||
if SystemStateController.exists():
|
||||
process = subprocess.Popen(('nmcli', 'connection', 'delete', 'wg'), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
|
||||
completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8)
|
||||
|
||||
if completed_successfully or not ConnectionController.system_uses_wireguard_interface():
|
||||
subprocess.run(('nmcli', 'connection', 'delete', 'hv-ipv6-sink'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
ConnectionController.terminate_tor_connection()
|
||||
try:
|
||||
from core.utils.encrypted_proxy import killswitch
|
||||
killswitch.disarm()
|
||||
except Exception:
|
||||
pass
|
||||
SystemState.dissolve()
|
||||
if connection_observer is not None:
|
||||
connection_observer.notify('disconnected', {})
|
||||
else:
|
||||
raise ConnectionTerminationError('The connection could not be terminated.')
|
||||
|
||||
# Stays here for Tor based only. Will move with Tor later.
|
||||
@staticmethod
|
||||
def get_proxies(port_number: int):
|
||||
|
||||
|
|
@ -460,219 +213,6 @@ class ConnectionController:
|
|||
https=f'socks5h://127.0.0.1:{port_number}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def await_connection(port_number: Optional[int] = None, connection_observer: Optional[ConnectionObserver] = None):
|
||||
|
||||
if port_number is None:
|
||||
ConnectionController.await_network_interface()
|
||||
|
||||
for retry_count in range(Constants.MAX_CONNECTION_ATTEMPTS):
|
||||
|
||||
if connection_observer is not None:
|
||||
|
||||
connection_observer.notify('connecting', dict(
|
||||
retry_interval=Constants.CONNECTION_RETRY_INTERVAL,
|
||||
maximum_number_of_attempts=Constants.MAX_CONNECTION_ATTEMPTS,
|
||||
attempt_count=retry_count + 1
|
||||
))
|
||||
|
||||
try:
|
||||
|
||||
ConnectionController.__test_connection(port_number)
|
||||
return
|
||||
|
||||
except ConnectionError:
|
||||
|
||||
time.sleep(Constants.CONNECTION_RETRY_INTERVAL)
|
||||
retry_count += 1
|
||||
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
|
||||
@staticmethod
|
||||
def await_network_interface():
|
||||
|
||||
network_interface_is_activated = False
|
||||
|
||||
retry_interval = .5
|
||||
maximum_number_of_attempts = 10
|
||||
attempt = 0
|
||||
|
||||
while not network_interface_is_activated and attempt < maximum_number_of_attempts:
|
||||
|
||||
time.sleep(retry_interval)
|
||||
|
||||
network_interface_is_activated = ConnectionController.system_uses_wireguard_interface()
|
||||
attempt += 1
|
||||
|
||||
if not network_interface_is_activated:
|
||||
raise ConnectionError('The network interface could not be activated.')
|
||||
|
||||
@staticmethod
|
||||
def system_uses_wireguard_interface():
|
||||
|
||||
if shutil.which('ip') is None:
|
||||
raise CommandNotFoundError('ip')
|
||||
|
||||
process = subprocess.Popen(('ip', 'route', 'get', '192.0.2.1'), stdout=subprocess.PIPE)
|
||||
process_output = str(process.stdout.read())
|
||||
|
||||
return bool(re.search('dev wg', str(process_output)))
|
||||
|
||||
@staticmethod
|
||||
def __establish_system_connection(profile: SystemProfile, connection_observer: Optional[ConnectionObserver] = None):
|
||||
|
||||
if shutil.which('dbus-send') is None:
|
||||
raise CommandNotFoundError('dbus-send')
|
||||
|
||||
if shutil.which('nmcli') is None:
|
||||
raise CommandNotFoundError('nmcli')
|
||||
|
||||
ConnectionController.terminate_system_connection()
|
||||
|
||||
# ── 1. Import wg config ───────────────────────────────────────────────
|
||||
try:
|
||||
process_output = subprocess.check_output(
|
||||
('nmcli', 'connection', 'import', '--temporary', 'type', 'wireguard', 'file',
|
||||
profile.get_wireguard_configuration_path()), text=True
|
||||
)
|
||||
except CalledProcessError:
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
|
||||
# ── 2. Activate if nmcli import did not do it automatically (distro-dependent) ──
|
||||
try:
|
||||
wg_up = subprocess.run(('ip', 'link', 'show', 'wg'), capture_output=True)
|
||||
if wg_up.returncode != 0:
|
||||
subprocess.check_output(('nmcli', 'connection', 'up', 'wg'), text=True)
|
||||
for _ in range(10):
|
||||
time.sleep(0.5)
|
||||
if subprocess.run(('ip', 'link', 'show', 'wg'), capture_output=True).returncode == 0:
|
||||
break
|
||||
else:
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
except CalledProcessError:
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
|
||||
# ── 3. IPv6 method check and sink ────────────────────────────────────
|
||||
try:
|
||||
connection_id = (m := re.search(r'(?<=\()([a-f0-9-]+?)(?=\))', process_output)) and m.group(1)
|
||||
ipv6_method = subprocess.check_output(
|
||||
('nmcli', '-g', 'ipv6.method', 'connection', 'show', connection_id), text=True
|
||||
).strip()
|
||||
except CalledProcessError:
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
|
||||
if ipv6_method in ('disabled', 'ignore'):
|
||||
try:
|
||||
subprocess.run(
|
||||
('dbus-send', '--system', '--print-reply',
|
||||
'--dest=org.freedesktop.NetworkManager', '/org/freedesktop/NetworkManager',
|
||||
'org.freedesktop.DBus.Properties.Set',
|
||||
'string:org.freedesktop.NetworkManager',
|
||||
'string:ConnectivityCheckEnabled', 'variant:boolean:false'),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
|
||||
)
|
||||
except CalledProcessError:
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
('nmcli', 'connection', 'add', 'type', 'dummy', 'save', 'no',
|
||||
'con-name', 'hv-ipv6-sink', 'ifname', 'hvipv6sink0',
|
||||
'ipv6.method', 'manual',
|
||||
'ipv6.addresses', 'fd7a:fd4b:54e3:077c::/64',
|
||||
'ipv6.gateway', 'fd7a:fd4b:54e3:077c::1',
|
||||
'ipv6.dns', '::1', 'ipv6.route-metric', '72'),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
|
||||
)
|
||||
except CalledProcessError:
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
|
||||
|
||||
# ── 4. Apply DNS via resolvectl wrapper (handles polkit correctly) ───
|
||||
try:
|
||||
if ConnectionController.__is_resolved_active():
|
||||
wg_config = profile.get_wireguard_configuration()
|
||||
if wg_config:
|
||||
dns_match = re.search(r'^DNS\s*=\s*(.+)$', wg_config, re.MULTILINE)
|
||||
if dns_match:
|
||||
dns_server = dns_match.group(1).split(',')[0].strip()
|
||||
subprocess.run(
|
||||
('sudo', Constants.RESOLVECTL_WRAPPER, 'set', dns_server, 'wg'),
|
||||
check=False, timeout=5,
|
||||
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
deadline = time.monotonic() + 8.0
|
||||
while time.monotonic() < deadline:
|
||||
result = subprocess.run(
|
||||
('resolvectl', 'status', 'wg'),
|
||||
capture_output=True, text=True, timeout=2
|
||||
)
|
||||
if 'Current DNS Server' in result.stdout:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
except Exception:
|
||||
pass
|
||||
# ── 5. Arm killswitch ─────────────────────────────────────────────────
|
||||
try:
|
||||
wg_server_ip = ConnectionController.__extract_wireguard_endpoint(profile)
|
||||
if wg_server_ip:
|
||||
from core.utils.encrypted_proxy import killswitch
|
||||
killswitch.arm(wg_server_ip, 'wg')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Wait for tunnel to be fully routable before testing connectivity
|
||||
import socket as _socket
|
||||
deadline = time.monotonic() + 10.0
|
||||
tunnel_ready = False
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
_socket.getaddrinfo('hc1.simplifiedprivacy.net', 443, _socket.AF_INET, _socket.SOCK_STREAM)
|
||||
tunnel_ready = True
|
||||
break
|
||||
except (_socket.gaierror, OSError):
|
||||
time.sleep(0.5)
|
||||
if not tunnel_ready:
|
||||
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})
|
||||
|
||||
try:
|
||||
ConnectionController.await_connection(connection_observer=connection_observer)
|
||||
|
||||
except ConnectionError:
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
|
||||
@staticmethod
|
||||
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 Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def __extract_wireguard_endpoint(profile):
|
||||
import re, socket
|
||||
try:
|
||||
config = profile.get_wireguard_configuration()
|
||||
if config is None:
|
||||
return None
|
||||
match = re.search(r'Endpoint\s*=\s*([^\s:]+):\d+', config)
|
||||
if not match:
|
||||
return None
|
||||
host = match.group(1)
|
||||
if re.match(r'^(\d{1,3}\.){3}\d{1,3}$', host):
|
||||
return host
|
||||
return socket.gethostbyname(host)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __with_tor_connection(*args, task: Callable[..., Any], connection_observer: Optional[ConnectionObserver] = None, **kwargs):
|
||||
|
|
@ -680,99 +220,9 @@ class ConnectionController:
|
|||
port_number = ConnectionService.get_random_available_port_number()
|
||||
ConnectionController.establish_tor_session_connection(port_number, connection_observer=connection_observer)
|
||||
|
||||
ConnectionController.await_connection(port_number, connection_observer=connection_observer)
|
||||
await_connection(port_number, connection_observer=connection_observer)
|
||||
task_output = task(*args, proxies=ConnectionController.get_proxies(port_number), **kwargs)
|
||||
|
||||
ConnectionController.terminate_tor_session_connection(port_number)
|
||||
|
||||
return task_output
|
||||
|
||||
@staticmethod
|
||||
def __test_connection(port_number: Optional[int] = None, timeout: float = 4.0):
|
||||
|
||||
import requests as _requests
|
||||
import socket as _socket
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
request_urls = [Constants.PING_URL]
|
||||
proxies = None
|
||||
|
||||
if os.environ.get('PING_URL') is None:
|
||||
|
||||
request_urls.extend([
|
||||
'https://hc1.simplifiedprivacy.net',
|
||||
'https://hc2.simplifiedprivacy.org',
|
||||
'https://hc3.hydraveil.net'
|
||||
])
|
||||
|
||||
random.shuffle(request_urls)
|
||||
|
||||
if port_number is not None:
|
||||
proxies = ConnectionController.get_proxies(port_number)
|
||||
|
||||
for request_url in request_urls:
|
||||
|
||||
try:
|
||||
if proxies is None:
|
||||
parsed = _urlparse(request_url)
|
||||
hostname = parsed.hostname
|
||||
port = parsed.port or (443 if parsed.scheme == 'https' else 80)
|
||||
try:
|
||||
results = _socket.getaddrinfo(hostname, port, _socket.AF_INET, _socket.SOCK_STREAM)
|
||||
if not results:
|
||||
continue
|
||||
resolved_ip = results[0][4][0]
|
||||
resolved_url = request_url.replace(hostname, resolved_ip, 1)
|
||||
response = _requests.get(
|
||||
resolved_url,
|
||||
timeout=timeout,
|
||||
headers={'Host': hostname},
|
||||
verify=False
|
||||
)
|
||||
except (_socket.gaierror, OSError):
|
||||
continue
|
||||
else:
|
||||
response = _requests.get(request_url, proxies=proxies, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return None
|
||||
except (_requests.exceptions.RequestException, OSError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise ConnectionError('The connection could not be established.')
|
||||
|
||||
@staticmethod
|
||||
def __should_renegotiate(profile: Union[SessionProfile, SystemProfile]):
|
||||
|
||||
if not profile.has_subscription():
|
||||
raise MissingSubscriptionError()
|
||||
|
||||
if profile.connection.needs_wireguard_configuration() and profile.has_wireguard_configuration():
|
||||
|
||||
if profile.subscription.has_been_activated():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def verify_singbox_installation():
|
||||
import subprocess
|
||||
import os
|
||||
from core.Errors import SingboxNotInstalledException
|
||||
|
||||
singbox_binary = '/usr/bin/sing-box'
|
||||
singbox_wrapper = '/usr/local/bin/hydraveil-singbox'
|
||||
|
||||
if not os.path.exists(singbox_binary):
|
||||
raise SingboxNotInstalledException(f'sing-box binary not found at {singbox_binary}')
|
||||
|
||||
if not os.path.exists(singbox_wrapper):
|
||||
raise SingboxNotInstalledException(f'sing-box wrapper not found at {singbox_wrapper}')
|
||||
|
||||
try:
|
||||
subprocess.run([singbox_binary, 'version'], capture_output=True, timeout=5, check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
raise SingboxNotInstalledException('sing-box binary check failed')
|
||||
except subprocess.TimeoutExpired:
|
||||
raise SingboxNotInstalledException('sing-box verification timeout')
|
||||
|
|
@ -1,28 +1,26 @@
|
|||
from core.models.orm_models.Location import Location
|
||||
from core.models.orm_models.Operator import Operator
|
||||
from core.errors.logger import logger
|
||||
|
||||
from core.models.manage.session_management import get_session
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||
from core.models.orm_calls.location_calls import execute_location_sql
|
||||
|
||||
class LocationController:
|
||||
|
||||
@staticmethod
|
||||
def get(country_code: str, city_code: str):
|
||||
with get_session() as session:
|
||||
location_object = session.execute(
|
||||
select(Location)
|
||||
.where((Location.country_code == country_code) & (Location.code == city_code))
|
||||
.options(joinedload(Location.operator))
|
||||
).scalar_one_or_none()
|
||||
|
||||
return location_object
|
||||
|
||||
# legacy:
|
||||
# Location.find(country_code, code)
|
||||
|
||||
|
||||
location_object = execute_location_sql(country_code, city_code)
|
||||
if location_object.valid:
|
||||
return location_object.data
|
||||
else:
|
||||
critical_error = f"[BaseProfile] Got invalid SQL Query which could not be solved by the wrapper, with error message {location_object.message} and type {location_object.error_type}"
|
||||
logger.error(critical_error)
|
||||
print(critical_error)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_all():
|
||||
|
|
@ -32,18 +30,3 @@ class LocationController:
|
|||
.options(joinedload(Location.operator))
|
||||
).scalars().all()
|
||||
return all_records
|
||||
|
||||
# legacy:
|
||||
# return Location.all()
|
||||
|
||||
|
||||
# Deprecated legacy sync,
|
||||
|
||||
# from core.services.WebServiceApiService import WebServiceApiService
|
||||
# @staticmethod
|
||||
# def _sync(proxies: Optional[dict] = None):
|
||||
|
||||
# locations = WebServiceApiService.get_locations(proxies)
|
||||
|
||||
# Location.truncate()
|
||||
# Location.save_many(locations)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionTerminationError, ProfileActivationError, ProfileDeactivationError, MissingLocationError, ConnectionUnprotectedError, EndpointVerificationError, ProfileStateConflictError, PaymentRequiredError
|
||||
from core.services.networking.systemwide.systemwide_wireguard import terminate_system_connection
|
||||
from core.services.networking.general_connection_tools.testing_evaluating import system_uses_wireguard_interface
|
||||
from core.services.networking.general_connection_tools.connection_enable import establish_connection
|
||||
from core.services.networking.systemwide.systemwide_utils import get_firewall_setting, get_dns_setting
|
||||
from core.services.networking.systemwide.encrypted_proxy.singbox_runner import end_singbox
|
||||
from core.services.networking.systemwide import killswitch
|
||||
from core.services.subscriptions import subscriptions
|
||||
from core.errors.exceptions import FirewallError
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.observers.TicketObserver import TicketObserver
|
||||
from core.services.assassin.ticket_respawn import respawn_profile
|
||||
from core.services.assassin import assassin_tools
|
||||
from core.errors.logger import logger
|
||||
|
||||
from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionTerminationError, ProfileActivationError, ProfileDeactivationError, MissingLocationError, ConnectionUnprotectedError, EndpointVerificationError, ProfileStateConflictError
|
||||
from core.controllers.ApplicationController import ApplicationController
|
||||
from core.controllers.ApplicationVersionController import ApplicationVersionController
|
||||
from core.controllers.SessionStateController import SessionStateController
|
||||
|
|
@ -35,138 +49,206 @@ class ProfileController:
|
|||
if profile_observer is not None:
|
||||
profile_observer.notify('created', profile)
|
||||
|
||||
@staticmethod
|
||||
def update(profile: Union[SessionProfile, SystemProfile], profile_observer: ProfileObserver = None):
|
||||
|
||||
profile.save()
|
||||
|
||||
if profile_observer is not None:
|
||||
profile_observer.notify('updated', profile)
|
||||
|
||||
@staticmethod
|
||||
def enable(profile: Union[SessionProfile, SystemProfile], ignore: tuple[type[Exception]] = (), pristine: bool = False, asynchronous: bool = False, profile_observer: ProfileObserver = None, application_version_observer: ApplicationVersionObserver = None, connection_observer: ConnectionObserver = None):
|
||||
def enable(
|
||||
profile: Union[SessionProfile, SystemProfile],
|
||||
ignore: tuple[type[Exception]] = (),
|
||||
pristine: bool = False,
|
||||
asynchronous: bool = False,
|
||||
profile_observer: ProfileObserver = None,
|
||||
application_version_observer: ApplicationVersionObserver = None,
|
||||
connection_observer: ConnectionObserver = None,
|
||||
ticket_observer: TicketObserver = None,
|
||||
max_resolution: Optional[str] = None
|
||||
):
|
||||
|
||||
from core.controllers.ConnectionController import ConnectionController
|
||||
|
||||
# =========== ALREADY ENABLED ============
|
||||
if ProfileController.is_enabled(profile):
|
||||
|
||||
if not ProfileStateConflictError in ignore:
|
||||
raise ProfileStateConflictError('The profile is already enabled or its session was not properly terminated.')
|
||||
else:
|
||||
ProfileController.disable(profile)
|
||||
|
||||
# =========== PRISTINE ============
|
||||
if pristine:
|
||||
profile.delete_data()
|
||||
|
||||
# ============================================================================
|
||||
# SESSION
|
||||
# ============================================================================
|
||||
if profile.is_session_profile():
|
||||
|
||||
# ASSASSIN MODE
|
||||
if profile.assassin:
|
||||
assassin_result = assassin_tools.create(
|
||||
profile=profile,
|
||||
max_resolution=max_resolution,
|
||||
ticket_observer=ticket_observer,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
if not assassin_result.valid:
|
||||
error_msg = f"Error with setting up Assassin: {assassin_result.error_type} & {assassin_result.message}"
|
||||
logger.error(error_msg)
|
||||
raise ProfileActivationError(f'Assassin NOT enabled: {assassin_result.message}')
|
||||
# random assassin values become the main profile:
|
||||
profile = assassin_result.data
|
||||
# CONCLUSION OF ASSASSIN CODE
|
||||
|
||||
application_version = profile.application_version
|
||||
|
||||
if not application_version.is_installed():
|
||||
ApplicationVersionController.install(application_version, application_version_observer=application_version_observer, connection_observer=connection_observer)
|
||||
|
||||
try:
|
||||
port_number = ConnectionController.establish_connection(profile, ignore=ignore, connection_observer=connection_observer)
|
||||
port_number = establish_connection(profile, ignore=ignore, connection_observer=connection_observer, ticket_observer=ticket_observer)
|
||||
except ConnectionError:
|
||||
raise ProfileActivationError('The profile could not be enabled.')
|
||||
except ValueError:
|
||||
raise ProfileActivationError('The profile could not be enabled.')
|
||||
|
||||
if profile_observer is not None:
|
||||
profile_observer.notify('enabled', profile)
|
||||
|
||||
ApplicationController.launch(application_version, profile, port_number, asynchronous=asynchronous, profile_observer=profile_observer)
|
||||
ApplicationController.launch(application_version, profile, port_number, asynchronous=asynchronous, profile_observer=profile_observer, ticket_observer=ticket_observer, connection_observer=connection_observer)
|
||||
|
||||
# ============================================================================
|
||||
# SYSTEMWIDE
|
||||
# ============================================================================
|
||||
if profile.is_system_profile():
|
||||
|
||||
if profile.connection.code in ('vless', 'hysteria2'):
|
||||
ProfileController._enable_encrypted_proxy(profile, connection_observer=connection_observer)
|
||||
if profile_observer is not None:
|
||||
profile_observer.notify('enabled', profile)
|
||||
return
|
||||
|
||||
# WireGuard and other system profiles — legacy flow
|
||||
from core.controllers.ConnectionController import ConnectionController
|
||||
try:
|
||||
ConnectionController.establish_connection(profile, ignore=ignore, connection_observer=connection_observer)
|
||||
connection_result = establish_connection(profile, ignore=ignore, connection_observer=connection_observer)
|
||||
if connection_result.valid:
|
||||
if profile_observer is not None:
|
||||
profile_observer.notify('enabled', profile)
|
||||
else:
|
||||
logger.error(f"Couldn't enable the profile: {connection_result.error_type}")
|
||||
error_msg = connection_result.message
|
||||
raise ProfileActivationError(error_msg)
|
||||
except FirewallError:
|
||||
raise
|
||||
except ConnectionError:
|
||||
raise ProfileActivationError('The profile could not be enabled.')
|
||||
except ValueError:
|
||||
raise ProfileActivationError('The profile could not be enabled.')
|
||||
|
||||
if profile_observer is not None:
|
||||
profile_observer.notify('enabled', profile)
|
||||
|
||||
@staticmethod
|
||||
def _enable_encrypted_proxy(profile, connection_observer=None) -> None:
|
||||
|
||||
from core.controllers.ConnectionController import ConnectionController
|
||||
from core.controllers.SystemStateController import SystemStateController
|
||||
from core.services.WebServiceApiService import WebServiceApiService
|
||||
|
||||
if not profile.has_operator_proxy_session():
|
||||
ProfileController.activate_subscription(profile, connection_observer=connection_observer)
|
||||
operator_proxy_session = ConnectionController.with_preferred_connection(
|
||||
profile.subscription.billing_code,
|
||||
profile.subscription.operator_id,
|
||||
profile.connection.code,
|
||||
task=WebServiceApiService.post_operator_proxy,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
if operator_proxy_session is None:
|
||||
raise ProfileActivationError('Could not obtain operator proxy session.')
|
||||
profile.attach_operator_proxy_session(operator_proxy_session)
|
||||
|
||||
ConnectionController._signal_previous_process()
|
||||
ok = ConnectionController.establish_encrypted_proxy_connection(
|
||||
profile, socks5_port=1080, observer=connection_observer
|
||||
)
|
||||
if not ok:
|
||||
raise ProfileActivationError('The profile could not be enabled.')
|
||||
|
||||
token = SystemStateController.create(profile.id)
|
||||
if connection_observer is not None:
|
||||
connection_observer.notify('connected_token', {'session_token': token})
|
||||
|
||||
@staticmethod
|
||||
def run_monitor(profile: Union[SessionProfile, SystemProfile], monitor) -> None:
|
||||
|
||||
if profile.is_system_profile() and profile.connection.needs_operator_proxy():
|
||||
monitor.run_blocking()
|
||||
|
||||
@staticmethod
|
||||
def disable(profile: Union[SessionProfile, SystemProfile], explicitly: bool = True, ignore: tuple[type[Exception]] = (), profile_observer: ProfileObserver = None, connection_observer: ConnectionObserver = None):
|
||||
def disable(
|
||||
profile: Union[SessionProfile, SystemProfile],
|
||||
explicitly: bool = True,
|
||||
ignore: tuple[type[Exception]] = (),
|
||||
profile_observer: ProfileObserver = None,
|
||||
ticket_observer: TicketObserver = None,
|
||||
connection_observer: ConnectionObserver = None,
|
||||
wipe_assassin: bool = False
|
||||
):
|
||||
|
||||
from core.controllers.ConnectionController import ConnectionController
|
||||
|
||||
if profile.is_session_profile():
|
||||
|
||||
# START ASSASSIN SECTION
|
||||
if profile.assassin and wipe_assassin:
|
||||
print("triggering to WIPE assassin")
|
||||
assassin_result = assassin_tools.wipe(
|
||||
profile=profile,
|
||||
ticket_observer=ticket_observer,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
if not assassin_result.valid:
|
||||
error_msg = f"Error with wiping respawn of Assassin: {assassin_result.error_type} & {assassin_result.message}"
|
||||
logger.error(error_msg)
|
||||
raise ProfileDeactivationError(f'Assassin NOT disabled: {assassin_result.message}')
|
||||
# END ASSASSIN SECTION
|
||||
|
||||
if SessionStateController.exists(profile.id):
|
||||
|
||||
session_state = SessionStateController.get(profile.id)
|
||||
|
||||
if session_state is not None:
|
||||
|
||||
for port_number in session_state.network_port_numbers.tor:
|
||||
ConnectionController.terminate_tor_session_connection(port_number)
|
||||
|
||||
session_state.dissolve(session_state.id)
|
||||
|
||||
if profile_observer is not None:
|
||||
|
||||
profile_observer.notify('disabled', profile, dict(
|
||||
explicitly=explicitly,
|
||||
))
|
||||
|
||||
# ============================================================================
|
||||
# SYSTEMWIDE
|
||||
# ============================================================================
|
||||
if profile.is_system_profile():
|
||||
|
||||
subjects = ProfileController.get_all().values()
|
||||
|
||||
for subject in subjects:
|
||||
|
||||
if subject.is_session_profile():
|
||||
|
||||
if subject.connection.is_unprotected() and ProfileController.is_enabled(subject) and not ConnectionUnprotectedError in ignore:
|
||||
raise ConnectionUnprotectedError('Disabling this system connection would leave one or more sessions exposed.')
|
||||
|
||||
if SystemStateController.exists():
|
||||
|
||||
system_state = SystemStateController.get()
|
||||
|
||||
if profile.id != system_state.profile_id:
|
||||
raise ProfileDeactivationError('The profile could not be disabled.')
|
||||
|
||||
try:
|
||||
ConnectionController.terminate_system_connection(connection_observer=connection_observer)
|
||||
# ================= SETTINGS =================
|
||||
firewall_setting = get_firewall_setting()
|
||||
dns_setting = get_dns_setting()
|
||||
# ======= KILL SYSTEMWIDE =============
|
||||
if profile.connection.code == "wireguard":
|
||||
terminate_system_connection(
|
||||
firewall_setting=firewall_setting,
|
||||
dns_setting=dns_setting
|
||||
)
|
||||
elif profile.connection.code in ("hysteria2", "vless"):
|
||||
end_singbox()
|
||||
else:
|
||||
raise ProfileDeactivationError('Unsupported protocol.')
|
||||
# ================= UPDATE UI ================
|
||||
# if it made it this far, it worked in theory.
|
||||
if profile_observer is not None:
|
||||
profile_observer.notify('disabled', profile, dict(
|
||||
explicitly=explicitly,
|
||||
))
|
||||
except ConnectionTerminationError:
|
||||
raise ProfileDeactivationError('The profile could not be disabled.')
|
||||
|
||||
if profile_observer is not None:
|
||||
profile_observer.notify('disabled', profile, dict(explicitly=explicitly))
|
||||
except ValueError:
|
||||
raise ProfileDeactivationError('The profile could not be disabled.')
|
||||
except FirewallError:
|
||||
raise
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
@staticmethod
|
||||
def destroy(profile: Union[SessionProfile, SystemProfile], profile_observer: ProfileObserver = None):
|
||||
def destroy(profile: Union[SessionProfile, SystemProfile], profile_observer: ProfileObserver = None, ticket_observer: TicketObserver = None, connection_observer: ConnectionObserver = None):
|
||||
|
||||
####################################
|
||||
# DESTROY TICKET
|
||||
####################################
|
||||
which_ticket = profile.ticket
|
||||
respawned = respawn_profile(
|
||||
profile=profile,
|
||||
ticket_observer=ticket_observer,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
if which_ticket and not respawned:
|
||||
logger.error("Serious issue with respawning the ticket. We might raise an error here.")
|
||||
|
||||
####################################
|
||||
# DESTROY PROFILE
|
||||
####################################
|
||||
ProfileController.disable(profile)
|
||||
profile.delete()
|
||||
|
||||
|
|
@ -186,29 +268,19 @@ class ProfileController:
|
|||
|
||||
if profile.has_subscription():
|
||||
|
||||
# Operator profiles (vless/hysteria2): check if already activated locally first.
|
||||
# If not, poll the server — the payment may have been processed since last attempt.
|
||||
if profile.subscription.operator_id is not None:
|
||||
if profile.subscription.has_been_activated():
|
||||
profile.save()
|
||||
return
|
||||
subscription = ConnectionController.with_preferred_connection(
|
||||
profile.subscription.billing_code,
|
||||
task=WebServiceApiService.get_subscription,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
if subscription is not None:
|
||||
profile.subscription = subscription
|
||||
profile.save()
|
||||
return
|
||||
else:
|
||||
raise PaymentRequiredError()
|
||||
subscription = subscriptions.get_subscription(
|
||||
billing_code=profile.subscription.billing_code,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
|
||||
subscription = ConnectionController.with_preferred_connection(profile.subscription.billing_code, task=WebServiceApiService.get_subscription, connection_observer=connection_observer)
|
||||
# legacy:
|
||||
# subscription = ConnectionController.with_preferred_connection(profile.subscription.billing_code, task=WebServiceApiService.get_subscription, connection_observer=connection_observer)
|
||||
|
||||
if subscription is not None:
|
||||
|
||||
profile.subscription = subscription
|
||||
profile.save()
|
||||
|
||||
else:
|
||||
raise InvalidSubscriptionError()
|
||||
|
||||
|
|
@ -230,7 +302,7 @@ class ProfileController:
|
|||
system_state = SystemStateController.get()
|
||||
|
||||
if system_state is not None and system_state.profile_id is profile.id:
|
||||
return ConnectionController.system_uses_wireguard_interface()
|
||||
return system_uses_wireguard_interface()
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -238,6 +310,7 @@ class ProfileController:
|
|||
def get_invoice(profile: Union[SessionProfile, SystemProfile]):
|
||||
|
||||
if profile.has_subscription():
|
||||
# return subscriptions.get_invoice(billing_code=profile.subscription.billing_code)
|
||||
return WebServiceApiService.get_invoice(profile.subscription.billing_code)
|
||||
else:
|
||||
return None
|
||||
|
|
@ -264,29 +337,6 @@ class ProfileController:
|
|||
def has_proxy_configuration(profile: Union[SessionProfile, SystemProfile]):
|
||||
profile.has_proxy_configuration()
|
||||
|
||||
@staticmethod
|
||||
def register_wireguard_session(profile: Union[SessionProfile, SystemProfile], connection_observer: Optional[ConnectionObserver] = None):
|
||||
|
||||
from core.controllers.ConnectionController import ConnectionController
|
||||
|
||||
if not profile.has_subscription():
|
||||
raise MissingSubscriptionError()
|
||||
|
||||
if not profile.has_location():
|
||||
raise MissingLocationError()
|
||||
|
||||
wireguard_keys = ProfileController.__generate_wireguard_keys()
|
||||
|
||||
wireguard_configuration = ConnectionController.with_preferred_connection(profile.location.country_code, profile.location.code, profile.subscription.billing_code, wireguard_keys.get('public'), task=WebServiceApiService.post_wireguard_session, connection_observer=connection_observer)
|
||||
|
||||
if wireguard_configuration is None:
|
||||
raise InvalidSubscriptionError()
|
||||
|
||||
expression = re.compile(r'^(PrivateKey =)\s?$', re.MULTILINE)
|
||||
wireguard_configuration = re.sub(expression, r'\1 ' + wireguard_keys.get('private'), wireguard_configuration)
|
||||
|
||||
profile.attach_wireguard_configuration(wireguard_configuration)
|
||||
|
||||
@staticmethod
|
||||
def get_wireguard_configuration_path(profile: Union[SessionProfile, SystemProfile]):
|
||||
return profile.get_wireguard_configuration_path()
|
||||
|
|
@ -296,64 +346,11 @@ class ProfileController:
|
|||
return profile.has_wireguard_configuration()
|
||||
|
||||
@staticmethod
|
||||
def verify_wireguard_endpoint(profile: Union[SessionProfile, SystemProfile], ignore: tuple[type[Exception]] = ()):
|
||||
|
||||
try:
|
||||
ProfileController.__verify_wireguard_endpoint(profile)
|
||||
|
||||
except EndpointVerificationError as error:
|
||||
|
||||
if not EndpointVerificationError in ignore:
|
||||
|
||||
profile.address_security_incident()
|
||||
raise error
|
||||
def turn_on_assassin(profile: SessionProfile):
|
||||
profile.assassin = True
|
||||
profile.save()
|
||||
|
||||
@staticmethod
|
||||
def __verify_wireguard_endpoint(profile: Union[SessionProfile, SystemProfile]):
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
import base64
|
||||
|
||||
signature = profile.get_wireguard_configuration_metadata('Signature')
|
||||
wireguard_public_keys = profile.get_wireguard_public_keys()
|
||||
operator = profile.location.operator
|
||||
|
||||
if signature is None:
|
||||
raise EndpointVerificationError('The WireGuard endpoint\'s signature could not be determined.')
|
||||
|
||||
if not wireguard_public_keys:
|
||||
raise EndpointVerificationError('The WireGuard endpoint\'s public key could not be determined.')
|
||||
|
||||
if operator is None:
|
||||
raise EndpointVerificationError('The WireGuard endpoint\'s operator could not be determined.')
|
||||
|
||||
try:
|
||||
|
||||
operator_public_key = ed25519.Ed25519PublicKey.from_public_bytes(bytes.fromhex(operator.public_key))
|
||||
|
||||
for wireguard_public_key in wireguard_public_keys:
|
||||
operator_public_key.verify(base64.b64decode(signature), wireguard_public_key.encode('utf-8'))
|
||||
|
||||
except Exception:
|
||||
raise EndpointVerificationError('The WireGuard endpoint could not be verified.')
|
||||
|
||||
@staticmethod
|
||||
def __generate_wireguard_keys():
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
|
||||
raw_private_key = X25519PrivateKey.generate()
|
||||
|
||||
public_key = raw_private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
|
||||
)
|
||||
|
||||
private_key = raw_private_key.private_bytes(
|
||||
encoding=serialization.Encoding.Raw, format=serialization.PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
return dict(
|
||||
private=base64.b64encode(private_key).decode(),
|
||||
public=base64.b64encode(public_key).decode()
|
||||
)
|
||||
def turn_off_assassin(profile: SessionProfile):
|
||||
profile.assassin = False
|
||||
profile.save()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from core.observers.ConnectionObserver import ConnectionObserver
|
|||
from core.services.WebServiceApiService import WebServiceApiService
|
||||
from typing import Union
|
||||
|
||||
|
||||
class SubscriptionController:
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -17,51 +18,4 @@ class SubscriptionController:
|
|||
def create(subscription_plan: SubscriptionPlan, profile: Union[SessionProfile, SystemProfile], connection_observer: ConnectionObserver = None):
|
||||
|
||||
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,
|
||||
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
|
||||
)
|
||||
return ConnectionController.with_preferred_connection(subscription_plan.id, profile.location.id, task=WebServiceApiService.post_subscription, connection_observer=connection_observer)
|
||||
|
|
|
|||
217
core/controllers/SyncController.py
Normal file
217
core/controllers/SyncController.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
from core.services.sync import legacy_insert
|
||||
from core.models.manage.session_management import init_session, close_session
|
||||
from core.services.sync.sync_service import coordinate_cache_sync, save_metadata
|
||||
from core.services.sync.insert_for_orm import insert_one_orm_model
|
||||
from core.services.networking.httpx import connect
|
||||
|
||||
# return objects
|
||||
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||
from core.models.Result import Result, ResultError
|
||||
|
||||
|
||||
from core.errors.logger import logger
|
||||
|
||||
# ORM models that can be sync'ed:
|
||||
from core.models.orm_models.Location import Location
|
||||
from core.models.orm_models.Operator import Operator
|
||||
from core.models.orm_models.Dependency import Dependency
|
||||
from core.Constants import Constants
|
||||
|
||||
from core.controllers.ApplicationController import ApplicationController
|
||||
from core.controllers.ApplicationVersionController import ApplicationVersionController
|
||||
from core.controllers.ClientVersionController import ClientVersionController
|
||||
from core.controllers.ConfigurationController import ConfigurationController
|
||||
from core.controllers.SubscriptionPlanController import SubscriptionPlanController
|
||||
from core.observers.ClientObserver import ClientObserver
|
||||
from core.observers.ConnectionObserver import ConnectionObserver
|
||||
from core.models.orm_models.ApplicationVersion import ApplicationVersion
|
||||
|
||||
# generic
|
||||
import sys
|
||||
import json
|
||||
|
||||
ORM_TABLES = {
|
||||
"locations": Location,
|
||||
"operators": Operator,
|
||||
"application_versions": ApplicationVersion,
|
||||
"dependencies": Dependency
|
||||
}
|
||||
|
||||
LEGACY_SQL_FUNCT_DICT = {
|
||||
"applications": legacy_insert.for_applications,
|
||||
"client_version": legacy_insert.for_client_version,
|
||||
"subscriptions": legacy_insert.for_subscriptions,
|
||||
}
|
||||
|
||||
|
||||
APP_CODES = {
|
||||
"firefox": 1,
|
||||
"chromium": 2,
|
||||
"brave": 3,
|
||||
"librewolf": 5
|
||||
}
|
||||
|
||||
def call_legacy_insert_function(key: str, new_data: dict):
|
||||
func = LEGACY_SQL_FUNCT_DICT.get(key)
|
||||
if func is None:
|
||||
return DatabaseOperation(valid=False, error_type=DBErrorType.UNKNOWN_MODEL)
|
||||
if key in APP_CODES:
|
||||
code = APP_CODES[key]
|
||||
return func(new_data, code)
|
||||
else:
|
||||
return func(new_data)
|
||||
|
||||
|
||||
def get_orm_model(key) -> bool:
|
||||
if key not in ORM_TABLES:
|
||||
return False
|
||||
return ORM_TABLES[key]
|
||||
|
||||
|
||||
|
||||
def new_sync(client_observer: ClientObserver, connection_observer: ConnectionObserver) -> Result:
|
||||
client_observer.notify('synchronizing', "Fetching list of new data ..")
|
||||
|
||||
####################################
|
||||
# METADATA. Should we even sync?
|
||||
####################################
|
||||
metadata_result = coordinate_cache_sync(client_observer, connection_observer)
|
||||
|
||||
# Outright Error:
|
||||
if not metadata_result["success"]:
|
||||
error_msg = metadata_result["error"]
|
||||
client_observer.notify('synchronizing', f'Error! {error_msg}')
|
||||
return Result(valid=False)
|
||||
|
||||
# Nothing changed if the 'changed_tables' variable does NOT exist
|
||||
changed_tables = metadata_result["changed_tables"]
|
||||
|
||||
# NEW MODELS:
|
||||
new_model_types = metadata_result.get("new_model_types", [])
|
||||
if new_model_types:
|
||||
for each_new_model in new_model_types:
|
||||
if each_new_model in ORM_TABLES:
|
||||
# Then we know how to handle this, let's sync it:
|
||||
logger.info(f"Adding the brand new {each_new_model} to the changed tables.")
|
||||
changed_tables.append(each_new_model)
|
||||
else:
|
||||
logger.info(f"Skipping {each_new_model} because we don't know how to handle it yet.")
|
||||
|
||||
|
||||
# CHECK ON CHANGED TABLES
|
||||
if not changed_tables:
|
||||
client_observer.notify('synchronized')
|
||||
return Result(valid=True)
|
||||
|
||||
# We only make it past this point if there's New Data
|
||||
|
||||
####################################
|
||||
# API CALLS: GET NEW DATA IN BULK
|
||||
####################################
|
||||
results = connect.bulk_async(
|
||||
wanted_list=changed_tables,
|
||||
observer=connection_observer,
|
||||
client_observer=client_observer
|
||||
)
|
||||
|
||||
if not results.valid:
|
||||
error_msg = f"Sync failed! {results.error_type}"
|
||||
logger.error(f"{error_msg} {results.message}")
|
||||
client_observer.notify('synchronizing', f'Error! {error_msg}')
|
||||
return Result(valid=False)
|
||||
|
||||
quantity_of_entries = len(results.data)
|
||||
logger.info(f"We have valid API call results. There are {quantity_of_entries} entries")
|
||||
all_results = results.data
|
||||
|
||||
####################################
|
||||
# LOOP INSERT INTO DATABASE
|
||||
####################################
|
||||
client_observer.notify('synchronizing', f'Inserting into Database..')
|
||||
skipped = []
|
||||
|
||||
for key, each_api_result in all_results.items():
|
||||
if not each_api_result.valid:
|
||||
logger.info(f"Skipping invalid api response for {key}")
|
||||
skipped.append(key)
|
||||
continue
|
||||
|
||||
logger.info(f"Inserting valid api data for {key} into the Database")
|
||||
each_insert = insert_data(
|
||||
key=key,
|
||||
each_api_calls_data=each_api_result.data,
|
||||
client_observer=client_observer
|
||||
)
|
||||
logger.info(f"Exited the insert data function with a result of {each_insert.valid}")
|
||||
|
||||
if each_insert.valid:
|
||||
logger.info(f"Success with insert of {key}")
|
||||
client_observer.notify('synchronizing', f'Inserted {key}')
|
||||
continue
|
||||
else:
|
||||
skipped.append(key)
|
||||
logger.error(f"Database error with inserting {key}, because {each_insert.error_type}")
|
||||
client_observer.notify('synchronizing', f'Failed inserting {key} because {each_insert.message}')
|
||||
|
||||
logger.info("This only gets triggered in errors, but Moving on to the next item..")
|
||||
|
||||
####################################
|
||||
# FINAL EVALUATION
|
||||
####################################
|
||||
total_skipped = len(skipped)
|
||||
logger.info(f"We exited the loop, doing the final evaluation. And a total of {total_skipped} were skipped.")
|
||||
ConfigurationController.update_last_synced_at()
|
||||
|
||||
filtered_metadata = metadata_result["filtered_metadata"] # from the top of the function
|
||||
|
||||
if total_skipped == 0:
|
||||
client_observer.notify('synchronized', "Fetch & Save Complete!")
|
||||
save_successful = save_metadata(filtered_metadata)
|
||||
if save_successful:
|
||||
return Result(valid=True, message="Finshed sync.")
|
||||
else:
|
||||
error_msg = "Finshed sync, but had issues with the saving of metadata for next time."
|
||||
logger.error(error_msg)
|
||||
return Result(valid=True, message=error_msg)
|
||||
|
||||
elif total_skipped < quantity_of_entries:
|
||||
error_msg = f"Partial Success. {total_skipped} skipped."
|
||||
client_observer.notify('synchronized', error_msg)
|
||||
return Result(valid=True, data=skipped, message=error_msg)
|
||||
|
||||
else:
|
||||
error_msg = f"Sync Failed. All {total_skipped} entries were skipped!"
|
||||
client_observer.notify('synchronized', error_msg)
|
||||
return Result(valid=False, data=skipped, message="Complete Failure, all data failed to insert.")
|
||||
|
||||
|
||||
def insert_data(
|
||||
key: str,
|
||||
each_api_calls_data: dict,
|
||||
client_observer: ClientObserver,
|
||||
) -> DatabaseOperation:
|
||||
|
||||
####################################
|
||||
# NEW ORM SYSTEM
|
||||
####################################
|
||||
new_orm_model = get_orm_model(key)
|
||||
|
||||
if new_orm_model:
|
||||
db_result = insert_one_orm_model(
|
||||
which_key=key,
|
||||
which_model=new_orm_model,
|
||||
new_data=each_api_calls_data,
|
||||
override=True
|
||||
)
|
||||
return db_result
|
||||
|
||||
####################################
|
||||
# LEGACY MANUAL SQL
|
||||
####################################
|
||||
return call_legacy_insert_function(key=key, new_data=each_api_calls_data)
|
||||
|
||||
####################################
|
||||
# UNKNOWN MODEL
|
||||
####################################
|
||||
return DatabaseOperation(valid=False, error_type=DBErrorType.UNKNOWN_MODEL)
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import os
|
||||
import uuid
|
||||
from core.models.system.SystemState import SystemState
|
||||
|
||||
|
||||
|
|
@ -14,11 +12,13 @@ class SystemStateController:
|
|||
return SystemState.exists()
|
||||
|
||||
@staticmethod
|
||||
def create(profile_id):
|
||||
token = str(uuid.uuid4())
|
||||
pid = os.getpid()
|
||||
SystemState(profile_id, token, pid).save()
|
||||
return token
|
||||
def create(profile_id: int, firewalled: bool, dns_set: bool, process_id: int = None) -> SystemState:
|
||||
if process_id:
|
||||
current_state = SystemState(profile_id, firewalled, dns_set, process_id)
|
||||
else:
|
||||
current_state = SystemState(profile_id, firewalled, dns_set)
|
||||
current_state.save()
|
||||
return current_state
|
||||
|
||||
@staticmethod
|
||||
def update_or_create(system_state):
|
||||
|
|
@ -26,4 +26,4 @@ class SystemStateController:
|
|||
|
||||
@staticmethod
|
||||
def dissolve():
|
||||
return SystemState.dissolve()
|
||||
return SystemState.dissolve()
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
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,
|
||||
)
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
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)
|
||||
136
core/controllers/profile_state/update_profile.py
Normal file
136
core/controllers/profile_state/update_profile.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# utils
|
||||
from core.errors.logger import logger
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.services.networking.systemwide.general_tools.manage_sudo_configs import remove_sudo_config
|
||||
|
||||
# JSON Models
|
||||
from core.models.BaseProfile import BaseProfile as Profile
|
||||
from core.models.Subscription import Subscription
|
||||
from core.models.session.SessionProfile import SessionProfile
|
||||
from core.models.system.SystemProfile import SystemProfile
|
||||
|
||||
# ORM Models
|
||||
from core.models.orm_models.Location import Location
|
||||
from core.models.orm_models.Operator import Operator
|
||||
|
||||
# ORM Calls
|
||||
from core.models.orm_calls.location_calls import get_profile_location_data
|
||||
from core.models.BaseProfile import get_application_version
|
||||
|
||||
SYSTEMWIDE_CHOICES = ['wireguard', 'hysteria2', 'vless']
|
||||
SESSION_CHOICES = ['wireguard', 'tor', 'proxy']
|
||||
|
||||
"""
|
||||
Steps:
|
||||
1) Looks up the profile by id number
|
||||
2) Filters what values to save, using the ORM for some such as location
|
||||
3) Saves it with the abstract class' json serialization methods, which use the ORM methods
|
||||
|
||||
Returns:
|
||||
Result Object
|
||||
|
||||
Called by:
|
||||
GUI's editor_page
|
||||
|
||||
Raises Errors:
|
||||
False
|
||||
"""
|
||||
|
||||
def update_profile(
|
||||
profile_id: int,
|
||||
key: str,
|
||||
new_value: str) -> Result:
|
||||
|
||||
final_data = None
|
||||
logger.info(f"[UPDATE PROFILE] Recieved profile_id {profile_id}, key {key}, and new_value {new_value}.")
|
||||
|
||||
if not key or not new_value:
|
||||
return Result(valid=False, data=f"Invalid Inputs of {key} and {new_value}", error_type=ResultError.INVALID_INPUT)
|
||||
|
||||
profile = Profile.find_by_id(profile_id)
|
||||
if not profile:
|
||||
error_msg = f"Invalid profile id of {profile_id}"
|
||||
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
|
||||
|
||||
if key == 'dimentions':
|
||||
profile.resolution = new_value
|
||||
|
||||
elif key == 'name':
|
||||
profile.name = new_value
|
||||
|
||||
# ============= CONNECTION TYPE =============
|
||||
elif key == 'connection':
|
||||
if new_value == 'tor':
|
||||
profile.connection.code = new_value
|
||||
profile.connection.masked = True
|
||||
|
||||
elif new_value == 'just proxy':
|
||||
profile.connection.code = 'system'
|
||||
profile.connection.masked = True
|
||||
else:
|
||||
error_msg = 'System wide profiles not supported atm'
|
||||
return Result(valid=False, message=error_msg, error_type=ResultError.NOT_SUPPORTED)
|
||||
|
||||
# ============= BROWSER =============
|
||||
elif key == 'browser':
|
||||
browser_type, browser_version = new_value.split(':', 1)
|
||||
|
||||
application_version = get_application_version( # SQLAlchemy
|
||||
application_code=browser_type,
|
||||
version_number=browser_version
|
||||
)
|
||||
# SQLAlchemy foreign key assignment — fills in id, timezone, operator, etc.
|
||||
profile.application_version = application_version
|
||||
|
||||
elif key == 'protocol':
|
||||
# ============= SAME VALUE =============
|
||||
if profile.connection.code == new_value:
|
||||
error_msg = f"This is NOT changing the protocol, it already was {new_value}"
|
||||
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
|
||||
|
||||
# ============= SYSTEMWIDE =============
|
||||
if profile.connection == 'system-wide':
|
||||
if new_value in SYSTEMWIDE_CHOICES:
|
||||
# REMOVE PAST CONFIG:
|
||||
removed = remove_sudo_config(profile)
|
||||
if not removed.valid:
|
||||
error_msg = "You must give permission to delete the previous protocol's config file."
|
||||
return Result(valid=False, message=error_msg, error_type=ResultError.PERMISSION)
|
||||
|
||||
# UPDATE PROFILE
|
||||
profile.connection.code = new_value
|
||||
final_data = "edit_session"
|
||||
else:
|
||||
error_msg = f"{new_value} is not a systemwide choice"
|
||||
return Result(valid=False, message=error_msg, error_type=ResultError.NOT_SUPPORTED)
|
||||
|
||||
# ============= SESSION =============
|
||||
else:
|
||||
if new_value in SESSION_CHOICES:
|
||||
profile.connection.code = new_value
|
||||
if new_value == 'wireguard':
|
||||
profile.connection.masked = False
|
||||
|
||||
# ============= LOCATION =============
|
||||
elif key == "location":
|
||||
country_code, city_code = new_value.split("_")
|
||||
location = get_profile_location_data( # SQLAlchemy
|
||||
country_code=country_code,
|
||||
city_code=city_code
|
||||
)
|
||||
# SQLAlchemy foreign key assignment — fills in id, timezone, operator, etc.
|
||||
profile.location = location
|
||||
|
||||
# Subscription gets wiped on a location change, and is outside ORM
|
||||
profile.subscription = None
|
||||
else:
|
||||
return Result(valid=False, message="Invalid Value to Edit", error_type=ResultError.INVALID_INPUT)
|
||||
|
||||
logger.info("[UPDATE PROFILE] Passing to Profile model to save..")
|
||||
try:
|
||||
profile.save()
|
||||
logger.info("[UPDATE PROFILE] Save worked, passing to the GUI the Result..")
|
||||
return Result(valid=True, data=final_data)
|
||||
except:
|
||||
error_msg = "Profile save failed"
|
||||
return Result(valid=False, message=error_msg, error_type=ResultError.UNKNOWN)
|
||||
|
|
@ -9,17 +9,21 @@ from core.models.invoice.TicketInvoice import TicketInvoice
|
|||
from core.services.prepare_tickets.get_pub_key import get_pub_key
|
||||
from core.observers.BaseObserver import BaseObserver
|
||||
from core.services.payment_phase.save_and_send_intitial_billing import save_and_send_intitial_billing
|
||||
from core.services.payment_phase.check_if_paid import _check_if_paid
|
||||
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.errors.logger import logger
|
||||
|
||||
from core.services.prepare_tickets.ticket_tracker import does_ticket_tracker_exist
|
||||
from core.services.networking.send_data_to_server import send_data_to_server
|
||||
from core.services.prepare_tickets.setup_ticket_tracker import setup_ticket_tracker
|
||||
from core.services.networking.httpx import connect
|
||||
|
||||
|
||||
from core.services.networking.make_url import make_url
|
||||
# from core.utils.confirm_its_a_valid_key_choice import confirm_its_a_valid_key_choice
|
||||
from core.services.helpers.valid_profile_quantity import valid_profile_quantity
|
||||
from core.errors.exceptions import *
|
||||
from core.errors.logger import logger
|
||||
from core.controllers.tickets.TicketSyncController import sync_ticket_prices
|
||||
|
||||
from core.services.payment_phase.do_we_have_billing_id import do_we_have_billing_id
|
||||
from core.services.payment_phase.ticket_config_tools import do_we_have_billing_id
|
||||
|
||||
"""
|
||||
Inputs: Which plan (key), which crypto, and how many profiles
|
||||
|
|
@ -38,113 +42,90 @@ def initiate_payment(
|
|||
) -> TicketInvoice:
|
||||
###############
|
||||
|
||||
invoice_data_object = TicketInvoice()
|
||||
if bypass_existing == False:
|
||||
tickets_exist_already, path = does_ticket_tracker_exist()
|
||||
logger.debug(f"tickets_exist_already is {tickets_exist_already}")
|
||||
if tickets_exist_already:
|
||||
return Result(valid=False, error_type=ResultError.ALREADY_EXISTS, message="There's already a ticket billing session in progress. Do you want to wipe it?")
|
||||
else:
|
||||
made_ticket_tracker = setup_ticket_tracker(how_many_profiles=how_many_profiles)
|
||||
logger.debug(f"Ticket Tracker doesn't exist, so we made it: {made_ticket_tracker} for {how_many_profiles} profiles")
|
||||
|
||||
billing_id = do_we_have_billing_id()
|
||||
if billing_id:
|
||||
error_msg = "Billing code exists already"
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, error_type=ResultError.BILLING_CODE_EXISTS, data=billing_id, message=error_msg)
|
||||
|
||||
rejected_choices = [None, "", False]
|
||||
|
||||
if how_many_profiles in rejected_choices:
|
||||
notification = "Missing profile quantity, to initiate payment"
|
||||
logger.error(notification)
|
||||
ticket_observer.notify("failed_input", subject=notification)
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=notification)
|
||||
|
||||
if not valid_profile_quantity(how_many_profiles):
|
||||
notification = "Invalid profile quantity"
|
||||
logger.error(notification)
|
||||
ticket_observer.notify("failed_input", subject=notification)
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="You've picked a currently unsupported profile quantity")
|
||||
|
||||
|
||||
if which_key in rejected_choices:
|
||||
notification = "Missing key plan, to initiate payment"
|
||||
logger.error(notification)
|
||||
ticket_observer.notify("failed_input", subject=notification)
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="You didn't pick a key plan. It's either blank, none, or invalid.")
|
||||
|
||||
# get & save the public key:
|
||||
public_key_results = get_pub_key(which_key, connection_observer, "local")
|
||||
|
||||
if not isinstance(public_key_results, dict):
|
||||
notification = f"Invalid Key chosen or the server is down for that key."
|
||||
logger.error(notification)
|
||||
ticket_observer.notify("failed_input", subject=notification)
|
||||
# invoice_data_object.add_error_code("no_pub_key")
|
||||
# return invoice_data_object
|
||||
|
||||
status = public_key_results.get("valid", False)
|
||||
|
||||
if status == False:
|
||||
message = public_key_results.get(
|
||||
"message", "Please fix before you continue"
|
||||
)
|
||||
error_code = public_key_results.get("error_code", "No error_code")
|
||||
notification = f"Connection Issues or Invalid Key chosen. {error_code}"
|
||||
logger.error(notification)
|
||||
ticket_observer.notify("failed_input", subject=notification)
|
||||
return Result(valid=False, error_type=ResultError.CONNECTION, message="There were connection issues with getting the right key.")
|
||||
|
||||
# In this case, the controller is going to send the whole JSON payload to the service,
|
||||
# instead of passing 4 values seperately.
|
||||
payload = {
|
||||
"which_key": which_key,
|
||||
"payment_type": "crypto",
|
||||
"which_cryptocurrency": which_cryptocurrency,
|
||||
"how_many_profiles": how_many_profiles,
|
||||
}
|
||||
|
||||
try:
|
||||
if bypass_existing == False:
|
||||
tickets_exist_already, path = does_ticket_tracker_exist()
|
||||
logger.debug(f"tickets_exist_already is {tickets_exist_already}")
|
||||
|
||||
if tickets_exist_already:
|
||||
invoice_data_object.add_error_code("already_exists")
|
||||
return invoice_data_object
|
||||
|
||||
billing_id = do_we_have_billing_id()
|
||||
if billing_id:
|
||||
invoice_data_object.add_error_code("billing_code_exists")
|
||||
invoice_data_object.temp_billing_code = billing_id
|
||||
return invoice_data_object
|
||||
|
||||
rejected_choices = [None, "", False]
|
||||
|
||||
if how_many_profiles in rejected_choices:
|
||||
notification = "Missing profile quantity, to initiate payment"
|
||||
ticket_observer.notify("failed_input", subject=notification)
|
||||
invoice_data_object.add_error_code("invalid_quantity")
|
||||
return invoice_data_object
|
||||
|
||||
if not valid_profile_quantity(how_many_profiles):
|
||||
notification = "Invalid profile quantity"
|
||||
ticket_observer.notify("failed_input", subject=notification)
|
||||
invoice_data_object.add_error_code("invalid_quantity")
|
||||
return invoice_data_object
|
||||
|
||||
if which_key in rejected_choices:
|
||||
notification = "Missing key plan, to initiate payment"
|
||||
ticket_observer.notify("failed_input", subject=notification)
|
||||
invoice_data_object.add_error_code("no_keyplan")
|
||||
return invoice_data_object
|
||||
|
||||
# get & save the public key:
|
||||
public_key_results = get_pub_key(which_key, connection_observer, "local")
|
||||
|
||||
if isinstance(public_key_results, dict):
|
||||
status = public_key_results.get("valid", False)
|
||||
if status == False:
|
||||
message = public_key_results.get(
|
||||
"message", "Please fix before you continue"
|
||||
)
|
||||
error_code = public_key_results.get("error_code", "No error_code")
|
||||
logger.debug(f"error_code: {error_code}")
|
||||
invoice_data_object.add_error_code(error_code)
|
||||
|
||||
notification = f"Connection Issues or Invalid Key chosen. {message}"
|
||||
ticket_observer.notify("failed_input", subject=notification)
|
||||
|
||||
return invoice_data_object
|
||||
else:
|
||||
notification = f"Invalid Key chosen or the server is down for that key."
|
||||
ticket_observer.notify("failed_input", subject=notification)
|
||||
invoice_data_object.add_error_code("no_pub_key")
|
||||
return invoice_data_object
|
||||
|
||||
# In this case, the controller is going to send the whole JSON payload to the service,
|
||||
# instead of passing 4 values seperately.
|
||||
payload = {
|
||||
"which_key": which_key,
|
||||
"payment_type": "crypto",
|
||||
"which_cryptocurrency": which_cryptocurrency,
|
||||
"how_many_profiles": how_many_profiles,
|
||||
}
|
||||
|
||||
# controller sends to the service:
|
||||
result = save_and_send_intitial_billing(
|
||||
payload, connection_observer, invoice_data_object
|
||||
return save_and_send_intitial_billing(
|
||||
payload=payload,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
|
||||
if result == False or result == None:
|
||||
invoice_data_object.add_error_code("failed_save")
|
||||
except ValueError as e:
|
||||
if ticket_observer:
|
||||
ticket_observer.notify("failed_input", subject=str(e))
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=str(e))
|
||||
|
||||
return invoice_data_object
|
||||
|
||||
except InvalidData as e:
|
||||
error_msg = "Invalid Data."
|
||||
ticket_observer.notify("failed_input", subject=error_msg)
|
||||
invoice_data_object.add_error_code("invalid_data")
|
||||
return invoice_data_object
|
||||
|
||||
except NetworkingError as e:
|
||||
error_msg = f"NetworkingError: {e}"
|
||||
except ConnectionError as e:
|
||||
error_msg = "There were connection issues with getting the right key."
|
||||
logger.error(error_msg, exc_info=True)
|
||||
ticket_observer.notify("connection_error", subject=error_msg)
|
||||
invoice_data_object.add_error_code("connection_error")
|
||||
return invoice_data_object
|
||||
|
||||
except ServerSideError as e:
|
||||
error_msg = f"ServerSideError: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
ticket_observer.notify("failed_output", subject=error_msg)
|
||||
invoice_data_object.add_error_code("server_error")
|
||||
return invoice_data_object
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
ticket_observer.notify("unknown_error", subject=error_msg)
|
||||
invoice_data_object.add_error_code("unknown_error")
|
||||
return invoice_data_object
|
||||
|
||||
###############
|
||||
if ticket_observer:
|
||||
ticket_observer.notify("connection_error", subject=str(e))
|
||||
return Result(valid=False, error_type=ResultError.CONNECTION, message=error_msg)
|
||||
|
||||
|
||||
def check_if_paid(
|
||||
|
|
@ -169,8 +150,20 @@ def check_if_paid(
|
|||
url = make_url(which_endpoint)
|
||||
|
||||
# literally send:
|
||||
reply = send_data_to_server(payload, url, connection_observer)
|
||||
api_reply_object = connect.single_endpoint(
|
||||
method="post",
|
||||
url=url,
|
||||
observer=connection_observer,
|
||||
payload=payload
|
||||
)
|
||||
|
||||
logger.debug(f"inside ticketpay controller the reply is {reply}")
|
||||
if not api_reply_object.valid:
|
||||
error_msg = f"Connection/API Error: {api_reply_object.message}"
|
||||
logger.error(f"[TICKET PayController] 2nd Post Request inside ticketpay controller had a {error_msg}")
|
||||
return {"valid": False, "message": error_msg}
|
||||
|
||||
# return the payload with GUI/CLI to interpret results:
|
||||
reply_dict = api_reply_object.data
|
||||
logger.debug(f"[TICKET PayController] We have a valid reply from the API inside ticketpay controller of {reply_dict}")
|
||||
return reply_dict
|
||||
|
||||
return reply
|
||||
|
|
|
|||
|
|
@ -7,10 +7,16 @@ if TYPE_CHECKING:
|
|||
|
||||
from core.services.prepare_tickets.ticket_prep_orchestrator import ticket_prep_orchestrator
|
||||
from core.services.prepare_tickets.make_sure_pub_key_exists import make_sure_pub_key_exists
|
||||
from core.services.prepare_tickets import ticket_tracker
|
||||
from core.services.helpers.get_how_many_profiles_were_ordered import (
|
||||
get_how_many_profiles_were_ordered,
|
||||
)
|
||||
from core.observers.BaseObserver import BaseObserver
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.errors.logger import logger
|
||||
|
||||
# generic
|
||||
from typing import Optional
|
||||
|
||||
"""
|
||||
Goal:
|
||||
|
|
@ -26,37 +32,54 @@ If it doesn't have the public key, then it checks the config file.
|
|||
If it doesn't have a config file, then it uses the temp billing id to get the public key from the server.
|
||||
"""
|
||||
|
||||
|
||||
# Bulk Prep:
|
||||
def prepare_tickets(
|
||||
how_many_profiles: int,
|
||||
ticket_observer: TicketObserver,
|
||||
connection_observer: ConnectionObserver,
|
||||
which_ticket: Optional[int] = None,
|
||||
billing_code: Optional[int] = None
|
||||
) -> dict:
|
||||
|
||||
# these two are for preparation of a single ticket to a profile.
|
||||
# can't have one blank without the other. (profile or ticket)
|
||||
if bool(which_ticket) != bool(billing_code):
|
||||
return {"valid": False, "error_code": "failed_input"}
|
||||
|
||||
# make sure it's a number:
|
||||
if not isinstance(how_many_profiles, int):
|
||||
ticket_observer.notify("failed_input", None)
|
||||
if ticket_observer is not None:
|
||||
ticket_observer.notify("failed_input", None)
|
||||
return {"valid": False, "error_code": "failed_input"}
|
||||
|
||||
# make sure that "how_many_profiles" is actually the number of profiles ordered
|
||||
# (which is based on locally saved data from the previous step):
|
||||
how_many_ordered = get_how_many_profiles_were_ordered()
|
||||
if how_many_profiles != how_many_ordered:
|
||||
ticket_observer.notify("failed_input", None)
|
||||
return {"valid": False, "error_code": "failed_input"}
|
||||
# allow single prep:
|
||||
if how_many_profiles != 1:
|
||||
# but if it's not a single ticket, then make sure that "how_many_profiles" is actually the number of profiles ordered
|
||||
# (which is based on locally saved data from the previous step):
|
||||
how_many_ordered = get_how_many_profiles_were_ordered()
|
||||
if how_many_profiles != how_many_ordered:
|
||||
if ticket_observer is not None:
|
||||
ticket_observer.notify("failed_input", None)
|
||||
return {"valid": False, "error_code": "failed_input"}
|
||||
|
||||
# make sure this guy has a public key to verify against:
|
||||
does_he_have_public_key = make_sure_pub_key_exists(connection_observer)
|
||||
if does_he_have_public_key == False:
|
||||
ticket_observer.notify("failed_input", None)
|
||||
if ticket_observer is not None:
|
||||
ticket_observer.notify("failed_input", None)
|
||||
return {"valid": False, "error_code": "failed_input"}
|
||||
|
||||
notification = "Preparing Cryptography Locally"
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
if ticket_observer is not None:
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
|
||||
# ok now we have the pre-reqs, let's use this high level orchestrator,
|
||||
prep_results = ticket_prep_orchestrator(
|
||||
how_many_profiles, ticket_observer, connection_observer
|
||||
how_many_profiles=how_many_profiles,
|
||||
ticket_observer=ticket_observer,
|
||||
connection_observer=connection_observer,
|
||||
which_ticket=which_ticket, # only relevant for single ticket prep
|
||||
billing_code=billing_code # only relevant for single ticket prep
|
||||
)
|
||||
|
||||
# rest of this function is evaluating the results:
|
||||
|
|
@ -69,7 +92,8 @@ def prepare_tickets(
|
|||
|
||||
if prep_results["valid"] == True:
|
||||
notification = f"Done! All Tickets Ready!"
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
if ticket_observer is not None:
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
return prep_results
|
||||
|
||||
if "how_many_failed" in prep_results:
|
||||
|
|
@ -77,9 +101,118 @@ def prepare_tickets(
|
|||
failed_validations = prep_results.get("failed_validations", None)
|
||||
if failed_validations:
|
||||
notification = f"Error with Ticket Preparation or Verification!"
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
if ticket_observer is not None:
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
return prep_results
|
||||
|
||||
notification = f"Error with Ticket Preparation or Verification!"
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
if ticket_observer is not None:
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
return prep_results
|
||||
|
||||
|
||||
# No profile required
|
||||
def respawn_billing_code_into_ticket(
|
||||
billing_code: str,
|
||||
which_ticket: int,
|
||||
ticket_observer: TicketObserver,
|
||||
connection_observer: ConnectionObserver
|
||||
) -> Result:
|
||||
"""
|
||||
Purpose:
|
||||
Invalidates a billing code, & produces a valid unused ticket
|
||||
Requires:
|
||||
Valid Billing Code.
|
||||
Any Ticket slot folder, (but not an actual ticket there).
|
||||
No profile required.
|
||||
Method:
|
||||
Converts a valid VPN billing code, into a valid unused ticket.
|
||||
At the cost of the server expiring the billing code immediately.
|
||||
And the ticket slot is used (wipes existing used ticket)
|
||||
|
||||
This has the effect of "wiping" the billing code, and starting over.
|
||||
The new ticket can be applied to any other profile now.
|
||||
Called by:
|
||||
ticket_respawn's respawn_profile
|
||||
"""
|
||||
#################################################
|
||||
# PREP PRE-REQS
|
||||
#################################################
|
||||
if not which_ticket:
|
||||
return Result(valid=False, error_type=ResultError.MISSING_DATA, message="Missing which ticket slot is being respawned")
|
||||
|
||||
if not isinstance(which_ticket, int):
|
||||
try:
|
||||
which_ticket = int(which_ticket)
|
||||
except:
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Ticket slot must be a number.")
|
||||
|
||||
#################################################
|
||||
# SEND TO SERVER
|
||||
#################################################
|
||||
ticket_result = prepare_tickets(
|
||||
how_many_profiles=1,
|
||||
ticket_observer=ticket_observer,
|
||||
connection_observer=connection_observer,
|
||||
which_ticket=which_ticket,
|
||||
billing_code=billing_code
|
||||
)
|
||||
|
||||
#################################################
|
||||
# EVALUATE RESULTS
|
||||
#################################################
|
||||
if not isinstance(ticket_result, dict):
|
||||
return Result(valid=False, error_type=ResultError.UNKNOWN, message=f"Error! The prepare_tickets function returned an invalid format of {type(ticket_result)} when it should be a dict. This is being recieved in the respawn_ticket function.")
|
||||
|
||||
valid = ticket_result.get('valid', False)
|
||||
|
||||
if not valid:
|
||||
notification = f"Error in Respawn Prep!"
|
||||
if ticket_observer is not None:
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
return prep_error_result(ticket_result)
|
||||
|
||||
#################################################
|
||||
# WIPE LOCAL TICKET DATA
|
||||
#################################################
|
||||
wiped_sub = ticket_tracker.wipe_one_ticket_sub(which_ticket)
|
||||
logger.info(f"Did local ticket tracker wipe? {wiped_sub}")
|
||||
|
||||
#################################################
|
||||
# NOTIFY & RETURN
|
||||
#################################################
|
||||
notification = f"Respawn Done for Ticket Slot {which_ticket}!"
|
||||
if ticket_observer is not None:
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
|
||||
update_msg = f"It worked. Respawn Done! The billing code is no longer valid and you now have a valid ticket at slot {which_ticket}"
|
||||
logger.info(update_msg)
|
||||
return Result(valid=True, message=update_msg)
|
||||
|
||||
|
||||
def prep_error_result(ticket_result: dict):
|
||||
message = ticket_result.get('message', False)
|
||||
error_code = ticket_result.get('error_code', False)
|
||||
|
||||
if message == "missing_data":
|
||||
return Result(valid=False, error_type=ResultError.MISSING_DATA, message="Missing Input Data. This is an error with the introduction functions.")
|
||||
|
||||
elif message == "cant_save":
|
||||
return Result(valid=False, error_type=ResultError.FILE_SYSTEM, message="Can't save the data. Please check your file permissions and free space.")
|
||||
|
||||
elif message == "verification_failed":
|
||||
how_many_failed = ticket_result.get('how_many_failed', False)
|
||||
return Result(valid=False, error_type=ResultError.ENCRYPTION, message=f"Verification of the encryption failed for {how_many_failed} profiles.")
|
||||
|
||||
elif error_code == "failed":
|
||||
return Result(valid=False, error_type=ResultError.FILE_SYSTEM, message="There was an error with setting up ticket tracker or some local filesystem problem.")
|
||||
|
||||
else:
|
||||
if message:
|
||||
error_msg = message
|
||||
elif error_code:
|
||||
error_msg = f"An error occured: {error_code}."
|
||||
else:
|
||||
error_msg = "An unknown error occured."
|
||||
|
||||
return Result(valid=False, error_type=ResultError.UNKNOWN, message=error_msg)
|
||||
|
|
|
|||
|
|
@ -7,12 +7,18 @@ if TYPE_CHECKING:
|
|||
|
||||
from core.Constants import Constants
|
||||
from core.observers.BaseObserver import BaseObserver
|
||||
from core.services.networking.get_data_from_server import get_data_from_server
|
||||
# from core.services.networking.api_requests.step1_get_or_post import get_data_from_api
|
||||
|
||||
from core.services.networking.httpx import connect
|
||||
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||
from core.services.networking.api_requests.step5_solve_api_problems import solve_api_problems
|
||||
|
||||
from core.errors.logger import logger
|
||||
|
||||
|
||||
def sync_ticket_prices(
|
||||
ticket_observer: TicketObserver, connection_observer: ConnectionObserver
|
||||
ticket_observer: TicketObserver,
|
||||
connection_observer: ConnectionObserver
|
||||
) -> dict:
|
||||
notification = f"Connecting to get Ticket Pricing..."
|
||||
ticket_observer.notify("connecting", subject=notification)
|
||||
|
|
@ -28,13 +34,35 @@ def sync_ticket_prices(
|
|||
|
||||
url = f"{base_url}/sync"
|
||||
try:
|
||||
sync_results = get_data_from_server(url, connection_observer)
|
||||
# api_result = get_data_from_api(url, None, connection_observer)
|
||||
api_result = connect.single_endpoint(
|
||||
method="get",
|
||||
url=url,
|
||||
observer=connection_observer,
|
||||
payload=None
|
||||
)
|
||||
|
||||
if sync_results in rejected_list:
|
||||
# 2nd try:
|
||||
if not api_result.valid:
|
||||
logger.error(f"Invalid API call result: {api_result.error_type}")
|
||||
return {"valid": False, "error_code": "sync_failed"}
|
||||
|
||||
logger.debug(f"Inside the sync controller, sync_results is: {sync_results}")
|
||||
# if not api_result.valid:
|
||||
# error_msg = api_result.message
|
||||
# logger.error(f"[TICKET SYNC Controller] There's an issue with the sync of endpoint {url} the API Reply: {error_msg}")
|
||||
# api_result = solve_api_problems(
|
||||
# api_reply_object=api_result,
|
||||
# get_or_post="get",
|
||||
# url=url,
|
||||
# payload=None,
|
||||
# connection_observer=connection_observer,
|
||||
# client_observer=None
|
||||
# )
|
||||
# # 2nd try:
|
||||
# if not api_result.valid:
|
||||
# return {"valid": False, "error_code": "sync_failed"}
|
||||
|
||||
return {"valid": True, "data": api_result.data}
|
||||
|
||||
except:
|
||||
return {"valid": False, "error_code": "sync_failed"}
|
||||
|
||||
return sync_results
|
||||
|
|
|
|||
|
|
@ -1,23 +1,30 @@
|
|||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.observers.TicketObserver import TicketObserver
|
||||
from core.essentials.observers.ConnectionObserver import ConnectionObserver
|
||||
|
||||
from core.Constants import Constants
|
||||
from core.observers.BaseObserver import BaseObserver
|
||||
# from core.observers.BaseObserver import BaseObserver
|
||||
from core.services.using_tickets.use_ticket_orchestrator import use_ticket_orchestrator
|
||||
from core.services.prepare_tickets.ticket_tracker import (
|
||||
get_data_for_a_single_ticket,
|
||||
does_ticket_tracker_exist,
|
||||
)
|
||||
|
||||
from core.services.prepare_tickets.ticket_tracker import does_ticket_tracker_exist
|
||||
|
||||
# from core.services.prepare_tickets import setup_ticket_tracker
|
||||
|
||||
from core.services.helpers.does_ticket_file_exist import does_ticket_file_exist
|
||||
from core.services.helpers.get_value_from_config import get_value_from_config
|
||||
from core.utils.basic_operations.does_file_exist import does_file_exist
|
||||
from core.utils.basic_operations.write_or_read_from_json import update_json
|
||||
from core.services.prepare_tickets.ticket_tracker import get_all_unused_tickets
|
||||
from core.models.session.SessionProfile import SessionProfile
|
||||
from core.models.system.SystemProfile import SystemProfile
|
||||
from core.errors.logger import logger
|
||||
|
||||
# generic
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def modify_random_tickets_setting(
|
||||
|
|
@ -28,7 +35,7 @@ def modify_random_tickets_setting(
|
|||
choices = ["on", "off"]
|
||||
if on_or_off not in choices:
|
||||
ticket_observer.notify("failed_input", None)
|
||||
return {"valid": False, "message": f"Invalid choice for turning on or off"}
|
||||
return {"valid": False, "message": "Invalid choice for turning on or off"}
|
||||
|
||||
billing_folder = Constants.HV_TICKETING_CONFIG_HOME
|
||||
filepath = f"{billing_folder}/billing_choices.json"
|
||||
|
|
@ -38,24 +45,28 @@ def modify_random_tickets_setting(
|
|||
notification = "First setup the Tickets before picking use"
|
||||
ticket_observer.notify("failed_input", subject=notification)
|
||||
return {"valid": False, "message": notification}
|
||||
|
||||
if on_or_off == "on":
|
||||
update_result = update_json(filepath, "use_random", True)
|
||||
return conclude_and_return(update_result=update_result, filepath=filepath, ticket_observer=ticket_observer)
|
||||
elif on_or_off == "off":
|
||||
update_result = update_json(filepath, "use_random", False)
|
||||
return conclude_and_return(update_result=update_result, filepath=filepath, ticket_observer=ticket_observer)
|
||||
else:
|
||||
try:
|
||||
if on_or_off == "on":
|
||||
update_json(filepath, "use_random", True)
|
||||
return {"valid": True}
|
||||
elif on_or_off == "off":
|
||||
update_json(filepath, "use_random", False)
|
||||
return {"valid": True}
|
||||
else:
|
||||
ticket_observer.notify("failed_input", None)
|
||||
return {
|
||||
"valid": False,
|
||||
"message": f"Invalid choice for turning on or off",
|
||||
}
|
||||
except:
|
||||
notification = f"Error with modifying config file. Check {filepath}"
|
||||
ticket_observer.notify("error", subject=notification)
|
||||
return {"valid": False, "message": notification}
|
||||
ticket_observer.notify("failed_input", None)
|
||||
return {
|
||||
"valid": False,
|
||||
"message": "Invalid choice for turning on or off",
|
||||
}
|
||||
|
||||
def conclude_and_return(update_result: bool, filepath: str, ticket_observer: TicketObserver) -> dict:
|
||||
if update_result:
|
||||
return {"valid": True}
|
||||
else:
|
||||
notification = f"Error with modifying config file. Check the filepath {filepath}"
|
||||
logger.error(notification)
|
||||
ticket_observer.notify("error", subject=notification)
|
||||
return {"valid": False, "message": notification}
|
||||
|
||||
|
||||
"""
|
||||
|
|
@ -71,47 +82,47 @@ def do_we_use_a_random_ticket(ticket_observer: TicketObserver) -> tuple:
|
|||
config_data = get_value_from_config("use_random")
|
||||
|
||||
# if the 'value' key is in the config, that means it successfully read the config.
|
||||
if "value" in config_data:
|
||||
random_setting = config_data["value"]
|
||||
|
||||
# they want a random ticket
|
||||
if random_setting == True:
|
||||
data_results = pick_a_random_ticket(ticket_observer)
|
||||
|
||||
if "random_ticket" in data_results:
|
||||
which_ticket = data_results["random_ticket"]
|
||||
error_msg = None
|
||||
return which_ticket, error_msg
|
||||
else:
|
||||
which_ticket = "error"
|
||||
if "message" in data_results:
|
||||
error_msg = data_results["message"]
|
||||
else:
|
||||
error_msg = (
|
||||
"Missing or Invalid Data. Unable to get unused ticket list."
|
||||
)
|
||||
return which_ticket, error_msg
|
||||
|
||||
# if it read the config, but the value is false:
|
||||
else:
|
||||
which_ticket = None
|
||||
error_msg = None
|
||||
return which_ticket, error_msg
|
||||
|
||||
# this is a problem with reading the config itself:
|
||||
else:
|
||||
if "value" not in config_data:
|
||||
# this is a problem with reading the config itself:
|
||||
which_ticket = "error"
|
||||
error_msg = "There is an error with the config file, or no config. Are you sure you have tickets?"
|
||||
ticket_observer.notify("failed_input", subject=error_msg)
|
||||
return which_ticket, error_msg
|
||||
|
||||
random_setting = config_data["value"]
|
||||
|
||||
# if it read the config, but the value is false:
|
||||
if not random_setting:
|
||||
which_ticket = None
|
||||
error_msg = None
|
||||
return which_ticket, error_msg
|
||||
|
||||
# they want a random ticket
|
||||
data_results = pick_a_random_ticket(ticket_observer)
|
||||
|
||||
# invalid format:
|
||||
if "random_ticket" not in data_results:
|
||||
which_ticket = "error"
|
||||
if "message" in data_results:
|
||||
error_msg = data_results["message"]
|
||||
else:
|
||||
error_msg = (
|
||||
"Missing or Invalid Data. Unable to get unused ticket list."
|
||||
)
|
||||
return which_ticket, error_msg
|
||||
|
||||
# finally get the result:
|
||||
which_ticket = data_results["random_ticket"]
|
||||
error_msg = None
|
||||
return which_ticket, error_msg
|
||||
|
||||
|
||||
def get_unused_tickets(ticket_observer: TicketObserver) -> dict:
|
||||
# does the file keeping track of ALL tickets exist:
|
||||
does_the_file_exist, path_of_file = does_ticket_tracker_exist()
|
||||
if does_the_file_exist == False:
|
||||
error_msg = f"The ticket tracker organizer file does not exist. Check the folder {path_of_file}"
|
||||
ticket_observer.notify("failed_input", subject=error_msg)
|
||||
# ticket_observer.notify("failed_input", subject=error_msg)
|
||||
return {"valid": False, "message": error_msg}
|
||||
|
||||
# Use the Model:
|
||||
|
|
@ -132,9 +143,10 @@ use_ticket function requires:
|
|||
|
||||
def use_ticket(
|
||||
which_ticket: int,
|
||||
which_location: str,
|
||||
which_location: int, # Location.id
|
||||
ticket_observer: TicketObserver,
|
||||
connection_observer: ConnectionObserver,
|
||||
profile: Optional[Union[SessionProfile, SystemProfile]] = None, # only for assassin or single profiles.
|
||||
) -> dict:
|
||||
|
||||
which_ticket = str(which_ticket) # type: ignore
|
||||
|
|
@ -142,46 +154,44 @@ def use_ticket(
|
|||
# does the ticket's file exist:
|
||||
ticket_exists = does_ticket_file_exist(which_ticket)
|
||||
if ticket_exists == False:
|
||||
error_msg = f"The ticket file does not exist in the correct folder."
|
||||
error_msg = "The ticket file does not exist in the correct folder."
|
||||
ticket_observer.notify("failed_input", subject=error_msg)
|
||||
return {"valid": False, "message": error_msg}
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||
|
||||
# does the file keeping track of ALL tickets exist:
|
||||
does_the_file_exist, path_of_file = does_ticket_tracker_exist()
|
||||
if does_the_file_exist == False:
|
||||
error_msg = f"The ticket tracker organizer file does not exist. Check the folder {path_of_file}"
|
||||
ticket_observer.notify("failed_input", subject=error_msg)
|
||||
return {"valid": False, "message": error_msg}
|
||||
|
||||
# is the ticket used?
|
||||
try:
|
||||
status, location, subscription = get_data_for_a_single_ticket(which_ticket)
|
||||
if status == "used":
|
||||
error_msg = f"Ticket is already tied to {location} with the subscription {subscription}"
|
||||
ticket_observer.notify("failed_input", subject=error_msg)
|
||||
return {"valid": False, "message": error_msg}
|
||||
except:
|
||||
error_msg = f"Your local ticket tracker has no value for ticket {which_ticket}"
|
||||
return {"valid": False, "message": error_msg}
|
||||
return Result(valid=False, error_type=ResultError.MISSING_FILE, message=error_msg)
|
||||
|
||||
# the actual work here, everything else is just handling:
|
||||
ticket_observer.notify("connecting", "Connecting..")
|
||||
reply = use_ticket_orchestrator(which_ticket, which_location, connection_observer)
|
||||
reply_object = use_ticket_orchestrator(which_ticket, which_location, connection_observer)
|
||||
|
||||
return reply
|
||||
return reply_object
|
||||
|
||||
# TRANSLATE FOR UI
|
||||
# if reply_object.valid:
|
||||
# return {"valid": True, "billing_code": reply_object.data}
|
||||
|
||||
# error_msg = reply_object.message
|
||||
# error_type = reply_object.error_type
|
||||
# logger.error(f"Error Type: {error_type}")
|
||||
# return {"valid": False, "message": error_msg, "error_type": error_type}
|
||||
|
||||
|
||||
def pick_a_random_ticket(ticket_observer: TicketObserver) -> dict:
|
||||
ticket_data = get_unused_tickets(ticket_observer)
|
||||
|
||||
if "valid" in ticket_data:
|
||||
if ticket_data["valid"] == True:
|
||||
list_of_unused_tickets = ticket_data["data"]
|
||||
|
||||
random_ticket = random.choice(list_of_unused_tickets)
|
||||
return {"valid": True, "random_ticket": random_ticket}
|
||||
else:
|
||||
return ticket_data
|
||||
else:
|
||||
if "valid" not in ticket_data:
|
||||
error_msg = "Missing or Invalid Data. Unable to get unused ticket list."
|
||||
return {"valid": False, "message": error_msg}
|
||||
|
||||
if ticket_data["valid"] != True:
|
||||
return ticket_data
|
||||
|
||||
# it's valid, get the list & pick one:
|
||||
list_of_unused_tickets = ticket_data["data"]
|
||||
random_ticket = random.choice(list_of_unused_tickets)
|
||||
return {"valid": True, "random_ticket": random_ticket}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,57 @@
|
|||
# MissingData, FailedToSave, InvalidData, CriticalFailure defined here
|
||||
|
||||
|
||||
from core.models.Result import Result, ResultError
|
||||
from typing import Optional
|
||||
|
||||
class DNSError(Exception):
|
||||
"""There are issues with DNS"""
|
||||
def __init__(self, result: Optional[Result]):
|
||||
if result:
|
||||
self.result = result
|
||||
if result.message and result.message is not None:
|
||||
error_msg = result.message
|
||||
else:
|
||||
error_msg = result.error_type
|
||||
else:
|
||||
error_msg = "Failed to start DNS"
|
||||
super().__init__(error_msg)
|
||||
|
||||
|
||||
class FirewallError(Exception):
|
||||
"""There are issues with the Firewall"""
|
||||
def __init__(self, result: Optional[Result]):
|
||||
if result:
|
||||
self.result = result
|
||||
if result.message and result.message is not None:
|
||||
error_msg = result.message
|
||||
else:
|
||||
error_msg = result.error_type
|
||||
else:
|
||||
error_msg = "Failed to start the firewall"
|
||||
super().__init__(error_msg)
|
||||
|
||||
|
||||
class SudoScript(Exception):
|
||||
"""There are issues with the sudo scripts"""
|
||||
def __init__(self, result: Result):
|
||||
self.result = result
|
||||
if result.message and result.message is not None:
|
||||
error_msg = result.message
|
||||
else:
|
||||
error_msg = result.error_type
|
||||
super().__init__(error_msg)
|
||||
|
||||
class MissingPreReqs(Exception):
|
||||
"""User can't take an action because they lack the prereqs"""
|
||||
def __init__(self, result: Result):
|
||||
self.result = result
|
||||
if result.message and result.message is not None:
|
||||
error_msg = result.message
|
||||
else:
|
||||
error_msg = result.error_type
|
||||
super().__init__(error_msg)
|
||||
|
||||
|
||||
class TorServiceInitializationError(Exception):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ debug_mode = os.getenv("DEBUG", "").lower() in ("1", "true", "yes")
|
|||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_level = logging.DEBUG if debug_mode else logging.WARNING
|
||||
console_handler.setLevel(console_level)
|
||||
console_formatter = logging.Formatter("%(message)s")
|
||||
console_formatter = logging.Formatter("[%(funcName)s] %(message)s")
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
from dataclasses import dataclass
|
||||
from dataclasses_json import dataclass_json
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class BaseConnection:
|
||||
code: str
|
||||
# called by: connection controller for basic type checks on wireguard code
|
||||
|
||||
# Called by: ConnectionController
|
||||
# it uses it for basic type checks on wireguard code
|
||||
def needs_wireguard_configuration(self):
|
||||
return self.code == 'wireguard'
|
||||
|
||||
# Called By SubscriptionPlan
|
||||
def is_session_connection(self):
|
||||
return type(self).__name__ == 'SessionConnection'
|
||||
|
||||
# Not called. Dead code
|
||||
def is_system_connection(self):
|
||||
return type(self).__name__ == 'SystemConnection'
|
||||
def needs_operator_proxy(self):
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
from core.models.manage.session_management import get_session
|
||||
from core.errors.logger import logger
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
|
|
@ -6,13 +8,22 @@ from abc import ABC, abstractmethod
|
|||
from core.Constants import Constants
|
||||
from core.Helpers import write_atomically
|
||||
|
||||
# from core.models.Location import Location
|
||||
from core.models.manage.wrapper import safe_db_operation, WrapperRollback
|
||||
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||
|
||||
# If switching to enums for Connection:
|
||||
# from core.models.system.SystemConnection import SystemConnection, SystemConnectionTypes
|
||||
# from core.models.session.SessionConnection import SessionConnection, SessionConnectionTypes
|
||||
|
||||
from core.controllers.LocationController import LocationController
|
||||
from core.models.orm_models.Location import Location
|
||||
from core.models.orm_models.Operator import Operator
|
||||
|
||||
from core.models.Subscription import Subscription
|
||||
from core.models.session.ApplicationVersion import ApplicationVersion
|
||||
# from core.models.session.ApplicationVersion import ApplicationVersion
|
||||
from core.models.orm_models.ApplicationVersion import ApplicationVersion
|
||||
from core.models.orm_calls.application_version_calls import get_application_version
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from dataclasses_json import config, Exclude, dataclass_json
|
||||
|
||||
|
|
@ -23,8 +34,40 @@ import os
|
|||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from sqlalchemy.orm import Session
|
||||
from enum import Enum
|
||||
|
||||
|
||||
from core.models.manage.session_management import init_session, get_session
|
||||
|
||||
|
||||
@safe_db_operation
|
||||
def execute_location_sql(country_code: str, city_code: str, session: Session) -> DatabaseOperation:
|
||||
location_object = session.execute(
|
||||
select(Location)
|
||||
.where((Location.country_code == country_code) & (Location.code == city_code))
|
||||
.options(joinedload(Location.operator))
|
||||
).scalar_one_or_none()
|
||||
return location_object
|
||||
|
||||
|
||||
|
||||
def get_profile_location_data(country_code: str, city_code: str) -> Location:
|
||||
# with get_session() as session:
|
||||
api_reply_object = execute_location_sql(country_code, city_code)
|
||||
if api_reply_object.valid:
|
||||
data = api_reply_object.data
|
||||
location_obj = api_reply_object.data
|
||||
return location_obj
|
||||
else:
|
||||
logger.error(f"[get_profile_location_data] Got invalid SQL Query")
|
||||
return None
|
||||
|
||||
|
||||
class ProfileType(str, Enum):
|
||||
SESSION = "session"
|
||||
SYSTEM = "system"
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class BaseProfile(ABC):
|
||||
|
|
@ -33,8 +76,9 @@ class BaseProfile(ABC):
|
|||
)
|
||||
name: str
|
||||
subscription: Optional[Subscription]
|
||||
type: ProfileType
|
||||
location: Optional[Location] = field(metadata=config(exclude=Exclude.ALWAYS)) # SQLAlchemy object
|
||||
|
||||
|
||||
# legacy version included it to be serialized, but now it's an SQLAlchemy object.
|
||||
# location: Optional[Location]
|
||||
|
||||
|
|
@ -64,12 +108,21 @@ class BaseProfile(ABC):
|
|||
def is_system_profile(self):
|
||||
return type(self).__name__ == 'SystemProfile'
|
||||
|
||||
def save(self: Self):
|
||||
|
||||
def save(self: Self, app_version_dict: dict = None):
|
||||
# === SERIALIZATION ===
|
||||
config_dict = self.to_dict()
|
||||
location_dict = self.location.to_dict() if self.location else None
|
||||
|
||||
config_dict["location"] = location_dict
|
||||
# === LOCATION ===
|
||||
location_dict = self.location.convert_to_dict() # this is from SQLAlchemy, and not JSON-models, that's why it's separate.
|
||||
if self.location:
|
||||
config_dict["location"] = location_dict
|
||||
|
||||
# === APPLICATION ===
|
||||
if app_version_dict:
|
||||
config_dict["application_version"] = app_version_dict
|
||||
|
||||
# === FILE I/O ===
|
||||
config_file_contents = json.dumps(config_dict, indent=4) + '\n'
|
||||
|
||||
# legacy version:
|
||||
|
|
@ -185,54 +238,59 @@ class BaseProfile(ABC):
|
|||
profiles_location = profile['location']
|
||||
|
||||
if profile['location'] is not None:
|
||||
# =========== GET COUNTRY & LOCATION ===========
|
||||
if isinstance(profiles_location, dict):
|
||||
try:
|
||||
country_code = profile['location']['country_code']
|
||||
city_code = profile['location']['code']
|
||||
except:
|
||||
print("ERROR! CANT FIND country code or city")
|
||||
logger.error(f"CRITICAL ERROR! Can't find country code or city for profile id {profile['id']} inside BaseProfile")
|
||||
return
|
||||
else:
|
||||
# potentially coming from SQLAlchemy:
|
||||
# potentially coming from SQLAlchemy ALREADY:
|
||||
country_code = profile.location.country_code
|
||||
city_code = profile.location.code
|
||||
|
||||
with get_session() as session:
|
||||
location_object = session.execute(
|
||||
select(Location)
|
||||
.where((Location.country_code == country_code) & (Location.code == city_code))
|
||||
.options(joinedload(Location.operator))
|
||||
).scalar_one_or_none()
|
||||
# =========== GET DATA USING THAT COUNTRY & LOCATION ===========
|
||||
location_dict = get_profile_location_data(country_code, city_code)
|
||||
|
||||
|
||||
if location_object:
|
||||
profile['location'] = location_object
|
||||
if location_dict:
|
||||
profile['location'] = location_dict
|
||||
|
||||
# this needs error handling if there's no location or malconformed config.
|
||||
|
||||
|
||||
# legacy phased out since SQLAlchemy handles the time_zone.
|
||||
|
||||
# if location is not None:
|
||||
|
||||
# if profile['location'].get('time_zone') is not None:
|
||||
# location.time_zone = profile['location']['time_zone']
|
||||
|
||||
# profile['location'] = location
|
||||
|
||||
# =========== SESSION ===========
|
||||
if 'application_version' in profile:
|
||||
|
||||
profile['type'] = ProfileType.SESSION
|
||||
if profile['application_version'] is not None:
|
||||
application_version = ApplicationVersion.find(profile['application_version']['application_code'] or None, profile['application_version']['version_number'] or None)
|
||||
|
||||
application_version = get_application_version(
|
||||
profile['application_version']['application_code'] or None,
|
||||
profile['application_version']['version_number'] or None
|
||||
)
|
||||
|
||||
if application_version is not None:
|
||||
profile['application_version'] = application_version
|
||||
|
||||
if application_version is None:
|
||||
profile['application_version'] = None
|
||||
|
||||
# legacy
|
||||
# if profile['application_version'] is not None:
|
||||
# application_version = ApplicationVersion.find(profile['application_version']['application_code'] or None, profile['application_version']['version_number'] or None)
|
||||
|
||||
# if application_version is not None:
|
||||
# profile['application_version'] = application_version
|
||||
|
||||
|
||||
from core.models.session.SessionProfile import SessionProfile
|
||||
# noinspection PyUnresolvedReferences
|
||||
profile = SessionProfile.from_dict(profile)
|
||||
|
||||
|
||||
# =========== SYSTEM ===========
|
||||
else:
|
||||
profile['type'] = ProfileType.SYSTEM
|
||||
|
||||
from core.models.system.SystemProfile import SystemProfile
|
||||
# noinspection PyUnresolvedReferences
|
||||
|
|
@ -240,6 +298,7 @@ class BaseProfile(ABC):
|
|||
|
||||
return profile
|
||||
|
||||
|
||||
@staticmethod
|
||||
def exists(id: int):
|
||||
return re.match(r'^\d{1,2}$', str(id)) and os.path.isfile(f'{BaseProfile.__get_config_path(id)}/config.json')
|
||||
|
|
@ -272,3 +331,10 @@ class BaseProfile(ABC):
|
|||
@staticmethod
|
||||
def __get_data_path(id: int):
|
||||
return f'{Constants.HV_PROFILE_DATA_HOME}/{str(id)}'
|
||||
|
||||
|
||||
# legacy phased out since SQLAlchemy handles the time_zone.
|
||||
# if location is not None:
|
||||
# if profile['location'].get('time_zone') is not None:
|
||||
# location.time_zone = profile['location']['time_zone']
|
||||
# profile['location'] = location
|
||||
|
|
|
|||
|
|
@ -1,84 +1,100 @@
|
|||
from core.errors.logger import logger
|
||||
from core.Constants import Constants
|
||||
from core.Helpers import write_atomically
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses_json import dataclass_json, config
|
||||
|
||||
#######################
|
||||
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, field_serializer, field_validator, ConfigDict
|
||||
from datetime import datetime
|
||||
from marshmallow import fields
|
||||
from typing import Optional, Self
|
||||
from zoneinfo import ZoneInfo
|
||||
import dataclasses_json
|
||||
from typing import Optional, Self
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class Configuration:
|
||||
connection: Optional[str] = field(
|
||||
default=None,
|
||||
metadata=config(
|
||||
undefined=dataclasses_json.Undefined.EXCLUDE,
|
||||
exclude=lambda value: value is None
|
||||
)
|
||||
)
|
||||
auto_sync_enabled: Optional[bool] = field(
|
||||
default=None,
|
||||
metadata=config(
|
||||
undefined=dataclasses_json.Undefined.EXCLUDE,
|
||||
exclude=lambda value: value is None
|
||||
)
|
||||
)
|
||||
endpoint_verification_enabled: Optional[bool] = field(
|
||||
default=False,
|
||||
metadata=config(
|
||||
undefined=dataclasses_json.Undefined.EXCLUDE,
|
||||
exclude=lambda value: value is None
|
||||
)
|
||||
)
|
||||
last_synced_at: Optional[datetime] = field(
|
||||
default=None,
|
||||
metadata=config(
|
||||
encoder=lambda datetime_instance: Configuration._iso_format(datetime_instance),
|
||||
decoder=lambda datetime_string: Configuration._from_iso_format(datetime_string),
|
||||
mm_field=fields.DateTime(format='iso'),
|
||||
undefined=dataclasses_json.Undefined.EXCLUDE,
|
||||
exclude=lambda value: value is None
|
||||
)
|
||||
)
|
||||
class ConnectionChoice(str, Enum):
|
||||
TOR = "tor"
|
||||
SYSTEM = "system"
|
||||
|
||||
def save(self: Self):
|
||||
class Configuration(BaseModel):
|
||||
connection: Optional[ConnectionChoice] = None
|
||||
auto_sync_enabled: Optional[bool] = None
|
||||
endpoint_verification_enabled: Optional[bool] = False
|
||||
last_synced_at: Optional[datetime] = None
|
||||
firewall: Optional[bool] = False
|
||||
dns: Optional[bool] = False
|
||||
did_sudo_setup: Optional[bool] = False
|
||||
singbox: Optional[str] = None
|
||||
|
||||
config_file_contents = f'{self.to_json(indent=4)}\n'
|
||||
os.makedirs(Constants.HV_CONFIG_HOME, exist_ok=True)
|
||||
model_config = ConfigDict(
|
||||
extra='ignore', # Ignore unknown fields in JSON
|
||||
exclude_none=True # Don't serialize None values
|
||||
)
|
||||
|
||||
@field_validator('last_synced_at', mode='before')
|
||||
@classmethod
|
||||
def parse_datetime(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.replace('Z', '+00:00') # Z → +00:00 for parsing
|
||||
return v
|
||||
|
||||
@field_serializer('last_synced_at')
|
||||
def serialize_datetime(self, value: datetime) -> str:
|
||||
if value:
|
||||
value = value.replace(tzinfo=ZoneInfo('UTC'))
|
||||
return value.isoformat().replace('+00:00', 'Z') # +00:00 → Z for JSON
|
||||
return None
|
||||
|
||||
config_file_path = f'{Constants.HV_CONFIG_HOME}/config.json'
|
||||
write_atomically(config_file_path, config_file_contents)
|
||||
|
||||
@staticmethod
|
||||
def get():
|
||||
|
||||
try:
|
||||
config_file_contents = open(f'{Constants.HV_CONFIG_HOME}/config.json', 'r').read()
|
||||
with open(f'{Constants.HV_CONFIG_HOME}/config.json', 'r') as f:
|
||||
config_file_contents = f.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
configuration = json.loads(config_file_contents)
|
||||
configuration_dict = json.loads(config_file_contents)
|
||||
except ValueError:
|
||||
sys.exit(1)
|
||||
|
||||
return Configuration(**configuration_dict) # Pydantic validates on init
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
configuration = Configuration.from_dict(configuration)
|
||||
|
||||
return configuration
|
||||
def save(self: Self):
|
||||
config_file_contents = f'{self.model_dump_json(indent=4)}\n'
|
||||
os.makedirs(Constants.HV_CONFIG_HOME, exist_ok=True)
|
||||
|
||||
config_file_path = f'{Constants.HV_CONFIG_HOME}/config.json'
|
||||
write_atomically(config_file_path, config_file_contents)
|
||||
|
||||
@staticmethod
|
||||
def _iso_format(datetime_instance: datetime):
|
||||
datetime_instance = datetime_instance.replace(tzinfo=ZoneInfo('UTC'))
|
||||
return datetime.isoformat(datetime_instance).replace('+00:00', 'Z')
|
||||
|
||||
@staticmethod
|
||||
def _from_iso_format(datetime_string: str):
|
||||
date_string = datetime_string.replace('Z', '+00:00')
|
||||
return datetime.fromisoformat(date_string)
|
||||
def read_config():
|
||||
try:
|
||||
config_file_contents = open(f'{Constants.HV_CONFIG_HOME}/config.json', 'r').read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
try:
|
||||
configuration = json.loads(config_file_contents)
|
||||
except ValueError:
|
||||
logger.error(f"[CONFIG] Can't load JSON config")
|
||||
|
||||
return configuration
|
||||
|
||||
def get_setting(looking_for):
|
||||
config = read_config()
|
||||
if not config:
|
||||
logger.error(f"[CONFIG] Can't load the entire config")
|
||||
return None
|
||||
|
||||
if looking_for not in config:
|
||||
logger.error(f"[CONFIG] What you want isn't in the config")
|
||||
return None
|
||||
|
||||
result = config[looking_for]
|
||||
|
||||
return result
|
||||
|
|
|
|||
55
core/models/DatabaseOperation.py
Normal file
55
core/models/DatabaseOperation.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Any
|
||||
|
||||
class DBErrorType(Enum):
|
||||
"""Classified error categories."""
|
||||
SUCCESS = "success"
|
||||
WRONG_DATA_FORMAT = "wrong_data_format"
|
||||
NEED_MIGRATION = "need_migration"
|
||||
MIGRATION_FAILED = "migration_failed"
|
||||
OLD_CLIENT_NEW_API = "old_client_new_api"
|
||||
MALFORMED_SQL = "malformed_sql_file"
|
||||
MISSING_SQL = "missing_sql_file"
|
||||
MISSING_DEPENDENCY = "missing_dependency"
|
||||
INTEGRITY_ERROR = "integrity_error"
|
||||
PERMISSION_ERROR = "permission_error"
|
||||
FILESYSTEM_FULL = "filesystem_full"
|
||||
DATABASE_LOCKED = "database_locked"
|
||||
CORRUPTED_DATABASE = "corrupted_database"
|
||||
PYTHON_MODEL_STRUCTURE = "python_model_structure"
|
||||
SQLALCHEMY_ERROR = "sqlalchemy_error"
|
||||
UNKNOWN_MODEL = "unknown_model"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
@dataclass
|
||||
class DatabaseOperation:
|
||||
valid: bool
|
||||
error_type: DBErrorType = DBErrorType.SUCCESS
|
||||
data: Optional[Any] = None
|
||||
message: Optional[str] = None
|
||||
tried_migration: bool = False
|
||||
tried_filtered: bool = False
|
||||
which_table: str = None
|
||||
|
||||
def is_recoverable(self) -> bool:
|
||||
"""Can the caller attempt a retry or manual fix?"""
|
||||
return self.db_error_type in {
|
||||
DBErrorType.OLD_CLIENT_NEW_API,
|
||||
DBErrorType.NEED_MIGRATION
|
||||
}
|
||||
|
||||
def user_message(self) -> str:
|
||||
"""Human-readable error for the UI."""
|
||||
messages = {
|
||||
DBErrorType.SUCCESS: "Operation completed successfully.",
|
||||
DBErrorType.NEED_MIGRATION: "Database schema needs update. Contact admin.",
|
||||
DBErrorType.OLD_CLIENT_NEW_API: "Client version incompatible. Please upgrade.",
|
||||
DBErrorType.MALFORMED_SQL: "Migration file is corrupted. Contact admin.",
|
||||
DBErrorType.MISSING_SQL: "Migration file missing. Contact admin.",
|
||||
DBErrorType.MISSING_DEPENDENCY: "Database dependency missing. Contact admin.",
|
||||
DBErrorType.UNKNOWN: f"Unexpected error: {self.message}",
|
||||
}
|
||||
return messages.get(self.error_type, "Unknown error")
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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
|
||||
location_country_code: Optional[str] = None
|
||||
location_city_code: Optional[str] = None
|
||||
54
core/models/Result.py
Normal file
54
core/models/Result.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Any
|
||||
|
||||
class ResultError(Enum):
|
||||
"""Classified error categories."""
|
||||
SUCCESS = "success"
|
||||
NOT_SUPPORTED = "NOT_SUPPORTED"
|
||||
INVALID_INPUT = "invalid_input"
|
||||
MISSING_FILE = "missing_file"
|
||||
MISSING_DEPENDENCY = "missing_dependency"
|
||||
MISSING_DATA = "missing_data"
|
||||
MISSING_SOFTWARE = "missing_software"
|
||||
FILE_SYSTEM = "filesystem"
|
||||
CONNECTION = "connection"
|
||||
DATABASE = "database"
|
||||
PERMISSION = "permission"
|
||||
SUBSCRIPTION = "subscription"
|
||||
TICKET = "ticket"
|
||||
ALREADY_EXISTS = "already_exists"
|
||||
BILLING_CODE_EXISTS = "billing_code_exists"
|
||||
PROCESS_GOT_KILLED = "process_got_killed"
|
||||
PROCESS_WONT_START = "process_wont_start"
|
||||
PROCESS_MISMATCH = "process_mismatch"
|
||||
NMCLI = "nmcli_issues"
|
||||
FIREWALL = "firewall"
|
||||
CLIENT_DNS = "client_dns"
|
||||
EXTERNAL_DNS = "external_dns"
|
||||
INTERFACE = "interface"
|
||||
TIMEOUT = "timeout"
|
||||
INVALID_API_REPLY = "invalid_api_reply"
|
||||
ENCRYPTION = "encryption"
|
||||
LEAK_ISSUE = "leak_issue"
|
||||
NEED_SYNC = "need_sync"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
@dataclass
|
||||
class Result():
|
||||
valid: bool
|
||||
goal_result: Optional[bool] = None
|
||||
error_type: ResultError = ResultError.SUCCESS
|
||||
data: Optional[Any] = None
|
||||
message: Optional[str] = None
|
||||
goal: Optional[str] = None
|
||||
|
||||
def user_message(self) -> str:
|
||||
"""Human-readable error for the UI."""
|
||||
messages = {
|
||||
ResultError.SUCCESS: "Operation completed successfully.",
|
||||
ResultError.UNKNOWN: f"Error: {self.message}",
|
||||
}
|
||||
return messages.get(self.error_type, "Unknown error")
|
||||
|
|
@ -10,13 +10,6 @@ import dataclasses_json
|
|||
@dataclass
|
||||
class Subscription:
|
||||
billing_code: str
|
||||
operator_id: Optional[int] = field(
|
||||
default=None,
|
||||
metadata=config(
|
||||
undefined=dataclasses_json.Undefined.EXCLUDE,
|
||||
exclude=lambda value: value is None
|
||||
)
|
||||
)
|
||||
expires_at: Optional[datetime] = field(
|
||||
default=None,
|
||||
metadata=config(
|
||||
|
|
@ -44,4 +37,4 @@ class Subscription:
|
|||
|
||||
@staticmethod
|
||||
def _iso_format(datetime_instance: datetime):
|
||||
return datetime.isoformat(datetime_instance).replace('+00:00', 'Z')
|
||||
return datetime.isoformat(datetime_instance).replace('+00:00', 'Z')
|
||||
|
|
|
|||
|
|
@ -53,9 +53,6 @@ class SubscriptionPlan(Model):
|
|||
if connection.code == 'wireguard':
|
||||
features_wireguard = True
|
||||
|
||||
if connection.code in ('operator', 'vless', 'hysteria2'):
|
||||
features_proxy = True
|
||||
|
||||
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition)
|
||||
return Model._query_one('SELECT * FROM subscription_plans WHERE features_proxy = ? AND features_wireguard = ? AND duration = ? LIMIT 1', SubscriptionPlan.factory, [features_proxy, features_wireguard, duration])
|
||||
|
||||
|
|
@ -79,9 +76,6 @@ class SubscriptionPlan(Model):
|
|||
if connection.code == 'wireguard':
|
||||
features_wireguard = True
|
||||
|
||||
if connection.code in ('operator', 'vless', 'hysteria2'):
|
||||
features_proxy = True
|
||||
|
||||
return Model._query_all('SELECT * FROM subscription_plans WHERE features_proxy = ? AND features_wireguard = ?', SubscriptionPlan.factory, [features_proxy, features_wireguard])
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -100,4 +94,4 @@ class SubscriptionPlan(Model):
|
|||
|
||||
@staticmethod
|
||||
def tuple_factory(subscription_plan):
|
||||
return subscription_plan.id, subscription_plan.code, subscription_plan.wireguard_session_limit, subscription_plan.duration, subscription_plan.price, subscription_plan.features_proxy, subscription_plan.features_wireguard
|
||||
return subscription_plan.id, subscription_plan.code, subscription_plan.wireguard_session_limit, subscription_plan.duration, subscription_plan.price, subscription_plan.features_proxy, subscription_plan.features_wireguard
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
from pydantic import BaseModel
|
||||
from core.errors.get_error_msg import get_error_msg
|
||||
|
||||
|
||||
class TicketInvoice(BaseModel):
|
||||
temp_billing_code: str | None = None
|
||||
payment_type: str | None = None
|
||||
selected_currency: str | None = None
|
||||
due_amount: float | None = None
|
||||
address: str | None = None
|
||||
valid: bool = True
|
||||
temp_billing_code: str
|
||||
payment_type: str = "crypto"
|
||||
selected_currency: str = None
|
||||
due_amount: float
|
||||
address: str
|
||||
|
||||
final_error_msg: str | None = None
|
||||
error_code: str | None = None
|
||||
|
|
|
|||
97
core/models/manage/clear_sql_model.py
Normal file
97
core/models/manage/clear_sql_model.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
|
||||
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||
from core.models.manage.wrapper import safe_db_operation
|
||||
from core.errors.logger import logger
|
||||
|
||||
from typing import Type
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
|
||||
@safe_db_operation
|
||||
def _drop_sql_model_wrapped(model_class: Type, session=None) -> DatabaseOperation:
|
||||
"""
|
||||
Purpose:
|
||||
Drop the entire table
|
||||
|
||||
Confusion:
|
||||
It gets the session from the wrapper
|
||||
The wrapper converts the returning dictionary into DatabaseOperation.data
|
||||
"""
|
||||
model_class.__table__.drop(bind=session.bind)
|
||||
session.commit()
|
||||
return {
|
||||
'dropped': model_class.__name__,
|
||||
'action': 'drop_fallback'
|
||||
}
|
||||
|
||||
|
||||
@safe_db_operation
|
||||
def _clear_sql_model_wrapped(model_class: Type, session=None, **filters) -> DatabaseOperation:
|
||||
"""
|
||||
Delete all records from a SQLAlchemy model table, optionally filtered.
|
||||
Preserves the table structure.
|
||||
|
||||
Args:
|
||||
model_class: The SQLAlchemy model class to clear
|
||||
session: Database session (injected by @safe_db_operation decorator)
|
||||
**filters: Optional column=value filters for selective deletion
|
||||
e.g., clear_sql_model(User, status='inactive')
|
||||
|
||||
Returns:
|
||||
DatabaseOperation with deletion count and model name in the .data
|
||||
|
||||
Raises:
|
||||
AttributeError: If a filter column doesn't exist on the model
|
||||
"""
|
||||
query = session.query(model_class)
|
||||
|
||||
# Validate and apply filters
|
||||
for column_name, value in filters.items():
|
||||
if not hasattr(model_class, column_name):
|
||||
raise AttributeError(
|
||||
f"Model {model_class.__name__} has no column '{column_name}'"
|
||||
)
|
||||
column = getattr(model_class, column_name)
|
||||
query = query.filter(column == value)
|
||||
|
||||
# Delete and commit
|
||||
deleted_count = query.delete(synchronize_session=False)
|
||||
session.commit()
|
||||
|
||||
return {
|
||||
'deleted_rows': deleted_count,
|
||||
'model': model_class.__name__,
|
||||
'filters_applied': filters if filters else None
|
||||
}
|
||||
|
||||
|
||||
|
||||
def clear_sql_model(model_class: Type, **filters) -> dict:
|
||||
"""
|
||||
Purpose:
|
||||
Clear table; fallback to DROP if clear fails
|
||||
|
||||
Role:
|
||||
Public handler that unwraps DatabaseOperation
|
||||
"""
|
||||
result = _clear_sql_model_wrapped(model_class, **filters)
|
||||
|
||||
if result.valid:
|
||||
return result.data
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to clear {model_class.__name__} "
|
||||
f"({result.error_type}), attempting DROP as fallback..."
|
||||
)
|
||||
|
||||
drop_result = _drop_sql_model_wrapped(model_class)
|
||||
|
||||
if drop_result.valid:
|
||||
return drop_result.data
|
||||
else:
|
||||
logger.error(
|
||||
f"[CLEAR SQL MODEL] Both clear and drop failed for {model_class.__name__}: {drop_result.message}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
|
@ -1,20 +1,30 @@
|
|||
from core.errors.logger import logger
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import create_engine
|
||||
from typing import Type, Dict, Any
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.errors.logger import logger
|
||||
from core.models.manage.session_management import get_session
|
||||
from core.models.orm_models.Base import BaseModel
|
||||
|
||||
from core.models.manage.wrapper import safe_db_operation, WrapperRollback
|
||||
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||
|
||||
# all models it knows how to do:
|
||||
from core.models.orm_models.CachedSync import CachedSync
|
||||
from core.models.orm_models.Location import Location
|
||||
from core.models.orm_models.Operator import Operator
|
||||
from core.models.orm_models.Dependency import Dependency
|
||||
|
||||
from core.models.orm_models.EncryptedProxy import EncryptedProxy
|
||||
|
||||
def insert_into_model(model_class: Type, all_data: dict | list, override=False) -> bool:
|
||||
# This is the public interface,
|
||||
def insert_into_model(model_class: Type, all_data: dict | list, override=False) -> DatabaseOperation:
|
||||
"""
|
||||
Generic ORM insert for any model. Keeping it generic for reuse.
|
||||
Purpose:
|
||||
Generic ORM insert for any model. Keeping it generic for reuse.
|
||||
|
||||
Args:
|
||||
model_class: The ORM model class
|
||||
|
|
@ -22,45 +32,78 @@ def insert_into_model(model_class: Type, all_data: dict | list, override=False)
|
|||
override: If True, wipe the table before inserting
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
DatabaseOperation object with true/false
|
||||
"""
|
||||
# Normalize to list for uniform handling
|
||||
data_list = all_data if isinstance(all_data, list) else [all_data]
|
||||
|
||||
logger.info(f"All the public data preparing to be inserted is {all_data}")
|
||||
# Call the wrapped function with normalized data
|
||||
return _wrapped_insert(model_class=model_class, data_list=data_list, override=override)
|
||||
|
||||
with get_session() as session:
|
||||
# Step 1: Wipe the table if override=True
|
||||
if override:
|
||||
logger.info(f"First, WIPING the pre-existing data for {model_class.__name__}. Are you sure you intended to completely delete the old data?")
|
||||
session.query(model_class).delete()
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Starting insert for {model_class.__name__}")
|
||||
@safe_db_operation
|
||||
def _wrapped_insert(model_class: Type, data_list: dict | list, session: Session, override=False) -> DatabaseOperation:
|
||||
"""Insert items. Doesn't manage session. It's managed by the wrapper safe_db_operation"""
|
||||
|
||||
# Normalize to list for uniform handling
|
||||
data_list = all_data if isinstance(all_data, list) else [all_data]
|
||||
if override:
|
||||
logger.info(f"First, WIPING the pre-existing data for {model_class.__name__}. Are you sure you intended to completely delete the old data?")
|
||||
session.query(model_class).delete()
|
||||
|
||||
try:
|
||||
for each_json in data_list:
|
||||
instance = model_class(**each_json)
|
||||
session.add(instance)
|
||||
|
||||
session.commit()
|
||||
logger.info(f"Completed! SQL Insertion Successful following the {model_class.__name__} Model’s Type Rules")
|
||||
return True
|
||||
logger.info(f"Starting insert for {model_class.__name__}")
|
||||
|
||||
except TypeError as e:
|
||||
print(f"TypeError caught: {e}")
|
||||
session.rollback()
|
||||
raise ValueError(f"Invalid fields for {model_class.__name__}: {e}")
|
||||
except IntegrityError as e:
|
||||
print(f"IntegrityError caught: {e.orig}")
|
||||
session.rollback()
|
||||
raise ValueError(f"Constraint violation: {e.orig}")
|
||||
except SQLAlchemyError as e:
|
||||
print(f"SQLAlchemyError caught: {e}")
|
||||
session.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Generic exception caught: {e}")
|
||||
session.rollback()
|
||||
raise e
|
||||
# Get valid field names upfront
|
||||
valid_fields = {col.name for col in model_class.__table__.columns}
|
||||
|
||||
# Filter data proactively
|
||||
filtered_data_list = []
|
||||
all_dropped_fields = set()
|
||||
|
||||
for each_json in data_list:
|
||||
filtered_json = {k: v for k, v in each_json.items() if k in valid_fields}
|
||||
filtered_data_list.append(filtered_json)
|
||||
|
||||
# Track dropped fields
|
||||
dropped = set(each_json.keys()) - valid_fields
|
||||
all_dropped_fields.update(dropped)
|
||||
|
||||
# Log if we dropped anything
|
||||
if all_dropped_fields:
|
||||
logger.info(f"Filtered unknown fields before insert: {all_dropped_fields}")
|
||||
|
||||
try:
|
||||
for filtered_json in filtered_data_list:
|
||||
instance = model_class(**filtered_json)
|
||||
session.add(instance)
|
||||
session.commit()
|
||||
|
||||
logger.info("Finished wrapped insert!")
|
||||
return DatabaseOperation(
|
||||
valid=True,
|
||||
tried_filtered=bool(all_dropped_fields),
|
||||
message=f"Inserted successfully" + (f" after filtering {all_dropped_fields}" if all_dropped_fields else "")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Unexpected error during insert
|
||||
session.rollback()
|
||||
raise WrapperRollback(DatabaseOperation(
|
||||
valid=False,
|
||||
error_type=DBErrorType.UNKNOWN,
|
||||
message=f"Wrapped Insert Failed: {str(e)}"
|
||||
))
|
||||
|
||||
# =================== Isolation Test ======================
|
||||
"""
|
||||
Spoof Failure of an operational error to trigger/test a migration
|
||||
"""
|
||||
# from sqlalchemy import text
|
||||
|
||||
# # 1. Drop a column the model expects
|
||||
# if model_class == Location:
|
||||
# with get_session() as session:
|
||||
# print("dropping provider_name from location..")
|
||||
# session.execute(text("ALTER TABLE locations DROP COLUMN provider_name"))
|
||||
# session.commit()
|
||||
# print("dropped provider_name from location!!!")
|
||||
|
||||
# # 2. Now insert will trigger OperationalError
|
||||
|
|
|
|||
149
core/models/manage/migrations.py
Normal file
149
core/models/manage/migrations.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# custom
|
||||
from core.errors.logger import logger
|
||||
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||
from core.Constants import Constants
|
||||
|
||||
from core.models.manage.session_management import create_ALL_tables
|
||||
# from core.models.orm_models.Location import Location
|
||||
# from core.models.orm_models.Operator import Operator
|
||||
# from core.models.orm_models.CachedSync import CachedSync
|
||||
# from core.models.orm_models.EncryptedProxy import EncryptedProxy
|
||||
|
||||
# generic
|
||||
from pathlib import Path
|
||||
from sqlalchemy import Column, Integer, String, create_engine, inspect, text
|
||||
from sqlalchemy.orm import declarative_base, Session
|
||||
from sqlalchemy.exc import OperationalError, DBAPIError
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy import text
|
||||
|
||||
"""
|
||||
Purpose:
|
||||
Sync SQL database schema to current Python model definitions.
|
||||
|
||||
Situation Used:
|
||||
Python Models have new values missing from the same SQL database
|
||||
|
||||
Metaphor:
|
||||
This replaces something like alembic, by doing it directly
|
||||
|
||||
Confusion:
|
||||
This may be confusing because we're using an ORM in general,
|
||||
but doing migrations manually.
|
||||
|
||||
Why:
|
||||
The reason is because alembic is good for servers, but not clients,
|
||||
due to a large amount of boilerplate for migrations.
|
||||
"""
|
||||
|
||||
def get_model_columns(model_class):
|
||||
"""
|
||||
Extract all columns directly from a model class.
|
||||
Bypasses metadata, caching, and all SQLAlchemy indirection.
|
||||
"""
|
||||
try:
|
||||
mapper = inspect(model_class)
|
||||
columns = {}
|
||||
|
||||
for column in mapper.columns:
|
||||
columns[column.name] = {
|
||||
'type': str(column.type),
|
||||
'nullable': column.nullable,
|
||||
'default': column.default,
|
||||
'primary_key': column.primary_key,
|
||||
}
|
||||
|
||||
return columns
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to inspect {model_class}: {e}")
|
||||
|
||||
|
||||
def migrate_sql() -> DatabaseOperation:
|
||||
from core.models.manage.session_management import engine
|
||||
from core.models.orm_models.Base import BaseModel, Base
|
||||
try:
|
||||
from core.models.orm_models.Location import Location
|
||||
from core.models.orm_models.Operator import Operator
|
||||
from core.models.orm_models.CachedSync import CachedSync
|
||||
from core.models.orm_models.EncryptedProxy import EncryptedProxy
|
||||
from core.models.orm_models.Dependency import Dependency
|
||||
MODELS = [Location, Operator, CachedSync, EncryptedProxy, Dependency]
|
||||
logger.info(f"[MIGRATION] Tables loaded")
|
||||
except:
|
||||
logger.error(f"[MIGRATION] Could not load models. Critical failure.")
|
||||
|
||||
changes_made = []
|
||||
|
||||
# Step 1: Extract column definitions directly from each model
|
||||
model_schema = {}
|
||||
for model_class in MODELS:
|
||||
table_name = model_class.__tablename__
|
||||
try:
|
||||
model_schema[table_name] = get_model_columns(model_class)
|
||||
except ValueError as e:
|
||||
return DatabaseOperation(
|
||||
valid=False,
|
||||
error_type=DBErrorType.PYTHON_MODEL_STRUCTURE,
|
||||
message=e
|
||||
)
|
||||
logger.info(f"[MIGRATION] Model '{table_name}': {list(model_schema[table_name].keys())}")
|
||||
|
||||
# Step 2: Compare to database schema
|
||||
inspector = inspect(engine)
|
||||
db_tables = set(inspector.get_table_names())
|
||||
|
||||
for table_name, expected_columns in model_schema.items():
|
||||
if table_name not in db_tables:
|
||||
changes_made.append(table_name)
|
||||
logger.info(f"[MIGRATION] Table '{table_name}' is MISSING from database")
|
||||
else:
|
||||
db_columns = {col['name']: col for col in inspector.get_columns(table_name)}
|
||||
|
||||
for col_name, col_info in expected_columns.items():
|
||||
if col_name not in db_columns:
|
||||
changes_made.append(col_name)
|
||||
logger.info(f"[MIGRATION] Column '{col_name}' is MISSING from table '{table_name}'")
|
||||
|
||||
# Step 3: Perform the actual migration
|
||||
if changes_made:
|
||||
logger.info("[MIGRATION] Changes detected. Running schema update...")
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
for table_name, expected_columns in model_schema.items():
|
||||
inspector = inspect(engine)
|
||||
|
||||
if table_name in inspector.get_table_names():
|
||||
db_columns = {col['name'] for col in inspector.get_columns(table_name)}
|
||||
|
||||
for col_name, col_info in expected_columns.items():
|
||||
if col_name not in db_columns:
|
||||
col_type = col_info['type']
|
||||
nullable = "NULL" if col_info['nullable'] else "NOT NULL"
|
||||
print(f"[MIGRATION] Adding column '{col_name}' to '{table_name}'")
|
||||
conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type} {nullable}"))
|
||||
|
||||
except (OperationalError, DBAPIError) as e:
|
||||
logger.error(f"[MIGRATION] Failed: {str(e)}")
|
||||
return DatabaseOperation(
|
||||
valid=False,
|
||||
error_type=DBErrorType.UNKNOWN,
|
||||
message=f"Schema sync failed: {str(e)}"
|
||||
)
|
||||
|
||||
logger.info(f"[MIGRATION] Completed! Schema synced successfully with these changes: {changes_made}")
|
||||
return DatabaseOperation(
|
||||
valid=True,
|
||||
data=changes_made,
|
||||
message=f"Synced these tables and columns: {'; '.join(changes_made)}"
|
||||
)
|
||||
|
||||
else:
|
||||
no_migration_needed = "Schema already matches models. No migration needed."
|
||||
logger.info(f"[MIGRATION] {no_migration_needed}")
|
||||
return DatabaseOperation(
|
||||
valid=True,
|
||||
message=no_migration_needed
|
||||
)
|
||||
|
||||
97
core/models/manage/pydantic_manager.py
Normal file
97
core/models/manage/pydantic_manager.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from core.models.pydantic_models.HysteriaData import HysteriaData
|
||||
from core.errors.logger import logger
|
||||
|
||||
# generic
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel
|
||||
import subprocess
|
||||
|
||||
|
||||
def save_to_sudo_folder(model: BaseModel, filepath: str) -> bool:
|
||||
"""
|
||||
Save model to a sudo-protected file using pkexec.
|
||||
"""
|
||||
try:
|
||||
json_data = model.model_dump_json(indent=2)
|
||||
parent_dir = str(Path(filepath).parent)
|
||||
|
||||
# Single pkexec call: mkdir + write
|
||||
process = subprocess.Popen(
|
||||
('pkexec', 'bash', '-c', f'mkdir -p "{parent_dir}" && tee "{filepath}"'),
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
stdout, stderr = process.communicate(input=json_data)
|
||||
|
||||
if process.returncode != 0:
|
||||
logger.error(f"Error: {stderr}")
|
||||
return False
|
||||
|
||||
logger.info(f"Saved to {filepath} (elevated)")
|
||||
return True
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"Error: Parent directory doesn't exist: {filepath}")
|
||||
return False
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error(f"Error: Permission denied writing to {filepath}")
|
||||
return False
|
||||
|
||||
except OSError as e:
|
||||
logger.error(f"Error: OS error (disk full?): {e}")
|
||||
return False
|
||||
|
||||
except TypeError as e:
|
||||
logger.error(f"Error: Model serialization failed (invalid type): {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error: Unexpected error saving {filepath}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def save(model: BaseModel, filepath: str) -> bool:
|
||||
"""
|
||||
Purpose:
|
||||
Serialize a Pydantic model to JSON file WITHOUT sudo.
|
||||
|
||||
Returns:
|
||||
Returns True on success,
|
||||
False on failure.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Saving to {filepath}")
|
||||
|
||||
# Ensure parent directory exists
|
||||
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(model.model_dump_json(indent=2))
|
||||
|
||||
return True
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"Error: Parent directory doesn't exist: {filepath}")
|
||||
return False
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error(f"Error: Permission denied writing to {filepath}")
|
||||
return False
|
||||
|
||||
except OSError as e:
|
||||
logger.error(f"Error: OS error (disk full?): {e}")
|
||||
return False
|
||||
|
||||
except TypeError as e:
|
||||
logger.error(f"Error: Model serialization failed (invalid type): {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error: Unexpected error saving {filepath}: {e}")
|
||||
return False
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
from core.errors.logger import logger
|
||||
from core.models.orm_models.Base import BaseModel
|
||||
from core.models.manage.version_check import insert_new_version
|
||||
from core.Constants import Constants
|
||||
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import sys
|
||||
|
||||
"""
|
||||
Note:
|
||||
At the bottom it initializes the Session and sets it up outside the function loose.
|
||||
|
|
@ -53,6 +59,8 @@ def _reinitialize_engine_and_session():
|
|||
Session = sessionmaker(bind=engine)
|
||||
|
||||
|
||||
|
||||
|
||||
def init_session():
|
||||
"""Initialize the global _session from the global Session factory."""
|
||||
global _session
|
||||
|
|
@ -86,13 +94,38 @@ def create_ONLY_db_version_table():
|
|||
raise RuntimeError("Engine not initialized. Call _reinitialize_engine_and_session() first.")
|
||||
|
||||
from core.models.orm_models.DatabaseVersion import database_version
|
||||
|
||||
try:
|
||||
database_version.create(engine)
|
||||
except:
|
||||
logger.info("[DB MANAGEMENT] database_version already exists, but was attempted to be made again by calling create_ONLY_db_version_table.")
|
||||
database_version.create(engine, checkfirst=True)
|
||||
logger.info("[DB MANAGEMENT] database_version table created.")
|
||||
|
||||
# CRITICAL FIX: Populate the table immediately
|
||||
session = get_session()
|
||||
existing_row = session.query(database_version).first()
|
||||
|
||||
if not existing_row:
|
||||
logger.info("[DB MANAGEMENT] Table was empty. Initializing with current app version.")
|
||||
insert_new_version(session, Constants.DB_VERSION_THIS_APP_WANTS)
|
||||
else:
|
||||
logger.info("[DB MANAGEMENT] Table already has a version row.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DB MANAGEMENT] Failed to create/initialize database_version table: {e}")
|
||||
|
||||
# if using without checking or try except blocks:
|
||||
# database_version.create(engine, checkfirst=True)
|
||||
|
||||
# def create_ONLY_db_version_table():
|
||||
# """Create only the database_version table using the global engine."""
|
||||
# if engine is None:
|
||||
# raise RuntimeError("Engine not initialized. Call _reinitialize_engine_and_session() first.")
|
||||
|
||||
# from core.models.orm_models.DatabaseVersion import database_version
|
||||
# try:
|
||||
# database_version.create(engine)
|
||||
# except:
|
||||
# logger.info("[DB MANAGEMENT] database_version already exists, but was attempted to be made again by calling create_ONLY_db_version_table.")
|
||||
|
||||
# # if using without checking or try except blocks:
|
||||
# # database_version.create(engine, checkfirst=True)
|
||||
|
||||
|
||||
def does_db_version_table_exist():
|
||||
|
|
@ -114,11 +147,13 @@ def create_ALL_tables():
|
|||
from core.models.orm_models.CachedSync import CachedSync
|
||||
from core.models.orm_models.EncryptedProxy import EncryptedProxy
|
||||
from core.models.SubscriptionPlan import SubscriptionPlan
|
||||
from core.models.session.ApplicationVersion import ApplicationVersion
|
||||
from core.models.orm_models.ApplicationVersion import ApplicationVersion
|
||||
from core.models.orm_models.Dependency import Dependency
|
||||
|
||||
try:
|
||||
BaseModel.metadata.create_all(engine, checkfirst=True)
|
||||
logger.info("[DB MANAGEMENT] All Tables have been successfully created.")
|
||||
print("created all tables")
|
||||
return True
|
||||
except:
|
||||
logger.error("[DB MANAGEMENT] Fatal Error with creating all tables in the create_ALL_tables function of session management.")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from core.models.orm_models.DatabaseVersion import database_version
|
|||
from core.Constants import Constants
|
||||
from core.errors.logger import logger
|
||||
|
||||
|
||||
# generic
|
||||
from sqlalchemy import exc, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -271,9 +270,10 @@ def get_custom_message(reason: str, compatability_dict: dict) -> str:
|
|||
GUI's startup script in __main__
|
||||
"""
|
||||
if reason == "upgrade":
|
||||
custom_error = "You just upgraded HydraVeil, so let's upgrade your database to handle the new data types."
|
||||
custom_error = "You just upgraded HydraVeil, so let's upgrade your database to handle the new data types. This will NOT harm your existing profiles or browser sessions."
|
||||
elif reason == "old_app":
|
||||
custom_error = "You're using an old version of HydraVeil, with a newer version of the database. This means the older app would crash trying to handle the newer data."
|
||||
custom_error = "Upgrade Time! You're using an old version of HydraVeil, with a newer version of the database. This means the older app would crash trying to handle the newer data. This will NOT harm your existing profiles or browser sessions."
|
||||
|
||||
else:
|
||||
error_msg = compatability_dict.get("error", "Unknown reason.")
|
||||
custom_error = f"There was an error with upgrading your database version. Please tell customer support: {error_msg}."
|
||||
|
|
@ -345,4 +345,111 @@ def check_database_compatibility(session: Session) -> dict:
|
|||
"reason": "error",
|
||||
"old_db_version": db_version_you_have,
|
||||
"error": "unknown result"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# DEAD CODE. Not used. But COULD be in the future.
|
||||
def delete_sync_metadata(session: Session) -> bool:
|
||||
"""
|
||||
Called By:
|
||||
Nobody, dead code. I originally was going to use this but cut it's use.
|
||||
|
||||
Purpose:
|
||||
Clears existing rows of metadata
|
||||
|
||||
Rank:
|
||||
Helper
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise.
|
||||
does NOT raise errors.
|
||||
"""
|
||||
func_name = "delete_sync_metadata" # for logging purposes
|
||||
|
||||
# Validate session
|
||||
if session is None:
|
||||
logger.error(f"{func_name}: Session is None")
|
||||
return False
|
||||
|
||||
if not isinstance(session, Session):
|
||||
logger.error(f"{func_name}: Invalid session type: {type(session)}")
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.debug(f"[{func_name}]: Starting transaction for metatable entry delete")
|
||||
|
||||
# Check if table exists before proceeding
|
||||
try:
|
||||
session.query(database_version).limit(1).all()
|
||||
except exc.NoSuchTableError:
|
||||
logger.info(
|
||||
f"{func_name}: Table 'database_version' does not exist, this is good because we wanted to clear it."
|
||||
)
|
||||
return True # TRUE! this is great that it's gone already.
|
||||
|
||||
# Delete existing rows
|
||||
logger.debug(f"{func_name}: Deleting existing version rows..")
|
||||
deleted_count = session.query(database_version).delete()
|
||||
logger.debug(f"{func_name}: Deleted {deleted_count} existing row(s)")
|
||||
|
||||
if deleted_count > 1:
|
||||
logger.warning(
|
||||
f"{func_name}: Deleted {deleted_count} rows. "
|
||||
"Table should contain only a single row at any time."
|
||||
)
|
||||
|
||||
# Commit transaction
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
except exc.IntegrityError as e:
|
||||
logger.error(
|
||||
f"{func_name}: Integrity constraint violation (e.g., primary key conflict). "
|
||||
f"Details: {str(e)}"
|
||||
)
|
||||
session.rollback()
|
||||
return False
|
||||
|
||||
except exc.OperationalError as e:
|
||||
logger.info(
|
||||
f"{func_name}: Sync metadata never existed. "
|
||||
f"Details: {str(e)}"
|
||||
)
|
||||
session.rollback()
|
||||
return True # this is good
|
||||
|
||||
except exc.DatabaseError as e:
|
||||
logger.error(
|
||||
f"{func_name}: General database error. "
|
||||
f"Details: {str(e)}"
|
||||
)
|
||||
session.rollback()
|
||||
return False
|
||||
|
||||
except exc.StatementError as e:
|
||||
logger.error(
|
||||
f"{func_name}: SQL statement error. "
|
||||
f"Details: {str(e)}"
|
||||
)
|
||||
session.rollback()
|
||||
return False
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(
|
||||
f"{func_name}: Value error during metadata deletion."
|
||||
f"Details: {str(e)}"
|
||||
)
|
||||
session.rollback()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(
|
||||
f"{func_name}: Unexpected exception type {type(e).__name__}. "
|
||||
f"Details: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
session.rollback()
|
||||
return False
|
||||
|
|
|
|||
123
core/models/manage/wrapper.py
Normal file
123
core/models/manage/wrapper.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# custom
|
||||
from core.errors.logger import logger
|
||||
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||
from core.models.manage.migrations import migrate_sql
|
||||
from core.models.manage.session_management import get_session
|
||||
from core.Constants import Constants
|
||||
|
||||
# generic
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import (
|
||||
OperationalError,
|
||||
IntegrityError,
|
||||
SQLAlchemyError
|
||||
)
|
||||
from typing import Type, Dict, Any, Callable, TypeVar
|
||||
from functools import wraps
|
||||
from enum import Enum
|
||||
|
||||
import traceback
|
||||
|
||||
|
||||
"""
|
||||
Purpose:
|
||||
This is a wrapper for other SQL functions to catch errors and do solutions/fixes.
|
||||
|
||||
Used/Called by:
|
||||
insert_into_model
|
||||
|
||||
Returns:
|
||||
DatabaseOperation Objects
|
||||
"""
|
||||
|
||||
def safe_db_operation(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
|
||||
# get the Session for the function it's wrapping,
|
||||
session = get_session()
|
||||
|
||||
try:
|
||||
# passes the session to the function,
|
||||
result = func(*args, session=session, **kwargs)
|
||||
|
||||
# If func already returns DatabaseOperation, pass it through
|
||||
if isinstance(result, DatabaseOperation):
|
||||
return result
|
||||
|
||||
# Otherwise, wrap success
|
||||
return DatabaseOperation(valid=True, data=result)
|
||||
|
||||
except OperationalError as e:
|
||||
logger.error(f"Schema mismatch in {func.__name__}: {e}")
|
||||
session.rollback()
|
||||
|
||||
try:
|
||||
logger.info("Attempting migration...")
|
||||
migrate_sql()
|
||||
|
||||
# try again:
|
||||
result = func(*args, session=session, **kwargs)
|
||||
return DatabaseOperation(
|
||||
valid=True,
|
||||
data=result,
|
||||
tried_migration=True,
|
||||
message="Recovered via migration"
|
||||
)
|
||||
except Exception as retry_error: # migration failed:
|
||||
logger.error(f"Migration failed with error: {str(retry_error)}", exc_info=True)
|
||||
session.rollback()
|
||||
return DatabaseOperation(
|
||||
valid=False,
|
||||
error_type=DBErrorType.MIGRATION_FAILED,
|
||||
message=str(retry_error)
|
||||
)
|
||||
|
||||
# This is really TypeErrors, passed from the insert function.
|
||||
except WrapperRollback as e:
|
||||
session.rollback()
|
||||
return e.database_operation
|
||||
|
||||
except IntegrityError as e:
|
||||
error_msg = f"IntegrityError caught: {e.orig}"
|
||||
logger.error(error_msg)
|
||||
session.rollback()
|
||||
return DatabaseOperation(
|
||||
valid=False,
|
||||
error_type=DBErrorType.INTEGRITY_ERROR,
|
||||
message=error_msg
|
||||
)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
error_msg = f"SQLAlchemyError caught: {e}"
|
||||
logger.error(error_msg)
|
||||
session.rollback()
|
||||
return DatabaseOperation(
|
||||
valid=False,
|
||||
error_type=DBErrorType.SQLALCHEMY_ERROR,
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.info("=" * 60)
|
||||
logger.info(traceback.print_exc())
|
||||
logger.info("=" * 60)
|
||||
session.rollback()
|
||||
return DatabaseOperation(
|
||||
valid=False,
|
||||
error_type=DBErrorType.UNKNOWN,
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
"""
|
||||
If a wrapped function needs to pass up an error to this wrapper,
|
||||
so that the wrapper can roll back sessions,
|
||||
|
||||
Then it uses this error exception and passes the DatabaseOperation object.
|
||||
"""
|
||||
class WrapperRollback(Exception):
|
||||
def __init__(self, database_operation: DatabaseOperation):
|
||||
self.database_operation = database_operation
|
||||
|
||||
71
core/models/orm_calls/application_version_calls.py
Normal file
71
core/models/orm_calls/application_version_calls.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from core.models.orm_models.ApplicationVersion import ApplicationVersion
|
||||
from core.models.session.Application import Application
|
||||
from core.models.manage.wrapper import safe_db_operation, WrapperRollback
|
||||
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||
from core.errors.logger import logger
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
from typing import Optional
|
||||
import random
|
||||
|
||||
@safe_db_operation
|
||||
def execute_application_sql(application_code: str, version_number: str, session: Session) -> DatabaseOperation:
|
||||
"""Query for a specific application version"""
|
||||
data = session.query(ApplicationVersion).filter(
|
||||
ApplicationVersion.application_code == application_code,
|
||||
ApplicationVersion.version_number == version_number
|
||||
).first()
|
||||
return DatabaseOperation(valid=True, data=data)
|
||||
|
||||
|
||||
def get_application_version(application_code: str, version_number: str) -> Optional[ApplicationVersion]:
|
||||
database_object = execute_application_sql(application_code, version_number)
|
||||
if database_object.valid:
|
||||
return database_object.data
|
||||
else:
|
||||
logger.error(f"Critical Database error with fetching data for {application_code} and version {version_number}: {database_object.error_type} with {database_object.message}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@safe_db_operation
|
||||
def execute_get_all(application: Optional[Application] = None, session: Session = None) -> DatabaseOperation:
|
||||
query = select(ApplicationVersion)
|
||||
|
||||
if application is not None:
|
||||
query = query.where(ApplicationVersion.application_code == application.code)
|
||||
|
||||
result = session.execute(query).scalars().all()
|
||||
return DatabaseOperation(valid=True, data=result)
|
||||
|
||||
|
||||
@safe_db_operation
|
||||
def get_all_supported_apps(session: Session) -> DatabaseOperation:
|
||||
"""Get all supported apps"""
|
||||
data = session.query(ApplicationVersion).filter(
|
||||
ApplicationVersion.format_revision == 2
|
||||
).all()
|
||||
return DatabaseOperation(valid=True, data=data)
|
||||
|
||||
|
||||
def get_random_installed_app() -> Optional[ApplicationVersion]:
|
||||
database_object = get_all_supported_apps()
|
||||
|
||||
if not database_object.valid:
|
||||
logger.error(f"Critical Database error with fetching all supported apps. {database_object.error_type} with {database_object.message}")
|
||||
return None
|
||||
|
||||
list_of_data = list(database_object.data)
|
||||
|
||||
while len(list_of_data) >= 1:
|
||||
random_item = random.choice(list_of_data)
|
||||
print(f"random_item is {random_item}")
|
||||
if random_item.installed:
|
||||
print("it is installed")
|
||||
return random_item
|
||||
else:
|
||||
id_of_random_item = random_item.id
|
||||
list_of_data.remove(random_item)
|
||||
|
||||
return None
|
||||
24
core/models/orm_calls/dependency_calls.py
Normal file
24
core/models/orm_calls/dependency_calls.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from core.models.orm_models.Dependency import Dependency
|
||||
from core.models.manage.wrapper import safe_db_operation, WrapperRollback
|
||||
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||
from core.errors.logger import logger
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
from typing import Optional
|
||||
|
||||
@safe_db_operation
|
||||
def execute_dependency_sql(name: str, session: Session) -> DatabaseOperation:
|
||||
data = session.query(Dependency).filter(
|
||||
Dependency.name == name
|
||||
).first()
|
||||
return DatabaseOperation(valid=True, data=data)
|
||||
|
||||
|
||||
def get_dependency_version(name: str) -> Optional[Dependency]:
|
||||
database_object = execute_dependency_sql(name)
|
||||
if database_object.valid:
|
||||
return database_object.data
|
||||
else:
|
||||
logger.error(f"Critical Database error with fetching data for {name} with {database_object.message}")
|
||||
return None
|
||||
60
core/models/orm_calls/location_calls.py
Normal file
60
core/models/orm_calls/location_calls.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from core.models.orm_models.Location import Location
|
||||
from core.models.orm_models.Operator import Operator
|
||||
from core.models.manage.wrapper import safe_db_operation, WrapperRollback
|
||||
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
|
||||
from core.errors.logger import logger
|
||||
|
||||
# generic
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
import random
|
||||
|
||||
@safe_db_operation
|
||||
def execute_location_sql(country_code: str, city_code: str, session: Session) -> DatabaseOperation:
|
||||
location_object = session.execute(
|
||||
select(Location)
|
||||
.where((Location.country_code == country_code) & (Location.code == city_code))
|
||||
.options(joinedload(Location.operator))
|
||||
).scalar_one_or_none()
|
||||
return location_object
|
||||
|
||||
|
||||
def get_profile_location_data(country_code: str, city_code: str) -> Location:
|
||||
location_object = execute_location_sql(country_code, city_code)
|
||||
if location_object.valid:
|
||||
return location_object.data
|
||||
else:
|
||||
critical_error = f"Got invalid SQL Query which could not be solved by the wrapper, with error message {location_object.message} and type {location_object.error_type}"
|
||||
logger.error(critical_error)
|
||||
print(critical_error)
|
||||
return None
|
||||
|
||||
|
||||
@safe_db_operation
|
||||
def execute_all_locations(session: Session) -> DatabaseOperation:
|
||||
return session.query(Location).all()
|
||||
|
||||
|
||||
def get_random_location(filter_out_list: Optional[list] = None) -> Location:
|
||||
location_object = execute_all_locations()
|
||||
|
||||
if not location_object.valid:
|
||||
critical_error = f"Got invalid SQL Query which could not be solved by the wrapper, with error message {location_object.message} and type {location_object.error_type}"
|
||||
logger.error(critical_error)
|
||||
return None
|
||||
|
||||
list_of_choices = location_object.data
|
||||
while True:
|
||||
random_pick = random.choice(list_of_choices)
|
||||
|
||||
# SPOOF: to force assassin to be a certain location, this can be implemented as a feature later:
|
||||
# if random_pick.id == 4:
|
||||
# return random_pick
|
||||
|
||||
if not filter_out_list:
|
||||
return random_pick
|
||||
|
||||
if random_pick not in filter_out_list:
|
||||
return random_pick
|
||||
79
core/models/orm_models/ApplicationVersion.py
Normal file
79
core/models/orm_models/ApplicationVersion.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from sqlalchemy.orm import Mapped
|
||||
from core.models.orm_models.Base import BaseModel
|
||||
from core.Constants import Constants
|
||||
|
||||
from functools import cached_property
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, UniqueConstraint, TypeDecorator
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
class ISODateTime(TypeDecorator):
|
||||
impl = String
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is not None:
|
||||
return value.isoformat() if isinstance(value, datetime) else value
|
||||
return value
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is not None:
|
||||
return datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
return value
|
||||
|
||||
|
||||
class ApplicationVersion(BaseModel):
|
||||
__tablename__ = 'application_versions'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
application_code = Column(String, unique=False, nullable=False)
|
||||
version_number = Column(String, unique=False, nullable=False)
|
||||
format_revision = Column(Integer, nullable=True)
|
||||
download_path = Column(String, unique=True, nullable=True)
|
||||
# released_at = Column(DateTime, nullable=True)
|
||||
released_at = Column(ISODateTime, nullable=True)
|
||||
file_hash = Column(String, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('application_code', 'version_number', name='uq_app_version'),
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Computed property: supported if format_revision is 2"""
|
||||
return self.format_revision == 2
|
||||
|
||||
@property
|
||||
def is_supported(self) -> bool:
|
||||
return self.format_revision == 2
|
||||
|
||||
|
||||
@cached_property
|
||||
def installed(self) -> bool:
|
||||
return self.is_installed()
|
||||
|
||||
|
||||
def convert_to_dict(self) -> dict:
|
||||
return {
|
||||
"application_code": self.application_code,
|
||||
"version_number": self.version_number
|
||||
}
|
||||
|
||||
def get_installation_path(self):
|
||||
return f'{Constants.HV_APPLICATION_DATA_HOME}/{self.application_code}/{self.version_number}'
|
||||
|
||||
def is_installed(self):
|
||||
return os.path.isdir(self.get_installation_path()) and len(os.listdir(self.get_installation_path())) > 0
|
||||
|
||||
def get_installed_file_hash(self):
|
||||
|
||||
try:
|
||||
return open(f'{self.get_installation_path()}/.sha3-512').readline().strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
def is_fresh(self):
|
||||
return self.is_installed() and (not self.is_supported or self.file_hash == self.get_installed_file_hash())
|
||||
|
|
@ -16,9 +16,10 @@ class CachedSync(Base):
|
|||
# version of the cached sync itself is the primary key
|
||||
version: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
applications: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None)
|
||||
application_versions: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None)
|
||||
client_version: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None)
|
||||
operators: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None)
|
||||
locations: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None)
|
||||
subscriptions: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None)
|
||||
applications: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0)
|
||||
application_versions: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0)
|
||||
client_version: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0)
|
||||
operators: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0)
|
||||
locations: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0)
|
||||
subscriptions: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0)
|
||||
dependencies: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
|
|
|||
15
core/models/orm_models/Dependency.py
Normal file
15
core/models/orm_models/Dependency.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from sqlalchemy.orm import Mapped
|
||||
from core.models.orm_models.Base import BaseModel
|
||||
from core.Constants import Constants
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, UniqueConstraint, TypeDecorator
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
class Dependency(BaseModel):
|
||||
__tablename__ = 'dependencies'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
version_number = Column(String, unique=False, nullable=False)
|
||||
download_path = Column(String, unique=True, nullable=False)
|
||||
file_hash = Column(String, nullable=False)
|
||||
|
|
@ -45,13 +45,14 @@ class Location(BaseModel):
|
|||
is_wireguard_capable: Mapped[Optional[bool]] = mapped_column(String, nullable=True, default=None)
|
||||
is_hysteria2_capable: Mapped[Optional[bool]] = mapped_column(String, nullable=True, default=None)
|
||||
is_vless_capable: Mapped[Optional[bool]] = mapped_column(String, nullable=True, default=None)
|
||||
|
||||
def to_dict(self):
|
||||
|
||||
def convert_to_dict(self):
|
||||
return {
|
||||
"country_code": self.country_code,
|
||||
"code": self.code,
|
||||
"time_zone": self.time_zone
|
||||
}
|
||||
"time_zone": self.time_zone,
|
||||
"location_id": self.id
|
||||
}
|
||||
|
||||
|
||||
# to use:
|
||||
|
|
|
|||
57
core/models/pydantic_models/HysteriaData.py
Normal file
57
core/models/pydantic_models/HysteriaData.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# generic
|
||||
from pydantic import BaseModel, field_validator, ValidationError, HttpUrl, ConfigDict, AnyUrl
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import model_validator, ValidationInfo
|
||||
from typing_extensions import Self
|
||||
from ipaddress import IPv4Address
|
||||
from pydantic_core import PydanticUndefinedType
|
||||
import validators
|
||||
|
||||
class HysteriaData(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
username: str
|
||||
password: str
|
||||
hysteria2_host: str
|
||||
server_ip: IPv4Address
|
||||
location_country_code: str
|
||||
location_city_code: int
|
||||
operator_id: int
|
||||
# operator_name: str = None
|
||||
operator_domain: str = None
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def denormalize(cls, data):
|
||||
if isinstance(data, dict) and 'operator' in data:
|
||||
return {
|
||||
**data,
|
||||
'operator_id': data['operator'].get('id'),
|
||||
'operator_domain': data['operator'].get('domain'),
|
||||
'hysteria2_host': data['operator'].get('hysteria2_host'),
|
||||
}
|
||||
return data
|
||||
|
||||
@field_validator('hysteria2_host', mode='before')
|
||||
@classmethod
|
||||
def validate_domain(cls, v):
|
||||
if isinstance(v, str):
|
||||
if not validators.domain(v):
|
||||
raise ValueError('Invalid domain')
|
||||
return v
|
||||
|
||||
|
||||
# likely future addition:
|
||||
# # @field_validator('operator_id')
|
||||
# # @classmethod
|
||||
# # def validate_operator_exists(cls, v, info):
|
||||
# # db = info.context.get('db')
|
||||
# # if not db:
|
||||
# # raise ValueError("Database session not provided")
|
||||
|
||||
# # operator = db.query(Operator).filter(Operator.id == v).first()
|
||||
# # if not operator:
|
||||
# # raise ValueError(f"Operator ID ID {v} does not exist")
|
||||
|
||||
# # return v
|
||||
|
||||
44
core/models/pydantic_models/VlessData.py
Normal file
44
core/models/pydantic_models/VlessData.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# generic
|
||||
from pydantic import BaseModel, field_validator, ValidationError, HttpUrl, ConfigDict, AnyUrl
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import model_validator, ValidationInfo
|
||||
from typing_extensions import Self
|
||||
from ipaddress import IPv4Address
|
||||
from pydantic_core import PydanticUndefinedType
|
||||
import validators
|
||||
|
||||
class VlessData(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
username: str = None
|
||||
links: str # vless link
|
||||
subscription_url: HttpUrl
|
||||
server_ip: IPv4Address
|
||||
location_country_code: str
|
||||
location_city_code: int
|
||||
operator_id: int
|
||||
operator_domain: str = None # redundant
|
||||
vless_host: str = None # redundant
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def denormalize(cls, data):
|
||||
if isinstance(data, dict) and 'operator' in data:
|
||||
return {
|
||||
**data,
|
||||
'operator_id': data['operator'].get('id'),
|
||||
'operator_domain': data['operator'].get('domain'),
|
||||
'vless_host': data['operator'].get('vless_host'),
|
||||
}
|
||||
return data
|
||||
|
||||
@field_validator('links', mode='before')
|
||||
@classmethod
|
||||
def validate_link(cls, v):
|
||||
if isinstance(v, list):
|
||||
v = v[0]
|
||||
|
||||
if not isinstance(v, str) or not v.startswith('vless://'):
|
||||
raise ValueError('links must be a string starting with "vless://"')
|
||||
|
||||
return v
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
from core.Constants import Constants
|
||||
from core.models.Model import Model
|
||||
from core.models.session.Application import Application
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses_json import config, Exclude
|
||||
from datetime import datetime
|
||||
from dateutil.parser import isoparse
|
||||
from marshmallow import fields
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
_table_name: str = 'application_versions'
|
||||
|
||||
_table_definition: str = """
|
||||
'id' int UNIQUE,
|
||||
'application_code' varchar,
|
||||
'version_number' varchar,
|
||||
'format_revision' int,
|
||||
'download_path' varchar UNIQUE,
|
||||
'released_at' varchar,
|
||||
'file_hash' varchar,
|
||||
UNIQUE(application_code, version_number)
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplicationVersion(Model):
|
||||
application_code: str
|
||||
version_number: str
|
||||
format_revision: Optional[int] = field(
|
||||
default=None,
|
||||
metadata=config(exclude=Exclude.ALWAYS)
|
||||
)
|
||||
id: Optional[int] = field(
|
||||
default=None,
|
||||
metadata=config(exclude=Exclude.ALWAYS)
|
||||
)
|
||||
download_path: Optional[str] = field(
|
||||
default=None,
|
||||
metadata=config(exclude=Exclude.ALWAYS)
|
||||
)
|
||||
released_at: Optional[datetime] = field(
|
||||
default=None,
|
||||
metadata=config(
|
||||
encoder=datetime.isoformat,
|
||||
decoder=datetime.fromisoformat,
|
||||
mm_field=fields.DateTime(format='iso'),
|
||||
exclude=Exclude.ALWAYS
|
||||
)
|
||||
)
|
||||
file_hash: Optional[str] = field(
|
||||
default=None,
|
||||
metadata=config(exclude=Exclude.ALWAYS)
|
||||
)
|
||||
installed: Optional[bool] = field(
|
||||
default=False,
|
||||
metadata=config(exclude=Exclude.ALWAYS)
|
||||
)
|
||||
supported: Optional[bool] = field(
|
||||
default=False,
|
||||
metadata=config(exclude=Exclude.ALWAYS)
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
self.installed = self.is_installed()
|
||||
self.supported = self.is_supported()
|
||||
|
||||
def get_installation_path(self):
|
||||
return f'{Constants.HV_APPLICATION_DATA_HOME}/{self.application_code}/{self.version_number}'
|
||||
|
||||
def is_installed(self):
|
||||
return os.path.isdir(self.get_installation_path()) and len(os.listdir(self.get_installation_path())) > 0
|
||||
|
||||
def is_supported(self):
|
||||
return self.exists(self.application_code, self.version_number) and self.format_revision == 2
|
||||
|
||||
def get_installed_file_hash(self):
|
||||
|
||||
try:
|
||||
return open(f'{self.get_installation_path()}/.sha3-512').readline().strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
def is_fresh(self):
|
||||
return self.is_installed() and (not self.is_supported() or self.file_hash == self.get_installed_file_hash())
|
||||
|
||||
@staticmethod
|
||||
def find_by_id(id: int):
|
||||
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition)
|
||||
return Model._query_one('SELECT * FROM application_versions WHERE id = ? LIMIT 1', ApplicationVersion.factory, [id])
|
||||
|
||||
@staticmethod
|
||||
def find(application_code: str, version_number: str):
|
||||
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition)
|
||||
return Model._query_one('SELECT * FROM application_versions WHERE application_code = ? AND version_number = ? LIMIT 1', ApplicationVersion.factory, [application_code, version_number])
|
||||
|
||||
@staticmethod
|
||||
def all(application: Optional[Application] = None):
|
||||
|
||||
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition)
|
||||
|
||||
if application is None:
|
||||
return Model._query_all('SELECT * FROM application_versions', ApplicationVersion.factory)
|
||||
|
||||
else:
|
||||
return Model._query_all('SELECT * FROM application_versions WHERE application_code = ?', ApplicationVersion.factory, [application.code])
|
||||
|
||||
@staticmethod
|
||||
def exists(application_code: str, version_number: str):
|
||||
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition)
|
||||
return Model._query_exists('SELECT * FROM application_versions WHERE application_code = ? AND version_number = ?', [application_code, version_number])
|
||||
|
||||
@staticmethod
|
||||
def truncate():
|
||||
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition, drop_existing=True)
|
||||
|
||||
@staticmethod
|
||||
def save_many(application_versions):
|
||||
Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition)
|
||||
Model._insert_many('INSERT INTO application_versions VALUES(?, ?, ?, ?, ?, ?, ?)', ApplicationVersion.tuple_factory, application_versions)
|
||||
|
||||
@staticmethod
|
||||
def factory(cursor, row):
|
||||
|
||||
database_fields = [column[0] for column in cursor.description]
|
||||
|
||||
application_version = ApplicationVersion(**{key: value for key, value in zip(database_fields, row)})
|
||||
application_version.released_at = isoparse(str(application_version.released_at))
|
||||
|
||||
return application_version
|
||||
|
||||
@staticmethod
|
||||
def tuple_factory(application_version):
|
||||
return application_version.id, application_version.application_code, application_version.version_number, application_version.format_revision, application_version.download_path, application_version.released_at, application_version.file_hash
|
||||
|
|
@ -6,13 +6,11 @@ class NetworkPortNumbers:
|
|||
proxy: list[int] = field(default_factory=list)
|
||||
wireguard: list[int] = field(default_factory=list)
|
||||
tor: list[int] = field(default_factory=list)
|
||||
vless: list[int] = field(default_factory=list)
|
||||
hysteria2: list[int] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def all(self):
|
||||
return self.proxy + self.wireguard + self.tor + self.vless + self.hysteria2
|
||||
return self.proxy + self.wireguard + self.tor
|
||||
|
||||
@property
|
||||
def isolated(self):
|
||||
return self.proxy + self.wireguard + self.vless + self.hysteria2
|
||||
return self.proxy + self.wireguard
|
||||
|
|
|
|||
|
|
@ -6,19 +6,47 @@ class SessionConnection(BaseConnection):
|
|||
masked: bool = False
|
||||
|
||||
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.')
|
||||
|
||||
# called by connection controller
|
||||
|
||||
# Called by: ConnectionController
|
||||
# doesn't even make sense, it's checking if the Session is code system. this will always be false.
|
||||
def is_unprotected(self):
|
||||
return self.code == 'system' and self.masked is False
|
||||
|
||||
# Called by: SessionProfile.determine_timezone
|
||||
def needs_proxy_configuration(self):
|
||||
return self.masked is True
|
||||
|
||||
def needs_operator_proxy(self):
|
||||
return self.code in ('vless', 'hysteria2')
|
||||
|
||||
def get_protocol(self):
|
||||
return self.code if self.needs_operator_proxy() else None
|
||||
# Potential refactor
|
||||
|
||||
# from dataclasses import dataclass, field
|
||||
# from dataclasses_json import dataclass_json, config
|
||||
# from enum import Enum
|
||||
|
||||
# class SessionConnectionTypes(str, Enum):
|
||||
# WIREGUARD = "wireguard"
|
||||
# TOR = "tor"
|
||||
|
||||
# @dataclass_json
|
||||
# @dataclass
|
||||
# class SessionConnection:
|
||||
# code: SessionConnectionTypes = field(
|
||||
# metadata=config(
|
||||
# encoder=lambda x: x.value, # Convert enum to string on save
|
||||
# decoder=lambda x: SessionConnectionTypes(x) # Convert string to enum on load
|
||||
# )
|
||||
# )
|
||||
# masked: bool = False
|
||||
|
||||
# def __post_init__(self):
|
||||
# # Convert string to enum for deserialization
|
||||
# if isinstance(self.code, str):
|
||||
# self.code = SessionConnectionTypes(self.code)
|
||||
|
||||
# # Validation
|
||||
# if self.code not in SessionConnectionTypes:
|
||||
# raise ValueError(f'Invalid code: {self.code}')
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
from core.Constants import Constants
|
||||
from core.Errors import UnknownTimeZoneError
|
||||
from core.models.BaseProfile import BaseProfile
|
||||
from core.models.session.ApplicationVersion import ApplicationVersion
|
||||
# from core.models.session.ApplicationVersion import ApplicationVersion
|
||||
|
||||
from core.models.orm_models.ApplicationVersion import ApplicationVersion
|
||||
|
||||
|
||||
from core.models.session.ProxyConfiguration import ProxyConfiguration
|
||||
from core.models.session.SessionConnection import SessionConnection
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -16,30 +20,48 @@ import shutil
|
|||
class SessionProfile(BaseProfile):
|
||||
resolution: str
|
||||
application_version: Optional[ApplicationVersion]
|
||||
connection: Optional[SessionConnection]
|
||||
connection: Optional[SessionConnection] = None
|
||||
ticket: Optional[int] = None
|
||||
assassin: Optional[bool] = False
|
||||
|
||||
def has_connection(self):
|
||||
return self.connection is not None
|
||||
|
||||
def save(self):
|
||||
print("We are able to trigger save on the child session")
|
||||
|
||||
if 'application_version' in self._get_dirty_keys():
|
||||
|
||||
persistent_state_path = f'{self.get_data_path()}/persistent-state'
|
||||
|
||||
if os.path.isdir(persistent_state_path):
|
||||
shutil.rmtree(persistent_state_path, ignore_errors=True)
|
||||
|
||||
if 'location' in self._get_dirty_keys():
|
||||
|
||||
self.__delete_proxy_configuration()
|
||||
self.__delete_wireguard_configuration()
|
||||
super().save()
|
||||
self.delete_wireguard_configuration()
|
||||
|
||||
# === APPLICATION ===
|
||||
app_version_dict = self.application_version.convert_to_dict()
|
||||
print(f"session child got {app_version_dict} as dict..")
|
||||
|
||||
super().save(app_version_dict=app_version_dict)
|
||||
|
||||
def attach_proxy_configuration(self, proxy_configuration):
|
||||
|
||||
proxy_configuration_file_contents = f'{proxy_configuration.to_json(indent=4)}\n'
|
||||
os.makedirs(Constants.HV_CONFIG_HOME, exist_ok=True)
|
||||
|
||||
proxy_configuration_file_path = self.get_proxy_configuration_path()
|
||||
|
||||
with open(proxy_configuration_file_path, 'w') as proxy_configuration_file:
|
||||
proxy_configuration_file.write(proxy_configuration_file_contents)
|
||||
|
||||
def attach_wireguard_configuration(self, wireguard_configuration):
|
||||
|
||||
wireguard_configuration_file_path = self.get_wireguard_configuration_path()
|
||||
|
||||
with open(wireguard_configuration_file_path, 'w') as wireguard_configuration_file:
|
||||
wireguard_configuration_file.write(wireguard_configuration)
|
||||
|
||||
|
|
@ -50,15 +72,19 @@ class SessionProfile(BaseProfile):
|
|||
return f'{self.get_config_path()}/wg.conf'
|
||||
|
||||
def get_proxy_configuration(self):
|
||||
|
||||
try:
|
||||
config_file_contents = open(self.get_proxy_configuration_path(), 'r').read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
try:
|
||||
proxy_configuration = json.loads(config_file_contents)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
proxy_configuration = ProxyConfiguration.from_dict(proxy_configuration)
|
||||
|
||||
return proxy_configuration
|
||||
|
||||
def has_proxy_configuration(self):
|
||||
|
|
@ -68,26 +94,36 @@ class SessionProfile(BaseProfile):
|
|||
return os.path.isfile(f'{self.get_config_path()}/wg.conf')
|
||||
|
||||
def address_security_incident(self):
|
||||
|
||||
super().address_security_incident()
|
||||
self.__delete_wireguard_configuration()
|
||||
self.delete_wireguard_configuration()
|
||||
|
||||
def determine_timezone(self):
|
||||
|
||||
time_zone = None
|
||||
|
||||
if self.has_connection():
|
||||
|
||||
if self.connection.needs_proxy_configuration():
|
||||
|
||||
if self.has_proxy_configuration():
|
||||
time_zone = self.get_proxy_configuration().time_zone
|
||||
|
||||
elif self.connection.needs_wireguard_configuration():
|
||||
|
||||
if self.has_wireguard_configuration():
|
||||
time_zone = self.get_wireguard_configuration_metadata('TZ')
|
||||
|
||||
if time_zone is None and self.has_location():
|
||||
time_zone = self.location.time_zone
|
||||
|
||||
if time_zone is None:
|
||||
raise UnknownTimeZoneError('The preferred time zone could not be determined.')
|
||||
|
||||
return time_zone
|
||||
|
||||
def __delete_proxy_configuration(self):
|
||||
Path(self.get_proxy_configuration_path()).unlink(missing_ok=True)
|
||||
|
||||
def __delete_wireguard_configuration(self):
|
||||
Path(self.get_wireguard_configuration_path()).unlink(missing_ok=True)
|
||||
def delete_wireguard_configuration(self):
|
||||
Path(self.get_wireguard_configuration_path()).unlink(missing_ok=True)
|
||||
|
|
|
|||
|
|
@ -1,22 +1,29 @@
|
|||
from core.models.BaseConnection import BaseConnection
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
# legacy:
|
||||
@dataclass
|
||||
class SystemConnection(BaseConnection):
|
||||
operator_id: Optional[int] = field(default=None)
|
||||
protocol: Optional[str] = field(default=None)
|
||||
class SystemConnection (BaseConnection):
|
||||
|
||||
def __post_init__(self):
|
||||
if self.code not in ('wireguard', 'operator', 'vless', 'hysteria2'):
|
||||
|
||||
if self.code not in ('vless', 'hysteria2', 'wireguard'):
|
||||
raise ValueError('Invalid connection code.')
|
||||
|
||||
@staticmethod
|
||||
def needs_proxy_configuration():
|
||||
return False
|
||||
|
||||
def needs_operator_proxy(self):
|
||||
return self.code in ('operator', 'vless', 'hysteria2')
|
||||
# Potential refactor:
|
||||
# from enum import Enum
|
||||
# from typing import Literal
|
||||
|
||||
def get_protocol(self):
|
||||
return self.protocol if self.protocol else self.code
|
||||
# class SystemConnectionTypes(str, Enum):
|
||||
# WIREGUARD = "wireguard"
|
||||
# HYSTERIA2 = "hysteria2"
|
||||
# VLESS = "vless"
|
||||
|
||||
# @dataclass
|
||||
# class SystemConnection(BaseConnection):
|
||||
# code: SystemConnectionTypes
|
||||
# masked: Literal[False] = False
|
||||
|
|
|
|||
|
|
@ -4,14 +4,16 @@ from core.models.BaseProfile import BaseProfile
|
|||
from core.models.system.SystemConnection import SystemConnection
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemProfile(BaseProfile):
|
||||
connection: Optional[SystemConnection]
|
||||
ticket: Optional[int] = None
|
||||
assassin: Optional[bool] = False
|
||||
|
||||
def get_system_config_path(self):
|
||||
filepath = self.__get_system_config_path(self.id)
|
||||
|
|
@ -19,25 +21,33 @@ class SystemProfile(BaseProfile):
|
|||
return filepath
|
||||
|
||||
def save(self):
|
||||
|
||||
if 'location' in self._get_dirty_keys():
|
||||
self.__delete_wireguard_configuration()
|
||||
|
||||
super().save()
|
||||
|
||||
def attach_wireguard_configuration(self, wireguard_configuration):
|
||||
|
||||
if shutil.which('pkexec') is None:
|
||||
raise CommandNotFoundError('pkexec')
|
||||
|
||||
wireguard_configuration_file_backup_path = f'{self.get_config_path()}/wg.conf.bak'
|
||||
|
||||
with open(wireguard_configuration_file_backup_path, 'w') as wireguard_configuration_file:
|
||||
wireguard_configuration_file.write(wireguard_configuration)
|
||||
|
||||
wireguard_configuration_is_attached = False
|
||||
failed_attempt_count = 0
|
||||
# Try sudo first (configured via sudoers by installer), fall back to pkexec
|
||||
install_cmd = 'sudo' if shutil.which('sudo') else 'pkexec'
|
||||
if install_cmd == 'pkexec' and shutil.which('pkexec') is None:
|
||||
raise CommandNotFoundError('pkexec')
|
||||
|
||||
while not wireguard_configuration_is_attached and failed_attempt_count < 3:
|
||||
process = subprocess.Popen((install_cmd, '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)
|
||||
|
||||
if not wireguard_configuration_is_attached:
|
||||
failed_attempt_count += 1
|
||||
|
||||
if not wireguard_configuration_is_attached:
|
||||
raise ProfileModificationError('The WireGuard configuration could not be attached.')
|
||||
|
||||
|
|
@ -53,6 +63,7 @@ class SystemProfile(BaseProfile):
|
|||
return False
|
||||
|
||||
def address_security_incident(self):
|
||||
|
||||
super().address_security_incident()
|
||||
self.__delete_wireguard_configuration()
|
||||
|
||||
|
|
@ -61,6 +72,7 @@ class SystemProfile(BaseProfile):
|
|||
self.__delete_wireguard_configuration()
|
||||
except ProfileModificationError:
|
||||
raise ProfileDeletionError('The WireGuard configuration could not be deleted.')
|
||||
|
||||
if shutil.which('pkexec') is None:
|
||||
raise CommandNotFoundError('pkexec')
|
||||
|
||||
|
|
@ -74,59 +86,19 @@ class SystemProfile(BaseProfile):
|
|||
|
||||
super().delete()
|
||||
|
||||
def attach_operator_proxy_session(self, operator_proxy_session):
|
||||
from core.models.OperatorProxySession import OperatorProxySession
|
||||
operator_proxy_session_file_contents = f'{operator_proxy_session.to_json(indent=4)}\n'
|
||||
os.makedirs(self.get_config_path(), exist_ok=True)
|
||||
operator_proxy_session_file_path = self.get_operator_proxy_session_path()
|
||||
with open(operator_proxy_session_file_path, 'w') as operator_proxy_session_file:
|
||||
operator_proxy_session_file.write(operator_proxy_session_file_contents)
|
||||
if operator_proxy_session.location_country_code and operator_proxy_session.location_city_code:
|
||||
try:
|
||||
from core.models.orm_models.Location import Location
|
||||
from core.models.manage.session_management import get_session
|
||||
from sqlalchemy import select
|
||||
session = get_session()
|
||||
loc = session.execute(
|
||||
select(Location).where(
|
||||
(Location.country_code == operator_proxy_session.location_country_code) &
|
||||
(Location.code == operator_proxy_session.location_city_code)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if loc:
|
||||
self.location = loc
|
||||
self.save()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_operator_proxy_session_path(self):
|
||||
return f'{self.get_config_path()}/operator_proxy_session.json'
|
||||
|
||||
def get_operator_proxy_session(self):
|
||||
try:
|
||||
config_file_contents = open(self.get_operator_proxy_session_path(), 'r').read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(config_file_contents)
|
||||
except ValueError:
|
||||
return None
|
||||
from core.models.OperatorProxySession import OperatorProxySession
|
||||
return OperatorProxySession.from_dict(data)
|
||||
|
||||
def has_operator_proxy_session(self):
|
||||
return os.path.isfile(self.get_operator_proxy_session_path())
|
||||
|
||||
def __delete_wireguard_configuration(self):
|
||||
|
||||
if self.has_wireguard_configuration():
|
||||
|
||||
if shutil.which('pkexec') is None:
|
||||
raise CommandNotFoundError('pkexec')
|
||||
|
||||
try:
|
||||
process = subprocess.run(('pkexec', 'rm', '-rf', self.get_wireguard_configuration_path()), check=True)
|
||||
|
||||
completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8)
|
||||
except subprocess.CalledProcessError as e:
|
||||
completed_successfully = True
|
||||
completed_successfully = True
|
||||
except:
|
||||
completed_successfully = True
|
||||
|
||||
|
|
@ -137,3 +109,29 @@ class SystemProfile(BaseProfile):
|
|||
def __get_system_config_path(id: int):
|
||||
config_path = f'{Constants.HV_SYSTEM_PROFILE_CONFIG_PATH}/{str(id)}'
|
||||
return config_path
|
||||
|
||||
|
||||
def attach_encrypted_proxy_config(self, config_data: dict):
|
||||
|
||||
if shutil.which('pkexec') is None:
|
||||
raise CommandNotFoundError('pkexec')
|
||||
|
||||
backup_path = f'{self.get_config_path()}/proxy.conf.bak'
|
||||
|
||||
with open(backup_path, 'w') as configuration_file:
|
||||
configuration_file.write(config_data)
|
||||
|
||||
configuration_is_attached = False
|
||||
failed_attempt_count = 0
|
||||
|
||||
while not configuration_is_attached and failed_attempt_count < 3:
|
||||
|
||||
process = subprocess.Popen(('pkexec', 'install', '-D', backup_path, self.get_wireguard_configuration_path(), '-o', 'root', '-m', '744'))
|
||||
configuration_is_attached = not bool(os.waitpid(process.pid, 0)[1] >> 8)
|
||||
|
||||
if not configuration_is_attached:
|
||||
failed_attempt_count += 1
|
||||
|
||||
if not configuration_is_attached:
|
||||
raise ProfileModificationError('The WireGuard configuration could not be attached.')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
from core.Constants import Constants
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from dataclasses_json import dataclass_json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Self
|
||||
from typing import Self
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Optional
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class SystemState:
|
||||
profile_id: int
|
||||
session_token: Optional[str] = field(default=None)
|
||||
pid: Optional[int] = field(default=None)
|
||||
firewalled: bool
|
||||
dns_set: bool
|
||||
process_id: Optional[int] = None
|
||||
|
||||
def save(self: Self):
|
||||
|
||||
|
|
@ -33,6 +35,7 @@ class SystemState:
|
|||
system_state_file_contents = open(f'{SystemState.__get_state_path()}/system.json', 'r').read()
|
||||
system_state_dict = json.loads(system_state_file_contents)
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
return SystemState.from_dict(system_state_dict)
|
||||
|
||||
except (FileNotFoundError, ValueError, KeyError):
|
||||
|
|
@ -54,4 +57,4 @@ class SystemState:
|
|||
|
||||
@staticmethod
|
||||
def __get_state_path():
|
||||
return Constants.HV_STATE_HOME
|
||||
return Constants.HV_STATE_HOME
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
from essentials.observers.ConnectionObserver import ConnectionObserver as BaseConnectionObserver
|
||||
|
||||
|
||||
class ConnectionObserver(BaseConnectionObserver):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.on_connected = []
|
||||
self.on_disconnected = []
|
||||
self.on_error = []
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
from core.observers.BaseObserver import BaseObserver
|
||||
|
||||
|
||||
class EncryptedProxyObserver(BaseObserver):
|
||||
def __init__(self):
|
||||
self.on_connected = []
|
||||
self.on_disconnected = []
|
||||
self.on_error = []
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
from core.Constants import Constants
|
||||
from core.models.ClientVersion import ClientVersion
|
||||
# from core.models.Location import Location # migrated to ORM
|
||||
# from core.models.Operator import Operator # migrated to ORM
|
||||
from core.models.OperatorProxySession import OperatorProxySession
|
||||
from core.models.orm_models.Location import Location
|
||||
from core.models.orm_models.Operator import Operator
|
||||
from core.models.Subscription import Subscription
|
||||
from core.models.SubscriptionPlan import SubscriptionPlan
|
||||
from core.models.invoice.Invoice import Invoice
|
||||
from core.models.invoice.PaymentMethod import PaymentMethod
|
||||
from core.models.session.Application import Application
|
||||
from core.models.session.ApplicationVersion import ApplicationVersion
|
||||
# from core.models.session.ApplicationVersion import ApplicationVersion
|
||||
from core.models.orm_models.ApplicationVersion import ApplicationVersion
|
||||
|
||||
from core.models.session.ProxyConfiguration import ProxyConfiguration
|
||||
from typing import Optional
|
||||
import re
|
||||
|
|
@ -19,10 +20,12 @@ class WebServiceApiService:
|
|||
@staticmethod
|
||||
def get_applications(proxies: Optional[dict] = None):
|
||||
|
||||
from requests.status_codes import codes as status_codes
|
||||
|
||||
response = WebServiceApiService.__get('/platforms/linux-x86_64/applications', None, proxies)
|
||||
applications = []
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
if response.status_code == status_codes.OK:
|
||||
for application in response.json()['data']:
|
||||
applications.append(Application(application['code'], application['name'], application['id']))
|
||||
|
||||
|
|
@ -31,10 +34,12 @@ class WebServiceApiService:
|
|||
@staticmethod
|
||||
def get_application_versions(code: str, proxies: Optional[dict] = None):
|
||||
|
||||
from requests.status_codes import codes as status_codes
|
||||
|
||||
response = WebServiceApiService.__get(f'/platforms/linux-x86_64/applications/{code}/application-versions', None, proxies)
|
||||
application_versions = []
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
if response.status_code == status_codes.OK:
|
||||
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']))
|
||||
|
||||
|
|
@ -43,10 +48,12 @@ class WebServiceApiService:
|
|||
@staticmethod
|
||||
def get_client_versions(proxies: Optional[dict] = None):
|
||||
|
||||
from requests.status_codes import codes as status_codes
|
||||
|
||||
response = WebServiceApiService.__get('/platforms/linux-x86_64/appimage/client-versions', None, proxies)
|
||||
client_versions = []
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
if response.status_code == status_codes.OK:
|
||||
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']))
|
||||
|
||||
|
|
@ -55,22 +62,26 @@ class WebServiceApiService:
|
|||
@staticmethod
|
||||
def get_operators(proxies: Optional[dict] = None):
|
||||
|
||||
from requests.status_codes import codes as status_codes
|
||||
|
||||
response = WebServiceApiService.__get('/operators', None, proxies)
|
||||
operators = []
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
if response.status_code == status_codes.OK:
|
||||
for operator in response.json()['data']:
|
||||
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']))
|
||||
operators.append(Operator(operator['id'], operator['name'], operator['public_key'], operator['nostr_public_key'], operator['nostr_profile_reference'], operator['nostr_attestation']['event_reference']))
|
||||
|
||||
return operators
|
||||
|
||||
@staticmethod
|
||||
def get_locations(proxies: Optional[dict] = None):
|
||||
|
||||
from requests.status_codes import codes as status_codes
|
||||
|
||||
response = WebServiceApiService.__get('/locations', None, proxies)
|
||||
locations = []
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
if response.status_code == status_codes.OK:
|
||||
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']))
|
||||
|
||||
|
|
@ -79,57 +90,60 @@ class WebServiceApiService:
|
|||
@staticmethod
|
||||
def get_subscription_plans(proxies: Optional[dict] = None):
|
||||
|
||||
from requests.status_codes import codes as status_codes
|
||||
|
||||
response = WebServiceApiService.__get('/subscription-plans', None, proxies)
|
||||
subscription_plans = []
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
if response.status_code == status_codes.OK:
|
||||
for subscription_plan in response.json()['data']:
|
||||
subscription_plans.append(SubscriptionPlan(subscription_plan['id'], subscription_plan['code'], subscription_plan['wireguard_session_limit'], subscription_plan['duration'], subscription_plan['price'], subscription_plan['features_proxy'], subscription_plan['features_wireguard']))
|
||||
|
||||
return subscription_plans
|
||||
|
||||
@staticmethod
|
||||
def post_subscription(subscription_plan_id, location_id=None, operator_id=None, proxies: Optional[dict] = None):
|
||||
def post_subscription(subscription_plan_id, location_id, proxies: Optional[dict] = None):
|
||||
|
||||
body = {'subscription_plan_id': subscription_plan_id}
|
||||
from requests.status_codes import codes as status_codes
|
||||
|
||||
response = WebServiceApiService.__post('/subscriptions', None, {
|
||||
'subscription_plan_id': subscription_plan_id,
|
||||
'location_id': location_id
|
||||
}, proxies)
|
||||
|
||||
if response.status_code == status_codes.CREATED:
|
||||
return Subscription(response.headers['X-Billing-Code'])
|
||||
|
||||
if operator_id is not None:
|
||||
body['operator_id'] = operator_id
|
||||
else:
|
||||
body['location_id'] = location_id
|
||||
|
||||
response = WebServiceApiService.__post('/subscriptions', None, body, proxies)
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
return Subscription(response.headers['X-Billing-Code'], operator_id=operator_id)
|
||||
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_subscription(billing_code: str, proxies: Optional[dict] = None):
|
||||
|
||||
from requests.status_codes import codes as status_codes
|
||||
|
||||
billing_code = billing_code.replace('-', '').upper()
|
||||
billing_code_fragments = re.findall('....?', billing_code)
|
||||
billing_code = '-'.join(billing_code_fragments)
|
||||
|
||||
response = WebServiceApiService.__get('/subscriptions/current', billing_code, proxies)
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
subscription = response.json()['data']
|
||||
return Subscription(
|
||||
billing_code,
|
||||
operator_id=subscription.get('operator_id'),
|
||||
expires_at=Subscription.from_iso_format(subscription['expires_at'])
|
||||
)
|
||||
if response.status_code == status_codes.OK:
|
||||
|
||||
return None
|
||||
subscription = response.json()['data']
|
||||
return Subscription(billing_code, Subscription.from_iso_format(subscription['expires_at']))
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_invoice(billing_code: str, proxies: Optional[dict] = None):
|
||||
|
||||
from requests.status_codes import codes as status_codes
|
||||
|
||||
response = WebServiceApiService.__get('/invoices/current', billing_code, proxies)
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
if response.status_code == status_codes.OK:
|
||||
|
||||
response_data = response.json()['data']
|
||||
|
||||
|
|
@ -145,59 +159,37 @@ class WebServiceApiService:
|
|||
|
||||
return Invoice(billing_code, invoice['status'], invoice['expires_at'], tuple[PaymentMethod](payment_methods))
|
||||
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_proxy_configuration(billing_code: str, proxies: Optional[dict] = None):
|
||||
|
||||
from requests.status_codes import codes as status_codes
|
||||
|
||||
response = WebServiceApiService.__get('/proxy-configurations/current', billing_code, proxies)
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
if response.status_code == status_codes.OK:
|
||||
|
||||
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 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'),
|
||||
data.get('location_country_code'),
|
||||
data.get('location_city_code'),
|
||||
)
|
||||
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def post_wireguard_session(country_code: str, location_code: str, billing_code: str, public_key: str, proxies: Optional[dict] = None):
|
||||
|
||||
from requests.status_codes import codes as status_codes
|
||||
|
||||
response = WebServiceApiService.__post(f'/countries/{country_code}/locations/{location_code}/wireguard-sessions', billing_code, {
|
||||
'public_key': public_key,
|
||||
}, proxies)
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
if response.status_code == status_codes.CREATED:
|
||||
return response.text
|
||||
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __get(path, billing_code: Optional[str] = None, proxies: Optional[dict] = None):
|
||||
|
|
@ -209,7 +201,7 @@ class WebServiceApiService:
|
|||
else:
|
||||
headers = None
|
||||
|
||||
return requests.get(Constants.SP_API_BASE_URL + path, headers=headers, proxies=proxies, timeout=30)
|
||||
return requests.get(Constants.SP_API_BASE_URL + path, headers=headers, proxies=proxies)
|
||||
|
||||
@staticmethod
|
||||
def __post(path, billing_code: Optional[str] = None, body: Optional[dict] = None, proxies: Optional[dict] = None):
|
||||
|
|
@ -221,16 +213,16 @@ class WebServiceApiService:
|
|||
else:
|
||||
headers = None
|
||||
|
||||
return requests.post(Constants.SP_API_BASE_URL + path, headers=headers, json=body, proxies=proxies, timeout=30)
|
||||
return requests.post(Constants.SP_API_BASE_URL + path, headers=headers, json=body, proxies=proxies)
|
||||
|
||||
@staticmethod
|
||||
def get_cached_sync(proxies: Optional[dict] = None):
|
||||
# @staticmethod
|
||||
# def get_cached_sync(proxies: Optional[dict] = None):
|
||||
|
||||
from requests.status_codes import codes as status_codes
|
||||
# from requests.status_codes import codes as status_codes
|
||||
|
||||
response = WebServiceApiService.__get('/cachedsync', None, proxies)
|
||||
# response = WebServiceApiService.__get('/cachedsync', None, proxies)
|
||||
|
||||
if response.status_code == status_codes.OK:
|
||||
return response.json()
|
||||
else:
|
||||
return None
|
||||
# if response.status_code == status_codes.OK:
|
||||
# return response.json()
|
||||
# else:
|
||||
# return None
|
||||
166
core/services/assassin/assassin_tools.py
Normal file
166
core/services/assassin/assassin_tools.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
from core.models.orm_calls.application_version_calls import get_random_installed_app
|
||||
from core.models.orm_calls.location_calls import get_random_location
|
||||
from core.services.assassin.screen_size import pick_random_resolution
|
||||
from core.services.assassin import ticket_respawn
|
||||
from core.services.assassin.location_tools import get_systemwide_location
|
||||
from core.controllers.tickets.UseTicketController import use_ticket
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.models.session.SessionProfile import SessionProfile
|
||||
from core.models.Subscription import Subscription
|
||||
from core.models.BaseProfile import ProfileType
|
||||
from core.models.orm_models.Location import Location
|
||||
from core.observers.ConnectionObserver import ConnectionObserver
|
||||
from core.observers.TicketObserver import TicketObserver
|
||||
from core.Errors import MissingSubscriptionError
|
||||
from core.errors.logger import logger
|
||||
|
||||
|
||||
def create(
|
||||
profile: SessionProfile,
|
||||
max_resolution: tuple,
|
||||
ticket_observer: TicketObserver,
|
||||
connection_observer: ConnectionObserver
|
||||
) -> Result:
|
||||
"""
|
||||
Purpose:
|
||||
Gets the requirements for an assassin profile, which is returned as an object.
|
||||
Rank:
|
||||
Feature's King Orchestrator
|
||||
Called by:
|
||||
ProfileController
|
||||
"""
|
||||
if not profile:
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Requires a profile")
|
||||
|
||||
if not profile.assassin:
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Requires an assassin profile")
|
||||
|
||||
# We print for the Assassin to avoid logs.
|
||||
print(f"Begun creating Assassin for profile id {profile.id}")
|
||||
|
||||
# APPLICATION
|
||||
random_app = get_random_installed_app()
|
||||
print(f"random_app is {random_app}")
|
||||
if not random_app:
|
||||
return Result(valid=False, error_type=ResultError.MISSING_SOFTWARE, message="You need to install more browsers to have available choices for the Assassin. Please sync & download..")
|
||||
|
||||
# LOCATION
|
||||
systemwide_location = get_systemwide_location()
|
||||
filter_out_list = [systemwide_location]
|
||||
random_location = get_random_location(filter_out_list=filter_out_list)
|
||||
print(f"random_location is {random_location}")
|
||||
|
||||
if not random_app:
|
||||
return Result(valid=False, error_type=ResultError.NEED_SYNC, message="You need to sync to get location choices for the Assassin.")
|
||||
|
||||
# SCREEN SIZE:
|
||||
if not max_resolution:
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Could not create a screen size without a max_resolution")
|
||||
random_screen_size = pick_random_resolution(max_resolution)
|
||||
print(f"random_screen_size is {random_screen_size}")
|
||||
if not random_screen_size:
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"Could not create a screen size with the given inputs of {str(max_resolution)}")
|
||||
|
||||
# use underlying profile's connection:
|
||||
assassins_connection = profile.connection
|
||||
print(f"assassins_connection is {assassins_connection}")
|
||||
|
||||
# SETUP ASSASSIN:
|
||||
assassin = SessionProfile(
|
||||
id=profile.id,
|
||||
name="assassin_ON",
|
||||
type=ProfileType.SESSION,
|
||||
location=random_location,
|
||||
resolution=random_screen_size,
|
||||
application_version=random_app,
|
||||
connection=assassins_connection,
|
||||
subscription=None,
|
||||
assassin=True
|
||||
)
|
||||
|
||||
# DELETE the pre-existing WG config. Even though this is a redundant duplicate of the turn off function,
|
||||
# we don't know if assassin mode was added to a profile that already had a wg config for non-assassin use.
|
||||
profile.delete_wireguard_configuration()
|
||||
print("deleted the assassin's pre-existing wg config.")
|
||||
|
||||
# returns Result after using the sub:
|
||||
return use_ticket_get_sub(
|
||||
profile=assassin,
|
||||
random_location=random_location,
|
||||
connection_observer=connection_observer,
|
||||
ticket_observer=ticket_observer
|
||||
)
|
||||
|
||||
|
||||
|
||||
def use_ticket_get_sub(
|
||||
profile: SessionProfile,
|
||||
random_location: Location,
|
||||
connection_observer: ConnectionObserver,
|
||||
ticket_observer: TicketObserver
|
||||
) -> Result:
|
||||
|
||||
print("using a ticket for the assassin")
|
||||
|
||||
# GET TICKET FROM LOCAL FILES
|
||||
which_ticket = ticket_respawn.get_by_any_means(profile)
|
||||
print(f"assassin's which_ticket is {which_ticket}")
|
||||
if not which_ticket:
|
||||
raise MissingSubscriptionError
|
||||
|
||||
# USE TICKET
|
||||
print("using ticket..")
|
||||
ticket_result = use_ticket(
|
||||
which_ticket=which_ticket,
|
||||
which_location=random_location.id,
|
||||
ticket_observer=ticket_observer,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
|
||||
print(f"ticket_result is {ticket_result}")
|
||||
valid = ticket_result.get("valid", False)
|
||||
if not valid:
|
||||
return Result(valid=False, error_type=ResultError.INVALID_API_REPLY)
|
||||
|
||||
billing_code = ticket_result.get("billing_code", False)
|
||||
if not billing_code:
|
||||
return Result(valid=False, error_type=ResultError.INVALID_API_REPLY)
|
||||
|
||||
assassins_subscription = Subscription(billing_code=billing_code)
|
||||
profile.subscription = assassins_subscription
|
||||
profile.ticket = which_ticket
|
||||
profile.save()
|
||||
return Result(valid=True, data=profile)
|
||||
|
||||
|
||||
def wipe(
|
||||
profile: SessionProfile,
|
||||
ticket_observer: TicketObserver,
|
||||
connection_observer: ConnectionObserver
|
||||
) -> Result:
|
||||
|
||||
####################################
|
||||
# RESPAWN TICKET
|
||||
####################################
|
||||
print("respawning assassin")
|
||||
respawned = ticket_respawn.respawn_profile(
|
||||
profile=profile,
|
||||
ticket_observer=ticket_observer,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
|
||||
if not respawned:
|
||||
logger.error("Serious issue with respawning the ticket!")
|
||||
return Result(valid=False, error_type=ResultError.TICKET, message=f"Could NOT respawn the ticket for profile {profile.id}")
|
||||
|
||||
print("respawn worked, now saving..")
|
||||
profile.name = "assassin"
|
||||
profile.subscription = None
|
||||
profile.ticket = None
|
||||
profile.save()
|
||||
|
||||
# DELETE the pre-existing WG config:
|
||||
profile.delete_wireguard_configuration()
|
||||
print("deleted the pre-existing wg config")
|
||||
|
||||
return Result(valid=True)
|
||||
20
core/services/assassin/location_tools.py
Normal file
20
core/services/assassin/location_tools.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from core.controllers.SystemStateController import SystemStateController
|
||||
from core.models.BaseProfile import BaseProfile as Profile
|
||||
|
||||
def get_systemwide_location() -> int:
|
||||
current_state = SystemStateController.get()
|
||||
|
||||
if not current_state:
|
||||
return None
|
||||
|
||||
systemwide_id = current_state.profile_id
|
||||
|
||||
if not systemwide_id:
|
||||
return None
|
||||
|
||||
systemwide_profile = Profile.find_by_id(systemwide_id)
|
||||
|
||||
if not systemwide_profile:
|
||||
return None
|
||||
|
||||
return systemwide_profile
|
||||
21
core/services/assassin/screen_size.py
Normal file
21
core/services/assassin/screen_size.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from core.errors.logger import logger
|
||||
import random
|
||||
|
||||
def pick_random_resolution(max_resolution: str) -> str:
|
||||
if "x" in max_resolution:
|
||||
max_width, max_height = max_resolution.split("x")
|
||||
else:
|
||||
logger.error(f"Developer Error: Invalid format of max_resolution being {max_resolution}, going with the defaults")
|
||||
max_height = 800
|
||||
max_width = 800
|
||||
|
||||
min_resolution = 650
|
||||
|
||||
width = random.randint(min_resolution, int(max_width))
|
||||
height = random.randint(min_resolution, int(max_height))
|
||||
|
||||
final_resolution = f"{width}x{height}"
|
||||
|
||||
return final_resolution
|
||||
|
||||
|
||||
132
core/services/assassin/ticket_respawn.py
Normal file
132
core/services/assassin/ticket_respawn.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
from core.controllers.tickets.TicketPrepController import respawn_billing_code_into_ticket
|
||||
from core.services.prepare_tickets.ticket_tracker import find_ticket_by_sub
|
||||
from core.services.prepare_tickets.ticket_tracker import get_all_unused_tickets
|
||||
from core.models.session.SessionProfile import SessionProfile
|
||||
from core.models.system.SystemProfile import SystemProfile
|
||||
from core.observers.ConnectionObserver import ConnectionObserver
|
||||
from core.observers.TicketObserver import TicketObserver
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.errors.logger import logger
|
||||
|
||||
# generic
|
||||
from typing import Union
|
||||
import random
|
||||
|
||||
|
||||
# This is in the Assassin folder, but it can be used for anything (such as respawn on a systemwide non-assassin profile).
|
||||
# Why: We keep it in Assassin, to remind us to do print statements instead of log. Assassin is ephemeral.
|
||||
def respawn_profile(
|
||||
profile: Union[SessionProfile, SystemProfile],
|
||||
ticket_observer: TicketObserver = None,
|
||||
connection_observer: ConnectionObserver = None,
|
||||
) -> Result:
|
||||
"""
|
||||
Purpose:
|
||||
Voids a subscription for a profile and respawns the ticket for it.
|
||||
Requires:
|
||||
A profile. This is NOT just a ticket.
|
||||
Rank:
|
||||
Controller
|
||||
Called by:
|
||||
ProfileController.destroy
|
||||
"""
|
||||
which_ticket = profile.ticket # which slot is to be used for this.
|
||||
try:
|
||||
billing_code = profile.subscription.billing_code
|
||||
except Exception as e:
|
||||
logger.error(f"FAILED to get the billing code: {str(e)}")
|
||||
billing_code = None
|
||||
|
||||
# try via lookup:
|
||||
if not which_ticket:
|
||||
logger.info(f"Unable to find the ticket # in the profile's native config for {profile.id}. Checking the ticket tracker JSON")
|
||||
if not billing_code:
|
||||
return Result(valid=False, error_type=ResultError.SUBSCRIPTION, message="You lack a valid subscription on the profile slot is being respawned")
|
||||
which_ticket = find_ticket_by_sub(billing_code)
|
||||
|
||||
# Regardless of how it was acquired,
|
||||
if not which_ticket:
|
||||
return Result(valid=False, error_type=ResultError.TICKET, message="This profile is not associated with a ticket. And/or you lack a ticket slot to respawn.")
|
||||
|
||||
notification = f"Respawn Started for Ticket {which_ticket}"
|
||||
logger.info(notification)
|
||||
if ticket_observer is not None:
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
print(f"Sending to respawn_billing_code_into_ticket with billing_code: {billing_code} and ticket: {which_ticket}")
|
||||
respawn_result = respawn_billing_code_into_ticket(
|
||||
billing_code=billing_code,
|
||||
which_ticket=which_ticket,
|
||||
ticket_observer=ticket_observer,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
if not respawn_result.valid:
|
||||
notification = "Error with Ticket Respawn!"
|
||||
logger.error(f"{notification} {respawn_result.error_type} with message: {respawn_result.message}")
|
||||
if ticket_observer is not None:
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
return respawn_result
|
||||
|
||||
logger.info(f"We have a valid ticket respawn, but not yet dealt with profile {profile.id}'s billing id..")
|
||||
notification = "Ticket Respawned!"
|
||||
logger.info(notification)
|
||||
if ticket_observer is not None:
|
||||
ticket_observer.notify("preparing", subject=notification)
|
||||
|
||||
# Now that the billing code has been wiped by the server, wipe it locally:
|
||||
print("We are now wiping the billing code for this profile.")
|
||||
profile.subscription = None
|
||||
|
||||
# save the ticket,
|
||||
profile.ticket = which_ticket
|
||||
profile.save()
|
||||
return Result(valid=True, message="This profile fully respawned and had it's billing code wiped.")
|
||||
|
||||
|
||||
|
||||
def get_by_any_means(profile: SessionProfile) -> int | None:
|
||||
"""
|
||||
Purpose:
|
||||
Get the Ticket for a Profile by any means.
|
||||
1) Try the profile itself
|
||||
2) Search the ticket tracker
|
||||
3) See if ANY unused tickets are available
|
||||
Rank:
|
||||
Mini-Orchestrator
|
||||
Called by:
|
||||
respawn_profile
|
||||
"""
|
||||
which_ticket = None
|
||||
|
||||
# Ticket tied to profile?
|
||||
if profile:
|
||||
which_ticket = profile.ticket
|
||||
if which_ticket:
|
||||
print(f"we found a ticket for this profile of {which_ticket}")
|
||||
return which_ticket
|
||||
|
||||
# Ticket used with it?
|
||||
if profile.subscription:
|
||||
target_subscription = profile.subscription.billing_code
|
||||
which_ticket = find_ticket_by_sub(target_subscription)
|
||||
if which_ticket:
|
||||
return which_ticket
|
||||
else:
|
||||
print("skipping there is no sub yet.")
|
||||
|
||||
# Any available ticket?
|
||||
print("checking ANY available tickets.")
|
||||
availablity_dict = get_all_unused_tickets()
|
||||
valid = availablity_dict.get("valid", False)
|
||||
|
||||
if not valid:
|
||||
return False
|
||||
|
||||
unused_tickets = availablity_dict.get("data", False)
|
||||
print(f"all unused_tickets is {unused_tickets}")
|
||||
if not unused_tickets or len(unused_tickets) == 0:
|
||||
return False
|
||||
|
||||
print("picking a random ticket")
|
||||
which_ticket = random.choice(unused_tickets)
|
||||
return which_ticket
|
||||
|
||||
|
|
@ -15,13 +15,19 @@ from py_ecc.optimized_bls12_381 import (
|
|||
)
|
||||
from py_ecc.optimized_bls12_381.optimized_curve import FQ, FQ2
|
||||
|
||||
# for the (optional) validity tests:
|
||||
from py_ecc.optimized_bls12_381 import pairing
|
||||
|
||||
# errors:
|
||||
from core.errors.logger import logger
|
||||
import traceback
|
||||
|
||||
# for the (optional) validity tests:
|
||||
try:
|
||||
from core.services.crypto.cython.bls12_381_pairing import pairing
|
||||
logger.info("Imported Cython pairings")
|
||||
except ImportError:
|
||||
# Fallback to py_ecc if not compiled
|
||||
from py_ecc.optimized_bls12_381 import pairing
|
||||
logger.error("FAILED to import cython pairings, used python py_ecc")
|
||||
|
||||
|
||||
class TicketCustomer:
|
||||
"""
|
||||
|
|
@ -206,6 +212,7 @@ class TicketCustomer:
|
|||
blinded_signature = self._deserialize_point_g2(blind_signature)
|
||||
|
||||
blinded_commitment_as_dict = get_data(which_ticket, "blinded_commitment_json")
|
||||
|
||||
blinded_commitment = self._deserialize_point_g2(blinded_commitment_as_dict)
|
||||
|
||||
projective_public_key = self.load_key(string_public_key)
|
||||
|
|
@ -214,6 +221,7 @@ class TicketCustomer:
|
|||
return {"valid": False, "message": "invalid_key"}
|
||||
|
||||
# All of that was to prep the values for this pairing equation,
|
||||
# Which now uses Cython
|
||||
try:
|
||||
if pairing(blinded_signature, G1) == pairing(
|
||||
blinded_commitment, projective_public_key
|
||||
|
|
|
|||
22
core/services/crypto/cython/README.md
Normal file
22
core/services/crypto/cython/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Overview
|
||||
We use Cython to compile certain cryptographic operations to C, to make it faster. This is the situation with miller's loop from py_ecc.
|
||||
|
||||
# Pre-reqs:
|
||||
In the same venv as the project itself:
|
||||
```bash
|
||||
pip install cython
|
||||
pip install setuptools
|
||||
```
|
||||
<br/>
|
||||
|
||||
# Setup Compile
|
||||
```bash
|
||||
python3 setup.py build_ext --inplace
|
||||
```
|
||||
<br/>
|
||||
|
||||
# If any issues
|
||||
Try the exact path to your venv.
|
||||
```bash
|
||||
path/to/your/venv/python3 setup.py build_ext --inplace
|
||||
```
|
||||
125
core/services/crypto/cython/bls12_381_pairing.pyx
Normal file
125
core/services/crypto/cython/bls12_381_pairing.pyx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from py_ecc.fields import (
|
||||
bls12_381_FQ as FQ,
|
||||
bls12_381_FQ2 as FQ2,
|
||||
bls12_381_FQ12 as FQ12
|
||||
)
|
||||
from py_ecc.fields.field_properties import (
|
||||
field_properties,
|
||||
)
|
||||
from py_ecc.typing import (
|
||||
Field,
|
||||
Point2D,
|
||||
)
|
||||
|
||||
from py_ecc.bls12_381 import (
|
||||
G1,
|
||||
add,
|
||||
b,
|
||||
b2,
|
||||
curve_order,
|
||||
double,
|
||||
is_on_curve,
|
||||
multiply,
|
||||
twist,
|
||||
)
|
||||
|
||||
field_modulus = field_properties["bls12_381"]["field_modulus"]
|
||||
|
||||
ate_loop_count = 15132376222941642752
|
||||
log_ate_loop_count = 62
|
||||
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
from py_ecc.fields import bls12_381_FQ as FQ_py_ecc
|
||||
from py_ecc.utils import prime_field_inv
|
||||
|
||||
def linefunc(
|
||||
P1: Tuple[Field, Field],
|
||||
P2: Tuple[Field, Field],
|
||||
T: Tuple[Field, Field]
|
||||
) -> Field:
|
||||
"""
|
||||
Create a function representing the line between P1 and P2,
|
||||
and evaluate it at T
|
||||
"""
|
||||
if P1 is None or P2 is None or T is None:
|
||||
raise ValueError("Invalid input - no points-at-infinity allowed")
|
||||
|
||||
x1, y1 = P1
|
||||
x2, y2 = P2
|
||||
xt, yt = T
|
||||
|
||||
if x1 != x2:
|
||||
m = (y2 - y1) / (x2 - x1)
|
||||
return m * (xt - x1) - (yt - y1)
|
||||
elif y1 == y2:
|
||||
m = 3 * x1**2 / (2 * y1)
|
||||
return m * (xt - x1) - (yt - y1)
|
||||
else:
|
||||
return xt - x1
|
||||
|
||||
|
||||
def cast_point_to_fq12(
|
||||
pt: Optional[Tuple[FQ, FQ]]
|
||||
) -> Optional[Tuple[FQ12, FQ12]]:
|
||||
if pt is None:
|
||||
return None
|
||||
|
||||
x, y = pt
|
||||
return (FQ12([x.n] + [0] * 11), FQ12([y.n] + [0] * 11))
|
||||
|
||||
|
||||
# Check consistency of the "line function"
|
||||
one = G1
|
||||
two = double(G1)
|
||||
three = multiply(G1, 3)
|
||||
negone = multiply(G1, curve_order - 1)
|
||||
negtwo = multiply(G1, curve_order - 2)
|
||||
negthree = multiply(G1, curve_order - 3)
|
||||
|
||||
conditions = [
|
||||
linefunc(one, two, one) == FQ(0),
|
||||
linefunc(one, two, two) == FQ(0),
|
||||
linefunc(one, two, three) != FQ(0),
|
||||
linefunc(one, two, negthree) == FQ(0),
|
||||
linefunc(one, negone, one) == FQ(0),
|
||||
linefunc(one, negone, negone) == FQ(0),
|
||||
linefunc(one, negone, two) != FQ(0),
|
||||
linefunc(one, one, one) == FQ(0),
|
||||
linefunc(one, one, two) != FQ(0),
|
||||
linefunc(one, one, negtwo) == FQ(0),
|
||||
]
|
||||
|
||||
if not all(conditions):
|
||||
raise ValueError("Line function is inconsistent")
|
||||
|
||||
|
||||
def miller_loop(Q: Point2D[FQ12], P: Point2D[FQ12]) -> FQ12:
|
||||
cdef int i
|
||||
|
||||
if Q is None or P is None:
|
||||
return FQ12.one()
|
||||
|
||||
R = Q
|
||||
f = FQ12.one()
|
||||
|
||||
for i in range(log_ate_loop_count, -1, -1):
|
||||
f = f * f * linefunc(R, R, P)
|
||||
R = double(R)
|
||||
if ate_loop_count & (2**i):
|
||||
f = f * linefunc(R, Q, P)
|
||||
R = add(R, Q)
|
||||
|
||||
return f ** ((field_modulus**12 - 1) // curve_order)
|
||||
|
||||
|
||||
def pairing(Q: Point2D[FQ2], P: Point2D[FQ]) -> FQ12:
|
||||
if not is_on_curve(Q, b2):
|
||||
raise ValueError("Invalid input - point Q is not on the correct curve")
|
||||
if not is_on_curve(P, b):
|
||||
raise ValueError("Invalid input - point P is not on the correct curves")
|
||||
return miller_loop(twist(Q), cast_point_to_fq12(P))
|
||||
|
||||
|
||||
def final_exponentiate(p: FQ12) -> FQ12:
|
||||
return p ** ((field_modulus**12 - 1) // curve_order)
|
||||
19
core/services/crypto/cython/setup.py
Normal file
19
core/services/crypto/cython/setup.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
"""
|
||||
This is the Manual Setup for Cython extensions.
|
||||
|
||||
Compile with:
|
||||
pip install cython setuptools
|
||||
python3 setup.py build_ext --inplace
|
||||
|
||||
& Put the path to venv prior to the python3 if any issues.
|
||||
path/to/venv/python3 setup.py build_ext --inplace
|
||||
"""
|
||||
|
||||
from Cython.Build import cythonize
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
ext_modules=cythonize("bls12_381_pairing.pyx", language_level=3)
|
||||
)
|
||||
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
from core.services.crypto.TicketCustomer import TicketCustomer
|
||||
from core.errors.logger import logger
|
||||
|
||||
from typing import Optional
|
||||
|
||||
"""
|
||||
We are making both an unblinded and blinded commitment pair.
|
||||
|
|
@ -15,12 +18,14 @@ def make_ONE_commitment_pair(
|
|||
|
||||
# First, make the original unblinded commitment. it's saved inside the profile object:
|
||||
did_unblinded_save = profile_object.make_unblinded_commitment(which_ticket)
|
||||
logger.info(f"did_unblinded_save {did_unblinded_save}")
|
||||
|
||||
# that `profile_object` object is holding the unblinded commitment,
|
||||
# so it can be directly used to blind it (without having to serialize then deserialize it).
|
||||
|
||||
# Then BLIND it, so it can be sent to the billing server:
|
||||
blind_commitment = profile_object.blind_commitment(which_ticket)
|
||||
# logger.info(f"blind_commitment is {blind_commitment}")
|
||||
|
||||
# we need to make sure we actually saved the data,
|
||||
# because it's the only way to unblind it later:
|
||||
|
|
@ -33,15 +38,30 @@ def make_ONE_commitment_pair(
|
|||
return blind_commitment
|
||||
|
||||
|
||||
def make_ALL_commitments(how_many_profiles_to_make: int) -> list | None:
|
||||
def make_ALL_commitments(how_many_profiles_to_make: int, which_ticket: Optional[int] = None) -> list | None:
|
||||
# Setup the entire class object of "profile_object" for using all these other functions,
|
||||
profile_object = TicketCustomer()
|
||||
|
||||
# setup loop:
|
||||
which_ticket = 0
|
||||
list_of_all_blinded_data = []
|
||||
failed_to_save = []
|
||||
|
||||
##########################################################
|
||||
# SINGLE TICKET PREP (respawn flow)
|
||||
##########################################################
|
||||
if which_ticket and how_many_profiles_to_make == 1:
|
||||
logger.info("Doing a single ticket prep.")
|
||||
blinded_string = make_ONE_commitment_pair(profile_object, which_ticket)
|
||||
profile_object.reset()
|
||||
list_of_all_blinded_data.append(blinded_string)
|
||||
return list_of_all_blinded_data
|
||||
|
||||
##########################################################
|
||||
# BULK TICKET PREP (regular flow)
|
||||
##########################################################
|
||||
logger.info(f"Doing a regular bulk ticket prep for {how_many_profiles_to_make} tickets")
|
||||
which_ticket = 0
|
||||
|
||||
# loop up to the number of profiles requested:
|
||||
while which_ticket < how_many_profiles_to_make:
|
||||
which_ticket = which_ticket + 1
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
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"
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
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, Constants.SINGBOX_INTERNAL_SUBNET):
|
||||
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 _force_kill_singbox() -> None:
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
for pid in result.stdout.strip().splitlines():
|
||||
subprocess.run(
|
||||
['sudo', 'kill', '-9', pid.strip()],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def disable_hysteria(observer=None) -> bool:
|
||||
try:
|
||||
revert_dns_on_tun()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
SingboxRunner().stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import subprocess, time
|
||||
time.sleep(0.5)
|
||||
result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
_force_kill_singbox()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
killswitch.disarm()
|
||||
except Exception:
|
||||
pass
|
||||
if observer:
|
||||
observer.notify("disconnected", {"tunnel_if": Constants.SINGBOX_TUN_IF})
|
||||
return True
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
from core.errors.logger import logger
|
||||
from core.utils.encrypted_proxy import killswitch
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionStateData:
|
||||
tunnel_interface: str = "?"
|
||||
server_ip: Optional[str] = None
|
||||
socks5_port: str = "?"
|
||||
dns_enabled: bool = True
|
||||
dns_active: bool = False
|
||||
session_token: Optional[str] = None
|
||||
|
||||
|
||||
def create_ui_state() -> Dict[str, Any]:
|
||||
return {
|
||||
'spinner': None,
|
||||
'monitor': None,
|
||||
'timer_state': None,
|
||||
'connection_data': ConnectionStateData(),
|
||||
}
|
||||
|
||||
|
||||
# ── Cleanup helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def cleanup_spinner(state: Dict[str, Any]) -> None:
|
||||
if state['spinner']:
|
||||
try:
|
||||
state['spinner'].stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop spinner: {e}")
|
||||
finally:
|
||||
state['spinner'] = None
|
||||
|
||||
|
||||
def cleanup_monitor(state: Dict[str, Any]) -> None:
|
||||
if state['monitor'] is not None:
|
||||
try:
|
||||
state['monitor'].stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop monitor: {e}")
|
||||
finally:
|
||||
state['monitor'] = None
|
||||
|
||||
|
||||
def cleanup_timer(state: Dict[str, Any]) -> None:
|
||||
if state['timer_state'] and state['timer_state'].get('stop'):
|
||||
try:
|
||||
state['timer_state']['stop'].set()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop timer: {e}")
|
||||
finally:
|
||||
if state['timer_state']:
|
||||
state['timer_state']['stop'] = None
|
||||
|
||||
|
||||
def cleanup_reconnecting_spinner(state: Dict[str, Any]) -> None:
|
||||
if state['timer_state'] and state['timer_state'].get('reconnecting'):
|
||||
try:
|
||||
state['timer_state']['reconnecting'].set()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop reconnecting spinner: {e}")
|
||||
finally:
|
||||
if state['timer_state']:
|
||||
state['timer_state']['reconnecting'] = None
|
||||
|
||||
|
||||
def cleanup_all(state: Dict[str, Any]) -> None:
|
||||
cleanup_spinner(state)
|
||||
cleanup_timer(state)
|
||||
cleanup_reconnecting_spinner(state)
|
||||
cleanup_monitor(state)
|
||||
|
||||
|
||||
# ── Handler factory ───────────────────────────────────────────────────────────
|
||||
|
||||
def create_event_handlers(state: Dict[str, Any], drop_state: Dict, tunnel_state: Dict):
|
||||
"""Factory: returns event handler closures bound to state."""
|
||||
|
||||
def _update_connection_data(event) -> ConnectionStateData:
|
||||
"""Extract connection fields from event subject into a ConnectionStateData."""
|
||||
d = event.subject or {}
|
||||
return ConnectionStateData(
|
||||
tunnel_interface=d.get('tunnel_if', "?"),
|
||||
server_ip=d.get('server_ip'),
|
||||
socks5_port=d.get('socks5_port', "?"),
|
||||
dns_enabled=d.get('dns_enabled', True),
|
||||
dns_active=d.get('dns_active', False),
|
||||
)
|
||||
|
||||
def _update_ui_labels(conn: ConnectionStateData) -> None:
|
||||
"""Refresh all CLI status labels from current connection data."""
|
||||
from cli.ui import (
|
||||
label_connected, label_killswitch, label_dns, label_ipv6, label_ipv4
|
||||
)
|
||||
label_connected(f"tunnel={conn.tunnel_interface} port={conn.socks5_port}")
|
||||
label_killswitch(killswitch.status())
|
||||
label_dns(enabled=conn.dns_enabled, active=conn.dns_active)
|
||||
label_ipv6(blocked=True)
|
||||
if conn.server_ip:
|
||||
label_ipv4(server_ip=conn.server_ip, reachable=True)
|
||||
|
||||
def _update_monitor_port(event_data: Dict) -> None:
|
||||
"""Update monitor with new SOCKS5 port."""
|
||||
if state['monitor'] is not None:
|
||||
state['monitor'].set_connection_data(socks5_port=event_data.get('socks5_port'))
|
||||
|
||||
def on_connecting(event) -> None:
|
||||
"""Handle connecting event: show attempt spinner."""
|
||||
try:
|
||||
cleanup_timer(state)
|
||||
if (state['timer_state'] and
|
||||
state['timer_state'].get('reconnecting') is None and
|
||||
state['timer_state'].get('_retrying')):
|
||||
from cli.ui import connecting_spinner
|
||||
state['timer_state']['reconnecting'] = connecting_spinner()
|
||||
|
||||
d = event.subject or {}
|
||||
attempt = d.get("attempt_count", "?")
|
||||
total = d.get("maximum_number_of_attempts", "?")
|
||||
|
||||
cleanup_spinner(state)
|
||||
from cli.ui import Spinner
|
||||
state['spinner'] = Spinner(f"Connecting... attempt {attempt}/{total}")
|
||||
state['spinner'].start()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_connecting: {e}", exc_info=True)
|
||||
|
||||
def on_connected(event) -> None:
|
||||
"""Handle connected event: update labels, monitor port, and start session timer."""
|
||||
try:
|
||||
cleanup_spinner(state)
|
||||
cleanup_reconnecting_spinner(state)
|
||||
|
||||
if state['timer_state']:
|
||||
state['timer_state']['_retrying'] = False
|
||||
|
||||
state['connection_data'] = _update_connection_data(event)
|
||||
_update_ui_labels(state['connection_data'])
|
||||
|
||||
event_data = event.subject or {}
|
||||
_update_monitor_port(event_data)
|
||||
|
||||
if state['timer_state'] is not None:
|
||||
from cli.ui import session_timer
|
||||
state['timer_state']['stop'] = session_timer(
|
||||
drop_state=drop_state,
|
||||
tunnel_state=tunnel_state,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_connected: {e}", exc_info=True)
|
||||
|
||||
def on_connected_token(event) -> None:
|
||||
"""Handle session token event: pass token to monitor."""
|
||||
try:
|
||||
if state['monitor'] is not None:
|
||||
token = (event.subject or {}).get('session_token')
|
||||
if token:
|
||||
state['monitor'].set_session_token(token)
|
||||
state['connection_data'].session_token = token
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_connected_token: {e}", exc_info=True)
|
||||
|
||||
def on_disconnected(event) -> None:
|
||||
"""Handle disconnected event: cleanup UI and show disconnect label."""
|
||||
try:
|
||||
cleanup_all(state)
|
||||
from cli.ui import label_disconnect
|
||||
tunnel_if = (event.subject or {}).get('tunnel_if', '?')
|
||||
label_disconnect(f"tunnel={tunnel_if}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_disconnected: {e}", exc_info=True)
|
||||
|
||||
def on_error(event) -> None:
|
||||
"""Handle error event: cleanup UI and show error label."""
|
||||
try:
|
||||
cleanup_all(state)
|
||||
from cli.ui import label_error
|
||||
msg = event.subject
|
||||
if isinstance(msg, dict):
|
||||
msg = msg.get('message', str(msg))
|
||||
label_error(str(msg or "Unknown error"))
|
||||
logger.error(f"Connection error: {msg}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_error: {e}", exc_info=True)
|
||||
|
||||
return {
|
||||
'on_connecting': on_connecting,
|
||||
'on_connected': on_connected,
|
||||
'on_connected_token': on_connected_token,
|
||||
'on_disconnected': on_disconnected,
|
||||
'on_error': on_error,
|
||||
}
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
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, Constants.SINGBOX_INTERNAL_SUBNET):
|
||||
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 _force_kill_singbox() -> None:
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
for pid in result.stdout.strip().splitlines():
|
||||
subprocess.run(
|
||||
['sudo', 'kill', '-9', pid.strip()],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def disable_vless(observer=None) -> bool:
|
||||
try:
|
||||
revert_dns_on_tun()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
SingboxRunner().stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import subprocess, time
|
||||
time.sleep(0.5)
|
||||
result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
_force_kill_singbox()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
killswitch.disarm()
|
||||
except Exception:
|
||||
pass
|
||||
if observer:
|
||||
observer.notify("disconnected", {"tunnel_if": Constants.SINGBOX_TUN_IF})
|
||||
return True
|
||||
|
|
@ -11,7 +11,10 @@ from core.services.prepare_tickets.get_public_key_by_config import get_public_ke
|
|||
from core.services.prepare_tickets.get_pub_key import key_is_in_valid_format, get_pub_key
|
||||
from core.services.failed_verification.test_if_new_key_works import test_if_new_key_works
|
||||
from core.services.networking.make_url import make_url
|
||||
from core.services.networking.get_data_from_server import get_data_from_server
|
||||
|
||||
from core.services.networking.httpx import connect
|
||||
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||
|
||||
# utils
|
||||
from core.utils.basic_operations.write_or_read_from_json import get_value_from_json_file
|
||||
from core.utils.basic_operations.write_string_to_text_file import write_string_to_text_file
|
||||
|
|
@ -70,12 +73,21 @@ def get_new_pubkey_from_api(connection_observer: ConnectionObserver) -> dict | N
|
|||
url = make_url(which_key_plan)
|
||||
|
||||
# the result of this is a python dictionary with single '
|
||||
api_results = get_data_from_server(url, connection_observer)
|
||||
# api_results = get_data_from_server(url, None, connection_observer)
|
||||
api_results = connect.single_endpoint(
|
||||
method="get",
|
||||
url=url,
|
||||
observer=connection_observer,
|
||||
payload=None
|
||||
)
|
||||
|
||||
if "data" in api_results:
|
||||
new_public_key = api_results["data"]
|
||||
if api_results.valid:
|
||||
new_public_key = api_results.data
|
||||
return new_public_key
|
||||
else:
|
||||
logger.error(f"API Results returned were invalid for that key.")
|
||||
return None
|
||||
|
||||
return new_public_key
|
||||
|
||||
|
||||
def are_keys_different(old_public_key, new_public_key) -> bool:
|
||||
|
|
|
|||
58
core/services/helpers/assets_as_strings/sudo_scripts/dns.py
Normal file
58
core/services/helpers/assets_as_strings/sudo_scripts/dns.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
dns_script = """#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "[killswitch] ERROR: must be run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTION="${1:-}"
|
||||
IFACE="${2:-}"
|
||||
DNS="${3:-}"
|
||||
|
||||
|
||||
_check_prereqs() {
|
||||
[[ -z "$DNS" ]] && { echo "Error: DNS IP required" >&2; exit 1; }
|
||||
|
||||
# ✓ Check it's a valid IPv4 address:
|
||||
if ! [[ "$DNS" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
|
||||
echo "Error: Invalid IP: $DNS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ✓ checks interface exists before configuring
|
||||
if ! ip link show "$IFACE" &>/dev/null; then
|
||||
echo "Error: Interface $IFACE not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
_set_dns() {
|
||||
resolvectl dns "$IFACE" "$DNS" || {
|
||||
echo "ERROR: Failed to set DNS to $DNS" >&2
|
||||
return 1
|
||||
}
|
||||
resolvectl domain "$IFACE" '~.' || {
|
||||
echo "ERROR: Failed to set domain routing" >&2
|
||||
return 1
|
||||
}
|
||||
resolvectl default-route "$IFACE" true || {
|
||||
echo "ERROR: Failed to set default route" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
if [[ "$ACTION" == "set" ]]; then
|
||||
_check_prereqs
|
||||
_set_dns
|
||||
fi
|
||||
|
||||
# if [[ "$ACTION" == "revert" ]]; then
|
||||
# resolvectl revert "$IFACE" 2>/dev/null || true
|
||||
|
||||
# fi
|
||||
|
||||
if [[ "$ACTION" == "revert" ]]; then
|
||||
resolvectl revert "$IFACE" || true
|
||||
fi
|
||||
"""
|
||||
117
core/services/helpers/assets_as_strings/sudo_scripts/firewall.py
Normal file
117
core/services/helpers/assets_as_strings/sudo_scripts/firewall.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
firewall_script = """#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "[killswitch] ERROR: must be run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTION="${1:-}"
|
||||
SERVER_IP="${2:-}"
|
||||
TUNNEL_IF="${3:-}"
|
||||
INTERNAL_SUBNET="${4:-}"
|
||||
TABLE="hydraveil"
|
||||
|
||||
if [[ "$ACTION" != "arm" && "$ACTION" != "disarm" && "$ACTION" != "status" ]]; then
|
||||
echo "[killswitch] ERROR: invalid action '$ACTION'. Usage: arm <server_ip> <tunnel_if> [internal_subnet] | disarm | status" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
_default_iface() {
|
||||
ip route show default 2>/dev/null | awk 'NR==1{print $5}'
|
||||
}
|
||||
|
||||
_log() {
|
||||
local level="$1"; shift
|
||||
echo "[killswitch] [$level] $* — $(date '+%Y-%m-%d %H:%M:%S')" >&2
|
||||
}
|
||||
|
||||
if [[ "$ACTION" == "status" ]]; then
|
||||
if nft list table inet "$TABLE" &>/dev/null 2>&1; then
|
||||
echo "armed"
|
||||
else
|
||||
echo "disarmed"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$ACTION" == "disarm" ]]; then
|
||||
if nft list table inet "$TABLE" &>/dev/null 2>&1; then
|
||||
nft delete table inet "$TABLE"
|
||||
_log "INFO" "disarmed"
|
||||
else
|
||||
_log "INFO" "already disarmed"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -z "$SERVER_IP" ]] && { _log "ERROR" "server_ip required for arm"; exit 1; }
|
||||
[[ -z "$TUNNEL_IF" ]] && { _log "ERROR" "tunnel_if required for arm"; exit 1; }
|
||||
|
||||
if ! [[ "$SERVER_IP" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
|
||||
_log "ERROR" "invalid IPv4: $SERVER_IP"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IFS='.' read -r o1 o2 o3 o4 <<< "$SERVER_IP"
|
||||
for oct in "$o1" "$o2" "$o3" "$o4"; do
|
||||
if (( oct > 255 )); then
|
||||
_log "ERROR" "invalid IPv4 octet ($oct) in $SERVER_IP"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
WAN_IFACE=$(_default_iface)
|
||||
if [[ -z "$WAN_IFACE" ]]; then
|
||||
_log "ERROR" "could not detect primary network interface"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INTERNAL_RULE=""
|
||||
if [[ -n "$INTERNAL_SUBNET" ]]; then
|
||||
INTERNAL_RULE=" ip daddr ${INTERNAL_SUBNET} accept"
|
||||
fi
|
||||
|
||||
nft list table inet "$TABLE" &>/dev/null 2>&1 && nft delete table inet "$TABLE" || true
|
||||
|
||||
nft -f - << NFTEOF
|
||||
table inet ${TABLE} {
|
||||
chain output {
|
||||
type filter hook output priority filter; policy drop;
|
||||
oifname "lo" accept
|
||||
ether type arp drop
|
||||
ip6 daddr != ::1 drop
|
||||
ip daddr 224.0.0.0/4 drop
|
||||
ip daddr 255.255.255.255 drop
|
||||
ip daddr 192.168.1.0/24 drop
|
||||
|
||||
oifname "${WAN_IFACE}" ip daddr ${SERVER_IP} accept
|
||||
oifname "${TUNNEL_IF}" accept
|
||||
log prefix "hydraveil-drop " drop
|
||||
}
|
||||
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
iifname "lo" accept
|
||||
|
||||
ether type arp drop
|
||||
ip6 saddr != ::1 drop
|
||||
ip daddr 224.0.0.0/4 drop
|
||||
ip daddr 255.255.255.255 drop
|
||||
|
||||
ct state established,related accept
|
||||
iifname "${WAN_IFACE}" ip saddr ${SERVER_IP} accept
|
||||
iifname "${TUNNEL_IF}" accept
|
||||
|
||||
log prefix "hydraveil-drop " drop
|
||||
}
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
}
|
||||
}
|
||||
NFTEOF
|
||||
|
||||
|
||||
_log "INFO" "armed — wan=${WAN_IFACE} server=${SERVER_IP} tunnel=${TUNNEL_IF} internal=${INTERNAL_SUBNET:-none}"
|
||||
exit 0
|
||||
"""
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
setup_script = """
|
||||
#!/bin/bash
|
||||
|
||||
ORIGINAL_FOLDER="${ORIGINAL_FOLDER:-$(pwd)}"
|
||||
TARGET_FOLDER="${TARGET_FOLDER:-/opt/hydra-veil}"
|
||||
LINUX_USER="${LINUX_USER:-$SUDO_USER}"
|
||||
|
||||
add_sudoers_rule() {
|
||||
sudo tee /etc/sudoers.d/zzzzzzzzzzzzzzzzzzz > /dev/null <<EOF
|
||||
${LINUX_USER} ALL=(root) NOPASSWD: /opt/hydra-veil/firewall
|
||||
${LINUX_USER} ALL=(root) NOPASSWD: /opt/hydra-veil/dns
|
||||
EOF
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Error: This script must be run as root (use sudo)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate environment variables
|
||||
if [[ -z "$ORIGINAL_FOLDER" ]]; then
|
||||
echo "Error: ORIGINAL_FOLDER environment variable not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$TARGET_FOLDER" ]]; then
|
||||
echo "Error: TARGET_FOLDER environment variable not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate that source folder exists
|
||||
if [[ ! -d "$ORIGINAL_FOLDER" ]]; then
|
||||
echo "Error: ORIGINAL_FOLDER does not exist: $ORIGINAL_FOLDER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create target folder if it doesn't exist
|
||||
if [[ ! -d "$TARGET_FOLDER" ]]; then
|
||||
echo "Creating target folder: $TARGET_FOLDER"
|
||||
mkdir -p "$TARGET_FOLDER"
|
||||
fi
|
||||
|
||||
# Get the setup script's own filename, because we'll exclude it from being copied,
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
echo "Starting file copy from $ORIGINAL_FOLDER to $TARGET_FOLDER"
|
||||
echo "Script name to exclude: $SCRIPT_NAME"
|
||||
echo ""
|
||||
|
||||
# Counter for tracking progress
|
||||
COPIED=0
|
||||
SKIPPED=0
|
||||
|
||||
# Loop through all files in source folder
|
||||
while IFS= read -r -d '' file; do
|
||||
filename="$(basename "$file")"
|
||||
|
||||
# Skip if this is the script itself
|
||||
if [[ "$filename" == "$SCRIPT_NAME" ]]; then
|
||||
echo "⊘ Skipping: $filename (this script)"
|
||||
((SKIPPED++))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Copy the file
|
||||
if cp "$file" "$TARGET_FOLDER/"; then
|
||||
echo "✓ Copied: $filename"
|
||||
((COPIED++))
|
||||
|
||||
# Set permissions to 755
|
||||
if chmod 755 "$TARGET_FOLDER/$filename"; then
|
||||
echo " └─ chmod 755 applied"
|
||||
else
|
||||
echo " └─ WARNING: chmod 755 failed for $filename" >&2
|
||||
fi
|
||||
else
|
||||
echo "✗ ERROR: Failed to copy $filename" >&2
|
||||
((SKIPPED++))
|
||||
fi
|
||||
|
||||
done < <(find "$ORIGINAL_FOLDER" -maxdepth 1 -type f -print0)
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Copy complete!"
|
||||
echo "Files copied: $COPIED"
|
||||
echo "Files skipped: $SKIPPED"
|
||||
echo "Target folder: $TARGET_FOLDER"
|
||||
echo "=========================================="
|
||||
|
||||
# Try to apply the rules, if not emit 1
|
||||
if add_sudoers_rule; then
|
||||
echo "Operation successful"
|
||||
else
|
||||
echo "Operation failed - sudoers rule not applied"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
"""
|
||||
107
core/services/helpers/download_file.py
Normal file
107
core/services/helpers/download_file.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from core.services.networking.httpx import httpx_client
|
||||
from core.services.networking.httpx import connect
|
||||
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.errors.logger import logger
|
||||
from core.controllers.ConfigurationController import ConfigurationController
|
||||
from core.models.Configuration import Configuration, ConnectionChoice
|
||||
from core.observers.ApplicationVersionObserver import ApplicationVersionObserver
|
||||
from core.observers.ConnectionObserver import ConnectionObserver
|
||||
|
||||
import httpx
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
import hashlib
|
||||
|
||||
|
||||
def download_file_and_verify(
|
||||
target_app_name: str,
|
||||
download_path: str,
|
||||
target_file_hash: str,
|
||||
application_version_observer: Optional[ApplicationVersionObserver] = None,
|
||||
target_app_version: Optional[str] = None,
|
||||
connection_observer: Optional[ConnectionObserver] = None
|
||||
) -> Result:
|
||||
"""
|
||||
Download/stream a file, and return the BytesIO buffer
|
||||
"""
|
||||
################################################
|
||||
# SETUP HTTP CLIENT
|
||||
################################################
|
||||
client = httpx_client.get_http_session()
|
||||
if client is None:
|
||||
client = _get_httpx_client(target_app_name=target_app_name, connection_observer=connection_observer)
|
||||
|
||||
################################################
|
||||
# GET THE DATA
|
||||
################################################
|
||||
with client.stream('GET', download_path) as response:
|
||||
if response.status_code == 200:
|
||||
response_size = int(response.headers.get('Content-Length', 0))
|
||||
response_buffer = BytesIO()
|
||||
|
||||
block_size = 1024
|
||||
bytes_written = 0
|
||||
for data in response.iter_bytes(block_size):
|
||||
|
||||
bytes_written += len(data)
|
||||
response_buffer.write(data)
|
||||
progress = (bytes_written / response_size) * 100 if response_size > 0 else 0
|
||||
|
||||
if application_version_observer is not None:
|
||||
if target_app_version:
|
||||
application_version_observer.notify('download_progressing', f"Downloading {target_app_name} {progress:.2f}% v: {target_app_version}")
|
||||
else:
|
||||
application_version_observer.notify('download_progressing', f"Downloading {target_app_name} {progress:.2f}%")
|
||||
else:
|
||||
error_msg = f"Could not download {target_app_name} because of a Connection Error."
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, error_type=ResultError.CONNECTION, message=error_msg)
|
||||
|
||||
if application_version_observer is not None:
|
||||
application_version_observer.notify('downloaded', f"Downloaded {target_app_name}")
|
||||
response_buffer.seek(0)
|
||||
|
||||
################################################
|
||||
# VERIFY THE HASH
|
||||
################################################
|
||||
real_file_hash = __calculate_file_hash(response_buffer)
|
||||
|
||||
if real_file_hash != target_file_hash:
|
||||
error_msg = f'Application version file integrity could not be verified. We are targeting {target_file_hash}, but got {real_file_hash}'
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||
else:
|
||||
return Result(valid=True, data=response_buffer, message=real_file_hash)
|
||||
|
||||
|
||||
def _get_httpx_client(target_app_name: str, connection_observer: Optional[ConnectionObserver]) -> httpx.Client:
|
||||
connection_type = ConfigurationController.get_connection_enum()
|
||||
made_client = connect.make_client(connection_type, connection_observer) # always gets boolean
|
||||
|
||||
if not made_client:
|
||||
if connection_type == ConnectionChoice.SYSTEM:
|
||||
raise ConnectionError(f'Could not connect, to download {target_app_name}.')
|
||||
else: # Tor:
|
||||
if connection_observer:
|
||||
connection_observer.notify('message', "Tor Bootstrap..")
|
||||
bootstrap_results = connect.coordinate_bootstrap(connection_observer)
|
||||
if not bootstrap_results.valid:
|
||||
raise ConnectionError(f'Could not connect, to download {target_app_name}.')
|
||||
|
||||
client = httpx_client.get_http_session()
|
||||
return client
|
||||
|
||||
def __calculate_file_hash(file):
|
||||
|
||||
hasher = hashlib.sha3_512()
|
||||
buffer = file.read(65536)
|
||||
|
||||
while len(buffer) > 0:
|
||||
|
||||
hasher.update(buffer)
|
||||
buffer = file.read(65536)
|
||||
|
||||
file.seek(0)
|
||||
|
||||
return hasher.hexdigest()
|
||||
|
|
@ -5,15 +5,28 @@ if TYPE_CHECKING:
|
|||
from essentials.observers.ConnectionObserver import ConnectionObserver
|
||||
# services
|
||||
from core.services.networking.make_url import make_url
|
||||
from core.services.networking.send_data_to_server import send_data_to_server
|
||||
# from core.services.networking.api_requests.step1_get_or_post import send_data_to_server
|
||||
from core.services.networking.httpx import connect
|
||||
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||
|
||||
|
||||
# use temp billing to get the plan details
|
||||
def get_plan_data(
|
||||
temp_billing_code: str, connection_observer: ConnectionObserver
|
||||
) -> dict:
|
||||
) -> dict | ApiResponse:
|
||||
|
||||
# prep:
|
||||
which_endpoint = "/plan"
|
||||
url = make_url(which_endpoint)
|
||||
payload = {"temp_billing_code": temp_billing_code}
|
||||
reply = send_data_to_server(payload, url, connection_observer)
|
||||
return reply
|
||||
|
||||
# legacy:
|
||||
# reply = send_data_to_server(payload, url, connection_observer)
|
||||
|
||||
# send:
|
||||
return connect.single_endpoint(
|
||||
method="post",
|
||||
url=url,
|
||||
observer=connection_observer,
|
||||
payload=payload
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
from core.utils.basic_operations.write_or_read_from_json import get_value_from_json_file
|
||||
from core.Constants import Constants
|
||||
# from core.utils.basic_operations.write_or_read_from_json import get_value_from_json_file
|
||||
# from core.Constants import Constants
|
||||
|
||||
|
||||
def get_which_billing_key() -> str:
|
||||
billing_folder = Constants.HV_TICKETING_CONFIG_HOME
|
||||
filepath = f"{billing_folder}/billing_choices.json"
|
||||
# def get_which_billing_key() -> str:
|
||||
# billing_folder = Constants.HV_TICKETING_CONFIG_HOME
|
||||
# filepath = f"{billing_folder}/billing_choices.json"
|
||||
|
||||
json_key = "which_key"
|
||||
which_key = get_value_from_json_file(filepath, json_key)
|
||||
return which_key
|
||||
# json_key = "which_key"
|
||||
# which_key = get_value_from_json_file(filepath, json_key)
|
||||
# return which_key
|
||||
|
||||
|
||||
|
||||
# Function moved to 'ticket_config_tools' module. This is commented out until confirmed stable later.
|
||||
380
core/services/helpers/install_dependencies.py
Normal file
380
core/services/helpers/install_dependencies.py
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
from core.services.networking.httpx.httpx_client import get_http_session, init_session
|
||||
from core.services.helpers.download_file import download_file_and_verify
|
||||
from core.utils.basic_operations.folder_tools import validate_folder_structure
|
||||
from core.utils.basic_operations.does_file_exist import does_file_exist
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.Constants import Constants
|
||||
from core.errors.logger import logger
|
||||
from core.utils.run_commands import run_generic_command
|
||||
from core.observers.ApplicationVersionObserver import ApplicationVersionObserver
|
||||
from core.models.orm_models.Dependency import Dependency
|
||||
from core.models.orm_calls.dependency_calls import get_dependency_version
|
||||
from core.controllers.ConfigurationController import ConfigurationController
|
||||
from core.utils.basic_operations.compare_versions import version_update_required
|
||||
from core.observers.ConnectionObserver import ConnectionObserver
|
||||
|
||||
import httpx
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
import hashlib
|
||||
import tarfile
|
||||
import os
|
||||
|
||||
SUDO_SINGBOX_LOCATION = f"{Constants.SUDO_TARGET_FOLDER}/sing-box"
|
||||
|
||||
def setup_singbox_binary(
|
||||
application_version_observer: Optional[ApplicationVersionObserver],
|
||||
connection_observer: Optional[ConnectionObserver]
|
||||
) -> Result:
|
||||
"""
|
||||
Rank:
|
||||
Module's Main Orchestrator
|
||||
|
||||
Purpose:
|
||||
Other modules can call upon this before approving the use of singbox,
|
||||
it checks if it's ready, updated, and if required downloads and sets it up.
|
||||
|
||||
Method:
|
||||
1) Checks if it's already installed & updated
|
||||
If not:
|
||||
2) Gets the new version if required
|
||||
3) Moves the binary to the sudo protected folder
|
||||
4) Updates the config JSON with the new version.
|
||||
|
||||
Returns On Success:
|
||||
Return Object with valid=True
|
||||
|
||||
Returns On Failure:
|
||||
No error raises, but caller needs to deal with:
|
||||
ResultError.NEED_SYNC = needs sync
|
||||
ResultError.MISSING_FILE = incomplete downloads, operations, or filesystem errors
|
||||
ResultError.CONNECTION = failed to fetch from the API the new version
|
||||
ResultError.INVALID_INPUT = rare bugs
|
||||
"""
|
||||
already_in_sudo_folder = does_file_exist(SUDO_SINGBOX_LOCATION)
|
||||
|
||||
update_result = update_needed()
|
||||
|
||||
# new version sql could not be done, so we can't even update without the new version info:
|
||||
if not update_result.valid:
|
||||
logger.info("Sync is required. We could not access the information on the most current version locally.")
|
||||
return update_result # (user MUST sync)
|
||||
|
||||
if already_in_sudo_folder and update_result.goal_result:
|
||||
# no update required:
|
||||
no_update = "No update for singbox is required."
|
||||
logger.info(no_update)
|
||||
return Result(valid=True, message=no_update)
|
||||
|
||||
required_update = update_result.data
|
||||
|
||||
if required_update is None or not isinstance(required_update, Dependency):
|
||||
error_msg = "You need to Sync, because you lack the dependency data for singbox."
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, error_type=ResultError.NEED_SYNC, message=error_msg)
|
||||
|
||||
# UPDATE FROM HERE ON
|
||||
logger.info("Updating Singbox..")
|
||||
|
||||
# PREP VARIABLES
|
||||
target_version = str(required_update.version_number)
|
||||
download_path = required_update.download_path
|
||||
target_file_hash = required_update.file_hash
|
||||
target_folder = f"{Constants.HV_APPLICATION_DATA_HOME}/singbox/"
|
||||
original_file = f"{target_folder}/{target_version}/sing-box"
|
||||
|
||||
# CHECK IF WE ALREADY HAVE IT LOCALLY:
|
||||
if already_downloaded(which_version=target_version):
|
||||
logger.info("Using already downloaded version from local files..")
|
||||
return move_and_update_config(
|
||||
original_file=original_file,
|
||||
target_version=target_version
|
||||
)
|
||||
|
||||
# DOWNLOAD FROM API:
|
||||
file_result = download_and_verify(
|
||||
download_path=download_path,
|
||||
target_folder=target_folder,
|
||||
target_file_hash=target_file_hash,
|
||||
target_app_name="sing-box",
|
||||
target_version=target_version,
|
||||
application_version_observer=application_version_observer,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
|
||||
if not file_result.valid:
|
||||
logger.error(f"Download or verification failed: {file_result.error_type}")
|
||||
return file_result
|
||||
|
||||
# FINALLY MOVE FILE & UPDATE STATE:
|
||||
return move_and_update_config(
|
||||
original_file=original_file,
|
||||
target_version=target_version
|
||||
)
|
||||
|
||||
|
||||
def move_and_update_config(original_file: str, target_version: str) -> Result:
|
||||
"""
|
||||
Rank:
|
||||
Coordinator
|
||||
Purpose:
|
||||
Move the binary to the sudo protected folder.
|
||||
Update the configuration that it's installed the "target_version"
|
||||
Returns:
|
||||
Result Object.
|
||||
Wrapper should in theory handle external file errors.
|
||||
"""
|
||||
# SETUP/MOVE:
|
||||
logger.info("Requesting permission to move to a sudo folder...")
|
||||
moved_results = _move_singbox_to_sudo_folder(
|
||||
original_file=original_file
|
||||
)
|
||||
|
||||
if not moved_results.valid:
|
||||
return moved_results
|
||||
|
||||
# TEST IT'S THERE:
|
||||
setup_in_sudo_folder = does_file_exist(SUDO_SINGBOX_LOCATION)
|
||||
|
||||
if setup_in_sudo_folder:
|
||||
# UPDATE CONFIG TO REFLECT IT:
|
||||
logger.info("The move to sudo folder was successfull. We're now updating the Configuration with the newest version installed.")
|
||||
updated = ConfigurationController.update_singbox_version(target_version)
|
||||
if updated:
|
||||
logger.info("Configuration is updated. Singbox is ready. Complete.")
|
||||
return Result(valid=True, message="Ready to proceed with singbox installed properly")
|
||||
|
||||
else:
|
||||
# ConfigurationController doesn't even return False ever, so this is essentially a full-blown breakdown
|
||||
error_msg = "RARE FILESYSTEM CRISIS CAUSES INFINITE LOOP! User actually updated the singbox binary into the sudo folder, but it's not reflecting that. Please evaluate the permissions on the config, and check if you have enough space. Check the config is even there."
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, error_type=ResultError.FILE_SYSTEM, message=error_msg)
|
||||
|
||||
else:
|
||||
error_msg = "If you denied the sudo request, it should have already given a permission error before reaching this point. So now it's possibly a corrupt filesystem or file issue."
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, error_type=ResultError.FILE_SYSTEM, message=error_msg)
|
||||
|
||||
|
||||
def _move_singbox_to_sudo_folder(original_file: str) -> Result:
|
||||
if not does_file_exist(original_file):
|
||||
return Result(valid=False, error_type=ResultError.MISSING_FILE)
|
||||
|
||||
target_file = SUDO_SINGBOX_LOCATION
|
||||
command = ['pkexec', 'install', '-D', original_file, target_file, '-o', 'root', '-m', '755']
|
||||
human_readable_goal = "Move Singbox to an elevated sudo folder."
|
||||
return run_generic_command(command, human_readable_goal, timeout=40)
|
||||
|
||||
|
||||
def update_needed() -> Result:
|
||||
"""
|
||||
Rank:
|
||||
Coordinator
|
||||
|
||||
Purpose:
|
||||
Evaluates if an update is needed.
|
||||
|
||||
Returns:
|
||||
Always a Result Object.
|
||||
No update needed = goal_result=True
|
||||
Update needed = goal_result=False
|
||||
Can't complete the checks, valid=False
|
||||
"""
|
||||
app_name = "singbox"
|
||||
rejected_values = [None, False, ""]
|
||||
|
||||
################################################
|
||||
# WHAT YOU NEED
|
||||
################################################
|
||||
version_sql_query = get_dependency_version(app_name)
|
||||
if not isinstance(version_sql_query, Dependency):
|
||||
return Result(valid=False, error_type=ResultError.NEED_SYNC, message="You need to Sync, you lack the dependency data for singbox.")
|
||||
|
||||
new_version = version_sql_query.version_number
|
||||
|
||||
if not new_version or new_version in rejected_values:
|
||||
return Result(valid=False, error_type=ResultError.NEED_SYNC, message="You need to Sync, you lack the dependency data for singbox.")
|
||||
logger.info(f"The new version for {app_name} from public sync data that we need is: {new_version}")
|
||||
|
||||
|
||||
################################################
|
||||
# WHAT YOU GOT
|
||||
################################################
|
||||
version_installed = ConfigurationController.get_singbox_version()
|
||||
logger.info(f"While the version of {app_name} we installed in our configuration is {version_installed}")
|
||||
if version_installed in rejected_values:
|
||||
logger.info(f"We need to update to the new {new_version}")
|
||||
return Result(valid=True, goal_result=False, data=version_sql_query)
|
||||
|
||||
################################################
|
||||
# EVALUATE
|
||||
################################################
|
||||
try:
|
||||
need_update = version_update_required(
|
||||
new_version=new_version,
|
||||
version_installed=version_installed
|
||||
)
|
||||
except ValueError as e:
|
||||
if "new_version" in str(e):
|
||||
logger.error(f"The new_version is in the wrong format: {str(e)}")
|
||||
return Result(valid=False, error_type=ResultError.NEED_SYNC, message="You need to Sync, the data is corrupt. Also wipe the version in the config.")
|
||||
elif "version_installed" in str(e):
|
||||
logger.error(f"The version_installed is in the wrong format: {str(e)}. But we can still update to the new version..")
|
||||
# wipe config:
|
||||
changed_config = ConfigurationController.update_singbox_version(None) # can make this dynamic if more apps are added.
|
||||
if not changed_config:
|
||||
logger.error("Critical Issue with wiping the config version")
|
||||
need_update = True # the version_sql_query data is still valid.
|
||||
else:
|
||||
error_msg = f"Corrupt data, corrupt filesystem, or outright developer bug. Please contact customer support with new_version: {new_version} and version_installed {version_installed} tried to see if it should update but {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||
|
||||
if need_update:
|
||||
return Result(valid=True, goal_result=True, data=version_sql_query, message="update")
|
||||
else:
|
||||
return Result(valid=True, goal_result=False, data=version_sql_query, message="Not needed.")
|
||||
|
||||
|
||||
def already_downloaded(which_version: str) -> bool:
|
||||
"""
|
||||
Prior Context:
|
||||
Downloads first go to a non-sudo folder, then are moved to the sudo folder.
|
||||
|
||||
Purpose:
|
||||
This function evaluates if we need to download it again to that non-sudo folder
|
||||
"""
|
||||
non_sudo_file = f"{Constants.HV_APPLICATION_DATA_HOME}/singbox/{which_version}/sing-box"
|
||||
return does_file_exist(non_sudo_file)
|
||||
|
||||
|
||||
def download_and_verify(
|
||||
download_path: str,
|
||||
target_folder: str,
|
||||
target_file_hash: str,
|
||||
target_app_name: str,
|
||||
target_version: str,
|
||||
application_version_observer: Optional[ApplicationVersionObserver] = None,
|
||||
connection_observer: Optional[ConnectionObserver] = None
|
||||
) -> Result:
|
||||
"""
|
||||
Purpose:
|
||||
Stream download file reusing pre-existing HTTPx client, then verify the hash.
|
||||
|
||||
Result object error types:
|
||||
ResultError.CONNECTION = couldn't connect
|
||||
ResultError.INVALID_INPUT = verification hash doesn't match
|
||||
"""
|
||||
if application_version_observer is not None:
|
||||
application_version_observer.notify('downloading', "singbox")
|
||||
|
||||
################################################
|
||||
# GET & VERIFY
|
||||
################################################
|
||||
download_result = download_file_and_verify(
|
||||
target_app_name=target_app_name,
|
||||
download_path=download_path,
|
||||
target_file_hash=target_file_hash,
|
||||
application_version_observer=application_version_observer,
|
||||
target_app_version=target_version,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
|
||||
if not download_result.valid:
|
||||
return download_result
|
||||
|
||||
################################################
|
||||
# SAVE IT IN CORRECT STRUCTURE
|
||||
################################################
|
||||
response_buffer = download_result.data
|
||||
temp_dir = f"{target_folder}/temp_dir"
|
||||
final_target_folder = f"{target_folder}/{target_version}"
|
||||
|
||||
# Create the temp folder (if it doesn't exist)
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
# Save buffer to a temp directory:
|
||||
with tarfile.open(fileobj=response_buffer, mode = 'r:gz') as tar_file:
|
||||
tar_file.extractall(temp_dir)
|
||||
|
||||
# make sure it has the file in the temp, and move it to the correct structure,
|
||||
target_file_is_in_payload = validate_folder_structure(
|
||||
temp_dir=temp_dir,
|
||||
target_folder=final_target_folder,
|
||||
target_file=target_app_name
|
||||
)
|
||||
|
||||
if target_file_is_in_payload:
|
||||
return Result(valid=True)
|
||||
else:
|
||||
return Result(valid=False, error_type=ResultError.MISSING_FILE)
|
||||
|
||||
|
||||
# Legacy:
|
||||
|
||||
# def __calculate_file_hash(file):
|
||||
|
||||
# hasher = hashlib.sha3_512()
|
||||
# buffer = file.read(65536)
|
||||
|
||||
# while len(buffer) > 0:
|
||||
|
||||
# hasher.update(buffer)
|
||||
# buffer = file.read(65536)
|
||||
|
||||
# file.seek(0)
|
||||
|
||||
# return hasher.hexdigest()
|
||||
|
||||
|
||||
|
||||
# ################################################
|
||||
# # SETUP HTTP CLIENT
|
||||
# ################################################
|
||||
# init_session()
|
||||
# client = get_http_session()
|
||||
# if client is None:
|
||||
# init_session
|
||||
# client = get_http_session()
|
||||
|
||||
|
||||
################################################
|
||||
# GET THE DATA
|
||||
################################################
|
||||
|
||||
|
||||
|
||||
# with client.stream('GET', download_path) as response:
|
||||
# if response.status_code == 200:
|
||||
# response_size = int(response.headers.get('Content-Length', 0))
|
||||
# response_buffer = BytesIO()
|
||||
|
||||
# block_size = 1024
|
||||
# bytes_written = 0
|
||||
# for data in response.iter_bytes(block_size):
|
||||
|
||||
# bytes_written += len(data)
|
||||
# response_buffer.write(data)
|
||||
# progress = (bytes_written / response_size) * 100 if response_size > 0 else 0
|
||||
|
||||
# if application_version_observer is not None:
|
||||
# application_version_observer.notify('download_progressing', f"Downloading {target_app_name} {progress:.2f}%")
|
||||
# else:
|
||||
# error_msg = f"Could not download {target_app_name} because of a Connection Error."
|
||||
# logger.error(error_msg)
|
||||
# return Result(valid=False, error_type=ResultError.CONNECTION, message=error_msg)
|
||||
|
||||
# if application_version_observer is not None:
|
||||
# application_version_observer.notify('downloaded', f"Downloaded {target_app_name}")
|
||||
# response_buffer.seek(0)
|
||||
|
||||
# ################################################
|
||||
# # VERIFY THE HASH
|
||||
# ################################################
|
||||
# real_file_hash = __calculate_file_hash(response_buffer)
|
||||
|
||||
# if real_file_hash != target_file_hash:
|
||||
# error_msg = f'Application version file integrity could not be verified. We are targeting {target_file_hash}, but got {real_file_hash}'
|
||||
# logger.error(error_msg)
|
||||
# return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||
|
||||
96
core/services/helpers/manage_assets.py
Normal file
96
core/services/helpers/manage_assets.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
from core.Constants import Constants
|
||||
from core.errors.logger import logger
|
||||
from core.services.helpers.assets_as_strings.sudo_scripts.dns import dns_script
|
||||
from core.services.helpers.assets_as_strings.sudo_scripts.firewall import firewall_script
|
||||
# from core.services.helpers.assets_as_strings.sudo_scripts.setup_script import setup_script
|
||||
|
||||
# generic
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import os
|
||||
from importlib import resources
|
||||
from string import Template
|
||||
|
||||
initial_appimage_assets = f"{Constants.APPDIR_HOME}/assets"
|
||||
current_assets_folder = f"{Constants.HV_DATA_HOME}/assets"
|
||||
|
||||
|
||||
def prep_singbox_wrapper():
|
||||
script_content = resources.files('core.assets.sudo_scripts').joinpath('singbox_wrapper').read_text()
|
||||
template = Template(script_content)
|
||||
return template.safe_substitute(VAR1=Constants.SINGBOX_OUTPUT)
|
||||
|
||||
def setup_script():
|
||||
return resources.files('core.assets.sudo_scripts').joinpath('setup.sh').read_text()
|
||||
|
||||
def assets_folder_setup():
|
||||
if os.path.exists(current_assets_folder):
|
||||
return True
|
||||
return copy_folders(initial_appimage_assets, current_assets_folder)
|
||||
|
||||
def updated_assets_folder_changes():
|
||||
return copy_folders(initial_appimage_assets, current_assets_folder)
|
||||
|
||||
def sudo_assets_folder_setup() -> bool:
|
||||
current_assets_folder = f"{Constants.HOME}/Downloads/hydraveil_sudo_scripts"
|
||||
singbox_wrapper = prep_singbox_wrapper()
|
||||
|
||||
data = {
|
||||
"dns": dns_script,
|
||||
"firewall": firewall_script,
|
||||
"singbox_wrapper": singbox_wrapper,
|
||||
"setup.sh": setup_script()
|
||||
}
|
||||
|
||||
# Create the folder (if it doesn't exist)
|
||||
os.makedirs(current_assets_folder, exist_ok=True)
|
||||
logger.info(f"[MANAGE ASSETS] Folder in Downloads is setup at {current_assets_folder}.")
|
||||
|
||||
# Iterate through the dictionary and write files
|
||||
for filename, content in data.items():
|
||||
file_path = os.path.join(current_assets_folder, filename)
|
||||
|
||||
logger.info(f"[MANAGE ASSETS] We are preparing {filename}.")
|
||||
|
||||
# Skip if file already exists
|
||||
if os.path.exists(file_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path, 'w') as f:
|
||||
f.write(content.lstrip('\n'))
|
||||
logger.info(f"[MANAGE ASSETS] File written for {filename}.")
|
||||
except Exception as e:
|
||||
logger.error(f"[MANAGE ASSETS] Failed to write to file for {filename} at {file_path} because {e}.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def copy_folders(initial_appimage_assets: str, current_assets_folder: str) -> bool:
|
||||
try:
|
||||
os.makedirs(current_assets_folder, exist_ok=True)
|
||||
for item in os.listdir(initial_appimage_assets):
|
||||
src = os.path.join(initial_appimage_assets, item)
|
||||
dst = os.path.join(current_assets_folder, item)
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
logger.info(f"[MANAGE ASSETS] Successfully copied over the folder.")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[MANAGE ASSETS] initial_appimage_assets is {initial_appimage_assets}")
|
||||
logger.error(f"[MANAGE ASSETS] current_assets_folder is {current_assets_folder}")
|
||||
logger.error(f"[MANAGE ASSETS] ERROR: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_setup_dir():
|
||||
"""After install completes"""
|
||||
delete_this_folder = f"{Constants.HOME}/Downloads/hydraveil_sudo_scripts"
|
||||
try:
|
||||
shutil.rmtree(delete_this_folder)
|
||||
logger.info(f"[MANAGE ASSETS] Cleaned up {delete_this_folder}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[MANAGE ASSETS] We could not delete {delete_this_folder} because {e}")
|
||||
return False
|
||||
75
core/services/helpers/setup_sudo_scripts.py
Normal file
75
core/services/helpers/setup_sudo_scripts.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from core.services.helpers.manage_assets import sudo_assets_folder_setup
|
||||
from core.utils.basic_operations.confirm_files_exist import confirm_files_and_folders_exist
|
||||
from core.utils.basic_operations.does_file_exist import does_file_exist
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.Constants import Constants
|
||||
from core.errors.logger import logger
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import os
|
||||
from subprocess import CalledProcessError
|
||||
import copy
|
||||
|
||||
FILES_TO_CHECK = ["setup.sh", "firewall", "dns", "singbox_wrapper"]
|
||||
|
||||
def auto_install_sudo_script() -> Result:
|
||||
setup_dir = Path(f"{Constants.HOME}/Downloads/hydraveil_sudo_scripts")
|
||||
setup_script = Path(setup_dir, 'setup.sh')
|
||||
original_linux_user = os.getlogin()
|
||||
|
||||
# Make sure the folder exists
|
||||
files_exist = confirm_files_and_folders_exist(
|
||||
folder_path=setup_dir, files_to_check=FILES_TO_CHECK
|
||||
)
|
||||
|
||||
if not files_exist:
|
||||
copied = sudo_assets_folder_setup()
|
||||
if not copied:
|
||||
return Result(valid=False, message="Could not copy scripts to begin.")
|
||||
else:
|
||||
logger.info(f"Folder setup in download folder at {setup_dir}")
|
||||
else:
|
||||
logger.info(f"Folder already existed in the download folder at {setup_dir}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pkexec', 'env', f'ORIGINAL_FOLDER={setup_dir}', f'TARGET_FOLDER={Constants.SUDO_TARGET_FOLDER}', f'LINUX_USER={original_linux_user}', 'bash', setup_script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("Sudo Setup Ran. Now testing..")
|
||||
return Result(valid=True)
|
||||
else:
|
||||
logger.error(f"Sudo setup setup failed: {result.stderr.strip()}")
|
||||
return Result(valid=False, message=result.stderr.strip())
|
||||
except CalledProcessError as e:
|
||||
return Result(valid=False, message=e)
|
||||
except Exception as e:
|
||||
return Result(valid=False, message=e)
|
||||
|
||||
def test_if_in_sudo_folder() -> Result:
|
||||
final_sudo_folder = Path("/opt/hydra-veil/")
|
||||
_files_to_check = FILES_TO_CHECK.copy() # to not affect the other functions.
|
||||
|
||||
# setup script itself doesn't get copied over:
|
||||
_files_to_check.remove("setup.sh")
|
||||
|
||||
# Now verify it copied them:
|
||||
sudo_files_copied = confirm_files_and_folders_exist(
|
||||
folder_path=final_sudo_folder, files_to_check=_files_to_check
|
||||
)
|
||||
|
||||
if sudo_files_copied:
|
||||
return Result(valid=True, message="Sudo Script Setup Completed Successfully!")
|
||||
else:
|
||||
error_msg = "We failed to find the files in the sudo folder."
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, message=error_msg)
|
||||
|
||||
|
||||
def is_singbox_wrapper_ready() -> bool:
|
||||
wrapper_location = f"{Constants.SUDO_TARGET_FOLDER}/singbox_wrapper"
|
||||
return does_file_exist(wrapper_location)
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
from core.models.session.SessionProfile import SessionProfile
|
||||
from core.models.system.SystemProfile import SystemProfile
|
||||
from core.models.BaseProfile import BaseProfile as Profile
|
||||
from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionTerminationError, ProfileActivationError, ProfileDeactivationError, MissingLocationError, ConnectionUnprotectedError, EndpointVerificationError, ProfileStateConflictError
|
||||
from typing import Union, Optional
|
||||
import base64
|
||||
|
||||
|
||||
def verify_wireguard_endpoint(profile: Union[SessionProfile, SystemProfile], ignore: tuple[type[Exception]] = ()):
|
||||
|
||||
try:
|
||||
__verify_wireguard_endpoint(profile)
|
||||
|
||||
except EndpointVerificationError as error:
|
||||
|
||||
if not EndpointVerificationError in ignore:
|
||||
|
||||
profile.address_security_incident()
|
||||
raise error
|
||||
|
||||
def __verify_wireguard_endpoint(profile: Union[SessionProfile, SystemProfile]):
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
import base64
|
||||
|
||||
signature = profile.get_wireguard_configuration_metadata('Signature')
|
||||
wireguard_public_keys = profile.get_wireguard_public_keys()
|
||||
operator = profile.location.operator
|
||||
|
||||
if signature is None:
|
||||
raise EndpointVerificationError('The WireGuard endpoint\'s signature could not be determined.')
|
||||
|
||||
if not wireguard_public_keys:
|
||||
raise EndpointVerificationError('The WireGuard endpoint\'s public key could not be determined.')
|
||||
|
||||
if operator is None:
|
||||
raise EndpointVerificationError('The WireGuard endpoint\'s operator could not be determined.')
|
||||
|
||||
try:
|
||||
|
||||
operator_public_key = ed25519.Ed25519PublicKey.from_public_bytes(bytes.fromhex(operator.public_key))
|
||||
|
||||
for wireguard_public_key in wireguard_public_keys:
|
||||
operator_public_key.verify(base64.b64decode(signature), wireguard_public_key.encode('utf-8'))
|
||||
|
||||
except Exception:
|
||||
raise EndpointVerificationError('The WireGuard endpoint could not be verified.')
|
||||
|
||||
107
core/services/keys_and_verifications/wireguard_keys.py
Normal file
107
core/services/keys_and_verifications/wireguard_keys.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from core.services.networking.httpx import connect
|
||||
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||
from core.models.session.SessionProfile import SessionProfile
|
||||
from core.models.system.SystemProfile import SystemProfile
|
||||
from core.Errors import MissingSubscriptionError, MissingLocationError, InvalidSubscriptionError
|
||||
from core.services.WebServiceApiService import WebServiceApiService
|
||||
from core.controllers.ConnectionController import ConnectionController
|
||||
from core.observers.ConnectionObserver import ConnectionObserver
|
||||
from core.Constants import Constants
|
||||
from core.errors.logger import logger
|
||||
|
||||
from typing import Union, Optional
|
||||
import base64
|
||||
import re
|
||||
|
||||
def register_wireguard_session(
|
||||
profile: Union[SessionProfile, SystemProfile],
|
||||
connection_observer: Optional[ConnectionObserver] = None
|
||||
):
|
||||
"""Register a WireGuard session for the given profile."""
|
||||
|
||||
if not profile.has_subscription():
|
||||
raise MissingSubscriptionError()
|
||||
|
||||
if not profile.has_location():
|
||||
raise MissingLocationError()
|
||||
|
||||
wireguard_keys = _generate_wireguard_keys()
|
||||
public_key = wireguard_keys.get('public', None)
|
||||
if not public_key:
|
||||
logger.error("No Public Key Generated. Raising ValueError")
|
||||
raise ValueError("No Public Key Generated.")
|
||||
|
||||
country_code = profile.location.country_code
|
||||
location_code = profile.location.code
|
||||
billing_code = profile.subscription.billing_code
|
||||
url = f'{Constants.SP_API_BASE_URL}/countries/{country_code}/locations/{location_code}/wireguard-sessions'
|
||||
|
||||
payload = {
|
||||
'public_key': public_key
|
||||
}
|
||||
|
||||
api_result = connect.single_endpoint(
|
||||
method="post",
|
||||
url=url,
|
||||
observer=connection_observer,
|
||||
payload=payload,
|
||||
billing_code=billing_code
|
||||
)
|
||||
|
||||
if not api_result.valid:
|
||||
if api_result.error_type == ErrorType.AUTHENTICATION_ERROR:
|
||||
logger.error(f"Server is giving an Authentication error for billing ID: {billing_code}")
|
||||
raise InvalidSubscriptionError(f"Invalid Subscription for {billing_code}")
|
||||
else:
|
||||
error_msg = f"Could Not Connect to API: {api_result.error_type}"
|
||||
logger.error(error_msg)
|
||||
raise ConnectionError(error_msg)
|
||||
|
||||
wireguard_configuration = api_result.data
|
||||
|
||||
if wireguard_configuration is None:
|
||||
raise InvalidSubscriptionError()
|
||||
|
||||
wireguard_configuration = _inject_private_key(wireguard_configuration, wireguard_keys.get('private'))
|
||||
profile.attach_wireguard_configuration(wireguard_configuration)
|
||||
|
||||
|
||||
def _generate_wireguard_keys() -> dict:
|
||||
"""Generate WireGuard public/private key pair."""
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
|
||||
raw_private_key = X25519PrivateKey.generate()
|
||||
|
||||
public_key = raw_private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
|
||||
)
|
||||
|
||||
private_key = raw_private_key.private_bytes(
|
||||
encoding=serialization.Encoding.Raw, format=serialization.PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
return dict(
|
||||
private=base64.b64encode(private_key).decode(),
|
||||
public=base64.b64encode(public_key).decode()
|
||||
)
|
||||
|
||||
|
||||
def _inject_private_key(config: str, private_key: str) -> str:
|
||||
"""Inject private key into WireGuard config."""
|
||||
expression = re.compile(r'^(PrivateKey =)\s?$', re.MULTILINE)
|
||||
return re.sub(expression, r'\1 ' + private_key, config)
|
||||
|
||||
|
||||
|
||||
|
||||
# legacy:
|
||||
|
||||
# ConnectionController.with_preferred_connection(
|
||||
# profile.location.country_code,
|
||||
# profile.location.code,
|
||||
# profile.subscription.billing_code,
|
||||
# wireguard_keys.get('public'),
|
||||
# task=WebServiceApiService.post_wireguard_session,
|
||||
# connection_observer=connection_observer
|
||||
# )
|
||||
81
core/services/networking/api_requests/ApiResponseModel.py
Normal file
81
core/services/networking/api_requests/ApiResponseModel.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any
|
||||
|
||||
|
||||
class BackoffStrategy(Enum):
|
||||
"""How to handle retries."""
|
||||
NO_RETRY = "no_retry" # Permanent error
|
||||
RETRY_IMMEDIATE = "retry_immediate" # Try again in <1s (502, 503, 504)
|
||||
RETRY_EXPONENTIAL = "retry_exponential" # Exponential backoff (500, 429)
|
||||
RETRY_WITH_AUTH_REFRESH = "retry_with_auth_refresh" # 401 after refreshing creds
|
||||
FIX_CLIENT_SIDE_INFO = "fix_client_side_info" # change payload or 405 get/post
|
||||
|
||||
class ErrorType(Enum):
|
||||
"""Classified error categories."""
|
||||
SUCCESS = "success"
|
||||
INVALID_ENDPOINT = "invalid_endpoint" # 404 - could be wrong URL or middleware rejecting
|
||||
INVALID_REQUEST = "invalid_request" # 400, 405 - bad method/body
|
||||
AUTHENTICATION_ERROR = "authentication_error" # 401 - need credentials
|
||||
AUTHORIZATION_ERROR = "authorization_error" # 403 - no permission
|
||||
RATE_LIMITED = "rate_limited" # 429
|
||||
SERVER_ERROR = "server_error" # 5xx transient
|
||||
NETWORK_ERROR = "network_error" # Connection issues
|
||||
TOR_COMMENTED_PORT = "tor_commented_port"
|
||||
TOR_FILE_MISSING = "tor_file_missing"
|
||||
DEFAULT_TOR_PORT_DEAD = "default_tor_port_dead"
|
||||
TOR_WORKS_BUT_UNRELIABLE = "tor_works_but_unreliable"
|
||||
TOR_NOT_INSTALLED = "tor_not_installed"
|
||||
REFUSAL_TO_INSTALL_TOR = "refusal_to_install_tor"
|
||||
PORT_OPEN = "port_open"
|
||||
PORT_USED = "port_used"
|
||||
TOR_ON_DIFFERENT_PORT = "tor_on_different_port"
|
||||
TOR_INSTALLED_BUT_DEAD = "tor_installed_but_dead"
|
||||
CANT_BOOTSTRAP = "cant_bootstrap"
|
||||
TOR_NOT_WORKING = "tor_not_working"
|
||||
TOR_DNS_BLOCKED = "tor_dns_blocked"
|
||||
DNS_RESOLUTION = "dns_resolution"
|
||||
DNS_TEMPORARY = "dns_temporary"
|
||||
DNS_PERMANENT = "dns_permanent"
|
||||
QUAD9_DNS_RESOLUTION = "quad9_dns_resolution"
|
||||
NO_INTERNET = "no_internet"
|
||||
CONNECTION_ERROR = "connection_error"
|
||||
INVALID_INPUT = "invalid_input"
|
||||
PERMISSION_ERROR = "permission_error"
|
||||
DEVELOPER_ERROR = "developer_error"
|
||||
PORT_NOT_LISTENING = "port_not_listening"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
@dataclass
|
||||
class ApiResponse:
|
||||
"""Unified response from all API operations."""
|
||||
valid: bool
|
||||
error_type: Optional[ErrorType] = None
|
||||
data: Optional[Any] = None
|
||||
failures: dict = field(default_factory=dict)
|
||||
message: Optional[str] = None
|
||||
backoff_strategy: BackoffStrategy = BackoffStrategy.NO_RETRY
|
||||
retry_now: bool = False # legacy
|
||||
retry_later: bool = False # legacy
|
||||
tor: bool = False
|
||||
ip_address: str = None
|
||||
ask_clearweb: bool = None
|
||||
port: int = None
|
||||
tor_needs_install: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dict for backwards compatibility."""
|
||||
return {
|
||||
"valid": self.valid,
|
||||
"error_code": self.error_type.value if self.error_type else None,
|
||||
"data": self.data,
|
||||
"message": self.message,
|
||||
"retry_now": self.retry_now,
|
||||
"retry_later": self.retry_later,
|
||||
"tor": self.tor,
|
||||
"ask_clearweb": self.ask_clearweb
|
||||
}
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""Dict-like access for backwards compatibility."""
|
||||
return getattr(self, key, default)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue