Initial Flet VPN monorepo skeleton

This commit is contained in:
SNW 2026-09-23 01:19:36 +00:00
commit 449ce76751
77 changed files with 4978 additions and 0 deletions

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
VPNAPP_ADMIN_TOKEN=replace-with-a-long-random-operator-token
VPNAPP_SECRET_KEY=replace-with-Fernet-key
VPNAPP_BASE_URL=https://sub.example.com
VPNAPP_DATABASE=backend/vpnapp.db

19
.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
.venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.ruff_cache/
build/
dist/
*.apk
*.aab
*.aar
native/android/libs/*
!native/android/libs/README.md
native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/libs/*
native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/jniLibs/
backend/*.db
backend/*.db-*
.env
.DS_Store

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 SNW
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

82
README.md Normal file
View file

@ -0,0 +1,82 @@
# VPN App
Private Android/Desktop client built as a small monorepo:
- `app/` — Flet/Python UI, profiles, settings, runtime state and common business logic.
- `native/android/` — Flet service extension and Kotlin service boundary. VPN lifecycle,
TUN, foreground service, hotspot and mobile libraries belong here, not in Python.
- `backend/` — deliberately small FastAPI + SQLite operator API and device subscription
endpoint.
- `docs/` — architecture, upstream research, implementation status and device test plan.
The current product scope is Android first and desktop proxy-only. iOS is intentionally
not in this repository. macOS is not a target of the first build; if added later it will
be proxy-only unless a separate native implementation is approved.
## Current product model
The app has two top-level tabs:
- **VPN** — `Основная` (VLESS) and `Усиленная` (AmneziaWG 3.1), both through the
`SignBox` adapter contract.
- **Белые списки** — FreeTurn, with stream presets `1..4`, CAPTCHA handling and
optional stability mode.
The Python engine manager is real and tested. Desktop `SignBox` proxy mode can launch a
configured local executable when one is supplied. Android native lifecycle and command
protocol are implemented; FreeTurn is integrated from a pinned local AAR during the
build, while SignBox remains `UNSUPPORTED` until the exact signed artifact and API are
supplied. Engine AARs are intentionally not checked into git. The UI never treats an
unsupported result as a successful connection.
The exact remaining functional blockers are tracked in
[`docs/implementation-status.md`](docs/implementation-status.md); an APK build alone
is not evidence that the missing VPN data path works.
## Quick start
```bash
uv sync
uv run flet run app/main.py
uv run pytest
```
Run the backend locally:
```bash
export VPNAPP_ADMIN_TOKEN='replace-this-in-a-secret-shell'
uv run uvicorn backend.main:app --reload --port 8080
```
The backend deliberately requires HTTPS in deployment. `VPNAPP_ADMIN_TOKEN` is an
operator credential only. Devices use a separate random bearer credential; UUIDs and
User-Agent strings are not authentication.
## Build scripts
```bash
./scripts/fetch-engine-artifacts.sh # optional, requires upstream build tools
./scripts/build-android.sh # flet build apk (requires Android SDK/Flutter)
./scripts/build-desktop.sh linux # flet build linux
./scripts/run-backend.sh # local backend
```
For Android, `./scripts/build-android.sh` builds an arm64 APK with any reviewed artifacts
present locally; missing engines report `UNSUPPORTED` at runtime. For a releasable build,
place the exact `freeturn.aar` and compatible `libbox.aar` under
`native/android/libs/` (the directory is ignored to avoid silently shipping unreviewed
native code) and set `REQUIRE_NATIVE_ARTIFACTS=1`.
## Security posture
The default proxy bind is localhost. Wi-Fi sharing is explicit, binds to the discovered
LocalOnlyHotspot address, and requires authentication by default. Management/debug APIs
are disabled by default. Secrets are redacted from logs. Application routing models
distinguish `PROXY`, `DIRECT`, and `VOID`; native UID matching and fail-closed `VOID`
enforcement are not yet shipped and are never presented as active security.
## License and upstreams
This repository's original code is MIT-licensed; see `LICENSE`. Upstream components,
versions, URLs and license obligations are recorded in `THIRD_PARTY_NOTICES.md`.
Do not copy the GPL Android reference application's UI or source into this project.

19
THIRD_PARTY_NOTICES.md Normal file
View file

@ -0,0 +1,19 @@
# Third-party notices
This file is intentionally explicit about components that may be bundled. Native
artifacts are not committed to this repository; the exact version and SHA-256 must be
recorded here before a release artifact is built.
| Component | Use | Version/commit | License | Source |
| --- | --- | --- | --- | --- |
| Flet | Python UI/runtime packaging | pinned by `uv.lock` | Apache-2.0 | https://github.com/flet-dev/flet |
| FreeTurn mobile API | Android FreeTurn allowlist/whitelist transport | `samosvalishe/free-turn-proxy` commit `065385e656085a06bf8079958bf1b563901db64f`; local AAR SHA-256 `112207f15bd5c93500be13e249f884dec0768d0f542599b3e24f641837483a6e` | GPL-3.0 | https://github.com/samosvalishe/free-turn-proxy |
| FreeTurn Android reference | lifecycle/API reference only; not a source dependency | inspected only | GPL-3.0 | https://github.com/samosvalishe/turn-proxy-android |
| sign-box/libbox or private SignBox fork | VPN/proxy engine artifact, if supplied | **must be pinned before release** | check supplied artifact/source | https://github.com/SagerNet/sing-box |
| XTLS/libXray | optional future VLESS mobile binding; not currently bundled | **not bundled** | MPL-2.0 | https://github.com/XTLS/libXray |
| FastAPI / Uvicorn / Pydantic / qrcode | Python app/backend dependencies | pinned by `uv.lock` | respective upstream licenses | PyPI |
The Android reference application is not copied. Its GPL license does not automatically
apply to an independent implementation that only uses documented APIs, but distributing
the FreeTurn GPL mobile library still requires complying with its license and providing
corresponding notices/source obligations as applicable.

1
app/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Flet application package."""

1
app/core/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Engine abstraction and adapters."""

6
app/core/errors.py Normal file
View file

@ -0,0 +1,6 @@
class EngineError(RuntimeError):
"""An engine failed to start, stop, or report state."""
class UnsupportedEngineError(EngineError):
"""The requested capability is not present in the current artifact."""

23
app/core/factory.py Normal file
View file

@ -0,0 +1,23 @@
from app.core.freeturn import FreeTurnBackend
from app.core.manager import EngineManager
from app.core.native import NativeEngineBackend
from app.core.registry import BackendRegistry
from app.core.signbox import SignBoxBackend
from app.models import EngineKind
def build_engine_manager(native_bridge=None) -> EngineManager:
registry = BackendRegistry()
if native_bridge is None:
registry.register(EngineKind.SIGNBOX, SignBoxBackend)
registry.register(EngineKind.FREETURN, FreeTurnBackend)
else:
registry.register(
EngineKind.SIGNBOX,
lambda: NativeEngineBackend(EngineKind.SIGNBOX, native_bridge),
)
registry.register(
EngineKind.FREETURN,
lambda: NativeEngineBackend(EngineKind.FREETURN, native_bridge),
)
return EngineManager(registry)

64
app/core/freeturn.py Normal file
View file

@ -0,0 +1,64 @@
from __future__ import annotations
from app.core.errors import UnsupportedEngineError
from app.core.interface import EngineBackend
from app.models import (
CapabilitySet,
EngineKind,
EngineState,
RuntimeRequest,
RuntimeSnapshot,
RuntimeStats,
)
class FreeTurnBackend(EngineBackend):
"""FreeTurn adapter contract.
The actual Android calls (`Mobile.Start`, `StartTunnel`, `SetProtect`, events,
CAPTCHA) are owned by the native service. Desktop support is intentionally not
claimed until a desktop build of the core is selected.
"""
name = "freeturn"
capabilities = CapabilitySet(
supports_vpn=False,
supports_proxy=True,
supports_socks=False,
supports_http=False,
supports_udp=True,
supports_mobile=True,
supports_desktop=False,
supports_stats=True,
supports_hot_reload=False,
supports_both=False,
)
def __init__(self) -> None:
self._state = RuntimeSnapshot(engine=EngineKind.FREETURN)
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot:
self._state = RuntimeSnapshot(
state=EngineState.UNSUPPORTED,
engine=EngineKind.FREETURN,
mode=request.mode,
profile_id=request.profile_id,
message="FreeTurn is available only through the Android native mobile binding",
)
raise UnsupportedEngineError(
self._state.message or "FreeTurn is unsupported on this platform"
)
async def stop(self) -> RuntimeSnapshot:
self._state = self._state.model_copy(update={"state": EngineState.STOPPED})
return self._state
async def restart(self, request: RuntimeRequest) -> RuntimeSnapshot:
await self.stop()
return await self.start(request)
async def get_state(self) -> RuntimeSnapshot:
return self._state
async def get_stats(self) -> RuntimeStats:
return self._state.stats

31
app/core/interface.py Normal file
View file

@ -0,0 +1,31 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from app.models import CapabilitySet, RuntimeRequest, RuntimeSnapshot, RuntimeStats
class EngineBackend(ABC):
"""Platform-independent lifecycle contract.
Platform adapters may implement this through a desktop process or through the
Android native bridge. Python never owns Android VPN process lifetime.
"""
name: str
capabilities: CapabilitySet
@abstractmethod
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot: ...
@abstractmethod
async def stop(self) -> RuntimeSnapshot: ...
@abstractmethod
async def restart(self, request: RuntimeRequest) -> RuntimeSnapshot: ...
@abstractmethod
async def get_state(self) -> RuntimeSnapshot: ...
@abstractmethod
async def get_stats(self) -> RuntimeStats: ...

106
app/core/manager.py Normal file
View file

@ -0,0 +1,106 @@
from __future__ import annotations
import asyncio
from app.core.errors import EngineError
from app.core.interface import EngineBackend
from app.core.registry import BackendRegistry
from app.models import (
CapabilitySet,
EngineKind,
EngineState,
RuntimeRequest,
RuntimeSnapshot,
RuntimeStats,
)
class EngineManager:
"""Serializes runtime operations and prevents FreeTurn/VPN conflicts."""
def __init__(self, registry: BackendRegistry) -> None:
self.registry = registry
self._active_kind: EngineKind | None = None
self._backend: EngineBackend | None = None
self._lock = asyncio.Lock()
@property
def active_kind(self) -> EngineKind | None:
return self._active_kind
def capabilities(self, kind: EngineKind) -> CapabilitySet:
if self._backend is not None and self._active_kind is kind:
return self._backend.capabilities
return self.registry.create(kind).capabilities
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot:
async with self._lock:
if self._active_kind is not None and self._active_kind is not request.engine:
raise EngineError("another engine is active; stop it before switching")
backend = (
self._backend
if self._backend is not None and self._active_kind is request.engine
else self.registry.create(request.engine)
)
self._backend = backend
if request.mode.value in {"vpn", "both"} and not backend.capabilities.supports_vpn:
raise EngineError(f"{request.engine} does not support VPN mode")
if request.mode.value in {"proxy", "both"} and not backend.capabilities.supports_proxy:
raise EngineError(f"{request.engine} does not support proxy mode")
if request.mode.value == "both" and not backend.capabilities.supports_both:
raise EngineError(f"{request.engine} does not support Both mode")
snapshot = await backend.start(request)
if snapshot.state in {EngineState.RUNNING, EngineState.STARTING}:
self._active_kind = request.engine
return snapshot
async def stop(self) -> RuntimeSnapshot:
async with self._lock:
if self._backend is None:
return RuntimeSnapshot()
snapshot = await self._backend.stop()
if snapshot.state in {EngineState.STOPPED, EngineState.ERROR, EngineState.UNSUPPORTED}:
self._active_kind = None
self._backend = None
return snapshot
async def restart(self, request: RuntimeRequest) -> RuntimeSnapshot:
async with self._lock:
if self._active_kind is not None and self._active_kind is not request.engine:
raise EngineError("another engine is active; stop it before switching")
backend = (
self._backend
if self._backend is not None and self._active_kind is request.engine
else self.registry.create(request.engine)
)
self._backend = backend
snapshot = await backend.restart(request)
if snapshot.state in {EngineState.RUNNING, EngineState.STARTING}:
self._active_kind = request.engine
return snapshot
async def get_state(self) -> RuntimeSnapshot:
async with self._lock:
if self._backend is None:
# Native services can outlive the Flet process. Probe registered
# adapters so a newly opened UI can discover the authoritative
# runtime instead of interpreting missing Python state as STOPPED.
for kind in self.registry.kinds():
candidate = self.registry.create(kind)
snapshot = await candidate.get_state()
if snapshot.engine is kind and snapshot.state in {
EngineState.STARTING,
EngineState.RUNNING,
EngineState.STOPPING,
}:
self._backend = candidate
self._active_kind = kind
return snapshot
return RuntimeSnapshot()
return await self._backend.get_state()
async def get_stats(self) -> RuntimeStats:
async with self._lock:
if self._backend is None:
return RuntimeStats()
return await self._backend.get_stats()

208
app/core/native.py Normal file
View file

@ -0,0 +1,208 @@
from __future__ import annotations
from typing import Any
from app.core.errors import EngineError, UnsupportedEngineError
from app.core.interface import EngineBackend
from app.models import (
CapabilitySet,
EngineKind,
EngineState,
RunMode,
RuntimeRequest,
RuntimeSnapshot,
RuntimeStats,
)
class NativeEngineBackend(EngineBackend):
"""Android implementation backed by the Flet service extension.
The service returns immediately with ``starting``; native SharedPreferences is
authoritative and is polled by ``get_state``. This class contains no Android
lifecycle code and is therefore safe to keep out of desktop builds.
"""
def __init__(self, kind: EngineKind, bridge: Any) -> None:
self.kind = kind
self.bridge = bridge
self.name = kind.value
self.capabilities = self._capabilities(kind)
self._request: RuntimeRequest | None = None
self._state = RuntimeSnapshot(engine=kind)
@staticmethod
def _capabilities(kind: EngineKind) -> CapabilitySet:
if kind is EngineKind.FREETURN:
return CapabilitySet(
supports_proxy=True,
supports_socks=False,
supports_udp=True,
supports_mobile=True,
supports_stats=True,
)
return CapabilitySet(
# The exact SignBox AAR/API is a release input. Keeping these false
# prevents the UI from advertising a native path that is not bundled.
supports_mobile=True,
)
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot:
payload = request.model_dump(mode="json")
existing = await self.bridge.get_runtime_state()
existing_state = self._result_state(existing)
existing_engine = self._result_engine(existing)
if existing_state in {EngineState.STARTING, EngineState.RUNNING}:
if existing_engine is not None and existing_engine is not request.engine:
raise EngineError("another native engine is active; stop it before switching")
existing_mode = self._result_mode(existing)
if (
request.mode is RunMode.BOTH
and existing_mode is RunMode.VPN
and request.proxy.access.value == "hotspot"
):
result = await self.bridge.start_hotspot_proxy(payload)
self._request = request
self._state = self._snapshot_from_result(result, request, EngineState.STARTING)
return self._raise_if_terminal_error(self._state)
self._request = request
self._state = self._snapshot_from_result(existing, request, existing_state)
return self._state
self._request = request
if request.mode is RunMode.PROXY:
result = (
await self.bridge.start_proxy(payload)
if request.proxy.access.value == "local"
else await self.bridge.start_hotspot_proxy(payload)
)
else:
result = await self.bridge.start_vpn(payload)
if request.mode is RunMode.BOTH and self._result_state(result) not in {
EngineState.ERROR,
EngineState.UNSUPPORTED,
}:
if request.proxy.access.value == "hotspot":
await self.bridge.start_hotspot_proxy(payload)
else:
await self.bridge.start_proxy(payload)
self._state = self._snapshot_from_result(result, request, EngineState.STARTING)
return self._raise_if_terminal_error(self._state)
async def stop(self) -> RuntimeSnapshot:
await self.bridge.stop_vpn()
await self.bridge.stop_proxy()
request = self._request
self._state = RuntimeSnapshot(
state=EngineState.STOPPED,
engine=self.kind,
mode=request.mode if request else None,
profile_id=request.profile_id if request else None,
)
return self._state
async def restart(self, request: RuntimeRequest) -> RuntimeSnapshot:
await self.stop()
return await self.start(request)
async def get_state(self) -> RuntimeSnapshot:
result = await self.bridge.get_runtime_state()
request = self._request
if isinstance(result, dict):
if request is None:
engine = self._result_engine(result)
if engine is not self.kind:
return self._state
self._state = self._snapshot_from_result(result, None, self._state.state)
else:
self._state = self._snapshot_from_result(result, request, self._state.state)
return self._state
async def get_stats(self) -> RuntimeStats:
return self._state.stats
def _raise_if_terminal_error(self, snapshot: RuntimeSnapshot) -> RuntimeSnapshot:
if snapshot.state is EngineState.ERROR:
raise EngineError(snapshot.message or "native runtime failed")
if snapshot.state is EngineState.UNSUPPORTED:
raise UnsupportedEngineError(snapshot.message or "native engine is unsupported")
return snapshot
@staticmethod
def _result_state(result: object) -> EngineState:
if isinstance(result, dict):
try:
return EngineState(str(result.get("state", "starting")))
except ValueError:
return EngineState.STARTING
return EngineState.STARTING
@staticmethod
def _result_engine(result: object) -> EngineKind | None:
if not isinstance(result, dict):
return None
value = result.get("engine")
try:
return EngineKind(str(value)) if value else None
except ValueError:
return None
def _snapshot_from_result(
self, result: object, request: RuntimeRequest | None, fallback: EngineState
) -> RuntimeSnapshot:
state = self._result_state(result)
if state is EngineState.STOPPED and fallback is EngineState.STARTING:
state = fallback
message = result.get("message") if isinstance(result, dict) else None
engine = request.engine if request else self._result_engine(result)
mode = request.mode if request else self._result_mode(result)
profile_id = request.profile_id if request else self._result_string(result, "profile_id")
payload = self._result_payload(result)
return RuntimeSnapshot(
state=state,
engine=engine,
mode=mode,
profile_id=profile_id,
message=str(message) if message else None,
proxy_addresses=self._proxy_addresses(payload),
captcha_url=payload.get("captcha_url") if isinstance(payload.get("captcha_url"), str) else None,
revision=request.config_revision if request else 0,
)
@staticmethod
def _result_string(result: object, key: str) -> str | None:
value = result.get(key) if isinstance(result, dict) else None
return str(value) if value else None
@staticmethod
def _result_mode(result: object):
from app.models import RunMode
value = result.get("mode") if isinstance(result, dict) else None
try:
return RunMode(str(value)) if value else None
except ValueError:
return None
@staticmethod
def _result_payload(result: object) -> dict[str, object]:
if not isinstance(result, dict):
return {}
payload = result.get("payload")
if isinstance(payload, dict):
return payload
if isinstance(payload, str):
try:
import json
decoded = json.loads(payload)
return decoded if isinstance(decoded, dict) else {}
except json.JSONDecodeError:
return {}
return {}
@staticmethod
def _proxy_addresses(payload: dict[str, object]) -> list[str]:
addresses = payload.get("addresses")
if isinstance(addresses, list):
return [str(item) for item in addresses]
return []

36
app/core/proxy.py Normal file
View file

@ -0,0 +1,36 @@
from __future__ import annotations
import asyncio
import socket
from collections.abc import Iterable
def pick_free_port(host: str = "127.0.0.1", candidates: Iterable[int] | None = None) -> int:
"""Choose an available TCP port without binding a privileged/system port."""
ports = list(candidates or range(49152, 65536))
for port in ports:
with socket.socket(
socket.AF_INET6 if ":" in host else socket.AF_INET, socket.SOCK_STREAM
) as sock:
try:
sock.bind((host, port))
except OSError:
continue
return port
raise OSError("no free high TCP port")
async def drain_process_output(process: asyncio.subprocess.Process) -> list[str]:
"""Read both streams until a process exits, returning sanitized text lines."""
lines: list[str] = []
async def read(stream: asyncio.StreamReader | None) -> None:
if stream is None:
return
while line := await stream.readline():
lines.append(line.decode("utf-8", errors="replace").rstrip())
await asyncio.gather(read(process.stdout), read(process.stderr))
return lines

27
app/core/registry.py Normal file
View file

@ -0,0 +1,27 @@
from __future__ import annotations
from collections.abc import Callable
from app.core.interface import EngineBackend
from app.models import EngineKind
BackendFactory = Callable[[], EngineBackend]
class BackendRegistry:
def __init__(self) -> None:
self._factories: dict[EngineKind, BackendFactory] = {}
def register(self, kind: EngineKind, factory: BackendFactory) -> None:
if kind in self._factories:
raise ValueError(f"backend already registered: {kind}")
self._factories[kind] = factory
def create(self, kind: EngineKind) -> EngineBackend:
try:
return self._factories[kind]()
except KeyError as exc:
raise KeyError(f"backend is not registered: {kind}") from exc
def kinds(self) -> tuple[EngineKind, ...]:
return tuple(self._factories)

153
app/core/signbox.py Normal file
View file

@ -0,0 +1,153 @@
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
from app.core.errors import UnsupportedEngineError
from app.core.interface import EngineBackend
from app.models import (
CapabilitySet,
EngineKind,
EngineState,
RuntimeRequest,
RuntimeSnapshot,
RuntimeStats,
)
class SignBoxBackend(EngineBackend):
"""SignBox/sing-box adapter.
Desktop proxy mode is intentionally process based. Android must use the native
libbox AAR through `native/android`; this Python class never starts a subprocess
on Android.
"""
name = "signbox"
capabilities = CapabilitySet(
supports_vpn=False,
supports_proxy=True,
supports_socks=True,
supports_http=True,
supports_udp=True,
supports_mobile=False,
supports_desktop=True,
supports_stats=True,
supports_both=False,
)
def __init__(self, executable: str | None = None, state_dir: Path | None = None) -> None:
self.executable = executable or os.getenv("SIGNBOX_BIN")
self.capabilities = CapabilitySet(
supports_proxy=bool(self.executable),
supports_socks=bool(self.executable),
supports_http=bool(self.executable),
supports_udp=bool(self.executable),
supports_desktop=bool(self.executable),
supports_stats=bool(self.executable),
)
self.state_dir = state_dir or Path(".runtime")
self._process: asyncio.subprocess.Process | None = None
self._monitor_task: asyncio.Task[None] | None = None
self._state = RuntimeSnapshot(engine=EngineKind.SIGNBOX)
self._request: RuntimeRequest | None = None
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot:
self._request = request
self._state = RuntimeSnapshot(
state=EngineState.STARTING,
engine=EngineKind.SIGNBOX,
mode=request.mode,
profile_id=request.profile_id,
revision=request.config_revision,
)
if not self.executable:
self._state = self._unsupported("SIGNBOX_BIN is not configured for desktop")
raise UnsupportedEngineError(self._state.message or "SignBox binary is missing")
if request.mode.value != "proxy":
self._state = self._unsupported(
"desktop SignBox adapter currently supports proxy-only; Android uses native libbox"
)
raise UnsupportedEngineError(self._state.message or "unsupported desktop mode")
if not request.engine_config:
self._state = self._unsupported("SignBox profile config is empty")
raise UnsupportedEngineError(self._state.message or "SignBox config is empty")
self.state_dir.mkdir(parents=True, exist_ok=True)
config_path = self.state_dir / f"signbox-{request.profile_id}.json"
config_path.write_text(
json.dumps(request.engine_config, indent=2), encoding="utf-8"
)
self._process = await asyncio.create_subprocess_exec(
self.executable,
"run",
"-c",
str(config_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._monitor_task = asyncio.create_task(self._monitor(self._process))
await asyncio.sleep(0)
if self._process.returncode is not None:
self._state = self._state.model_copy(
update={"state": EngineState.ERROR, "message": "SignBox exited during startup"}
)
raise UnsupportedEngineError(self._state.message or "SignBox exited during startup")
self._state = self._state.model_copy(update={"state": EngineState.RUNNING})
return self._state
async def stop(self) -> RuntimeSnapshot:
if self._monitor_task is not None:
self._monitor_task.cancel()
self._monitor_task = None
if self._process is not None:
self._process.terminate()
try:
await asyncio.wait_for(self._process.wait(), timeout=5)
except TimeoutError:
self._process.kill()
await self._process.wait()
self._process = None
self._state = self._state.model_copy(update={"state": EngineState.STOPPED})
return self._state
async def _monitor(self, process: asyncio.subprocess.Process) -> None:
async def drain(stream: asyncio.StreamReader | None) -> None:
if stream is None:
return
while await stream.readline():
pass
try:
await asyncio.gather(drain(process.stdout), drain(process.stderr))
await process.wait()
except asyncio.CancelledError:
raise
if self._process is process and self._state.state not in {
EngineState.STOPPED,
EngineState.STOPPING,
}:
self._state = self._state.model_copy(
update={"state": EngineState.ERROR, "message": "SignBox process exited unexpectedly"}
)
async def restart(self, request: RuntimeRequest) -> RuntimeSnapshot:
await self.stop()
return await self.start(request)
async def get_state(self) -> RuntimeSnapshot:
if self._process is not None and self._process.returncode is not None:
self._state = self._state.model_copy(
update={
"state": EngineState.ERROR,
"message": "SignBox process exited unexpectedly",
}
)
return self._state
async def get_stats(self) -> RuntimeStats:
return self._state.stats
def _unsupported(self, message: str) -> RuntimeSnapshot:
return self._state.model_copy(update={"state": EngineState.UNSUPPORTED, "message": message})

124
app/main.py Normal file
View file

@ -0,0 +1,124 @@
from __future__ import annotations
import asyncio
import flet as ft
from vpn_native_bridge import NativeBridge
from app.models import PaymentInfo
from app.screens.safe_area import safe_area
from app.screens.settings import build_settings_screen
from app.screens.vpn import build_vpn_screen
from app.screens.white_lists import build_white_lists_screen
from app.services.billing import month_key, payment_banner
from app.services.controller import RuntimeController
from app.services.settings_registry import SettingsRegistry
from app.services.state_store import StateStore
from app.theme import apply_accent
def main(page: ft.Page) -> None:
page.title = "VPN App"
page.padding = 0
page.scroll = ft.ScrollMode.AUTO
page.theme_mode = ft.ThemeMode.SYSTEM
store = StateStore()
persisted = store.load()
native_bridge = (
NativeBridge()
if page.platform in {ft.PagePlatform.ANDROID, ft.PagePlatform.ANDROID_TV}
else None
)
controller = RuntimeController(native_bridge=native_bridge)
registry = SettingsRegistry()
payment = PaymentInfo(
monthly_price_rub=int(persisted.get("monthly_price_rub", 0)),
payment_day=int(persisted.get("payment_day", 1)),
)
banner = ft.Ref[ft.Banner]()
def dismiss_payment(_: ft.ControlEvent) -> None:
state = store.load()
state["payment_banner_dismissed"] = month_key()
store.save(state)
page.close(banner.current)
def maybe_show_payment() -> None:
if payment.monthly_price_rub <= 0:
return
if persisted.get("payment_banner_dismissed") == month_key():
return
message = payment_banner(payment)
if message:
page.open(
ft.Banner(
ref=banner,
leading=ft.Icon(ft.Icons.INFO_OUTLINE),
content=ft.Text(message),
actions=[
ft.TextButton("Не показывать в этом месяце", on_click=dismiss_payment)
],
)
)
def set_theme(key: str) -> None:
apply_accent(page, key)
vpn_screen = build_vpn_screen(page, controller, registry, set_theme)
white_lists_screen = build_white_lists_screen(
page, controller, registry, lambda _: set_theme("freeturn")
)
settings_screen = build_settings_screen(page, registry)
def open_settings(_: ft.ControlEvent) -> None:
dialog = ft.AlertDialog(
modal=True,
title=ft.Text("Настройки"),
content=ft.Container(settings_screen, width=460, height=520),
actions=[ft.TextButton("Закрыть", on_click=lambda _: page.close(dialog))],
)
page.open(dialog)
def tab_changed(event: ft.ControlEvent) -> None:
index = event.control.selected_index
set_theme("freeturn" if index == 1 else "basic")
page.appbar = ft.AppBar(
title=ft.Text("VPN App"),
actions=[ft.IconButton(ft.Icons.SETTINGS_OUTLINED, on_click=open_settings)],
)
tabs = ft.Tabs(
expand=True,
selected_index=0,
tabs=[
ft.Tab(text="VPN", content=vpn_screen),
ft.Tab(text="Белые списки", content=white_lists_screen),
],
on_change=tab_changed,
)
page.add(
safe_area(
ft.Column(
[tabs],
expand=True,
)
)
)
apply_accent(page, "basic")
maybe_show_payment()
if native_bridge is not None:
async def restore_native_state() -> None:
while True:
await controller.refresh()
if controller.engine_kind.value == "freeturn":
tabs.selected_index = 1
set_theme("freeturn")
page.update()
await asyncio.sleep(2)
page.run_task(restore_native_state)
if __name__ == "__main__":
ft.run(main)

199
app/models.py Normal file
View file

@ -0,0 +1,199 @@
from __future__ import annotations
import calendar
import secrets
import uuid
from datetime import UTC, date, datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
def utc_now() -> datetime:
return datetime.now(UTC)
class EngineState(StrEnum):
STOPPED = "stopped"
STARTING = "starting"
RUNNING = "running"
STOPPING = "stopping"
ERROR = "error"
UNSUPPORTED = "unsupported"
class EngineKind(StrEnum):
SIGNBOX = "signbox"
FREETURN = "freeturn"
class RunMode(StrEnum):
VPN = "vpn"
PROXY = "proxy"
BOTH = "both"
class ProxyAccess(StrEnum):
LOCAL = "local"
HOTSPOT = "hotspot"
class ProxyProtocol(StrEnum):
SOCKS5 = "socks5"
HTTP = "http"
class AppPolicy(StrEnum):
PROXY = "proxy"
DIRECT = "direct"
VOID = "void"
class Strategy(StrEnum):
BASIC = "basic"
ENHANCED = "enhanced"
class CapabilitySet(BaseModel):
supports_vpn: bool = False
supports_proxy: bool = False
supports_socks: bool = False
supports_http: bool = False
supports_udp: bool = False
supports_mobile: bool = False
supports_desktop: bool = False
supports_stats: bool = False
supports_hot_reload: bool = False
supports_both: bool = False
class ProfileMetadata(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Новый профиль"
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class SignBoxProfile(ProfileMetadata):
kind: EngineKind = EngineKind.SIGNBOX
strategy: Strategy = Strategy.BASIC
config: dict[str, Any] = Field(default_factory=dict)
mtu: int = Field(default=1280, ge=576, le=9000)
split_tunneling: bool = False
raw_config: str | None = None
class FreeTurnProfile(ProfileMetadata):
kind: EngineKind = EngineKind.FREETURN
key: str = ""
options: dict[str, Any] = Field(default_factory=dict)
preset: int = Field(default=1, ge=1, le=4)
stability_over_load: bool = False
captcha_always_browser: bool = False
@property
def stream_settings(self) -> tuple[int, int]:
values = {1: (4, 4), 2: (8, 4), 3: (12, 6), 4: (24, 6)}
threads, per_credential = values[self.preset]
if self.stability_over_load and self.preset >= 3:
per_credential = 4
return threads, per_credential
class ProxySettings(BaseModel):
enabled: bool = True
access: ProxyAccess = ProxyAccess.LOCAL
socks_enabled: bool = True
socks_port: int = Field(default=1080, ge=1, le=65535)
socks_random_port: bool = False
http_enabled: bool = True
http_port: int = Field(default=8080, ge=1, le=65535)
http_random_port: bool = False
authentication: bool = False
username: str = ""
password: str = ""
bind_address: str = "127.0.0.1"
@field_validator("username", "password")
@classmethod
def no_control_chars(cls, value: str) -> str:
if any(ord(char) < 32 for char in value):
raise ValueError("credentials must not contain control characters")
return value
def validate_for_access(self) -> None:
if self.access is ProxyAccess.HOTSPOT and not self.authentication:
return
if self.authentication and (not self.username or not self.password):
raise ValueError("proxy authentication requires username and password")
class VpnSettings(BaseModel):
mode: RunMode = RunMode.VPN
tun_address: str = "10.0.0.1"
tun_prefix: int = Field(default=24, ge=8, le=30)
mtu: int = Field(default=1280, ge=576, le=9000)
split_tunneling: bool = False
app_rules: dict[str, AppPolicy] = Field(default_factory=dict)
@field_validator("app_rules")
@classmethod
def validate_rules(cls, value: dict[str, AppPolicy]) -> dict[str, AppPolicy]:
# A VOID rule must never be represented as a VpnService exclusion.
return dict(value)
class RuntimeRequest(BaseModel):
engine: EngineKind
mode: RunMode
profile_id: str
proxy: ProxySettings = Field(default_factory=ProxySettings)
vpn: VpnSettings = Field(default_factory=VpnSettings)
engine_config: dict[str, Any] = Field(default_factory=dict)
config_revision: int = Field(default=0, ge=0)
class RuntimeStats(BaseModel):
upload_bytes: int = 0
download_bytes: int = 0
upload_bps: int = 0
download_bps: int = 0
uptime_seconds: int = 0
class RuntimeSnapshot(BaseModel):
state: EngineState = EngineState.STOPPED
engine: EngineKind | None = None
mode: RunMode | None = None
profile_id: str | None = None
message: str | None = None
stats: RuntimeStats = Field(default_factory=RuntimeStats)
proxy_addresses: list[str] = Field(default_factory=list)
captcha_url: str | None = None
revision: int = 0
class PaymentInfo(BaseModel):
monthly_price_rub: int = Field(ge=0)
payment_day: int = Field(ge=1, le=31)
def due_date(self, year: int, month: int) -> date:
last_day = calendar.monthrange(year, month)[1]
return date(year, month, min(self.payment_day, last_day))
class DeviceSubscription(BaseModel):
device_id: str
device_token: str
subscription_url: str
def generate_device_token() -> str:
return secrets.token_urlsafe(32)
def generate_device_id() -> str:
return str(uuid.uuid4())

1
app/screens/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Small Flet screens."""

52
app/screens/components.py Normal file
View file

@ -0,0 +1,52 @@
from __future__ import annotations
from collections.abc import Callable
import flet as ft
from app.models import EngineState, RunMode
from app.theme import card
def connection_card(
state: EngineState,
mode: RunMode,
backend: str,
on_slide: Callable[[ft.ControlEvent], object],
enabled: bool = True,
) -> ft.Control:
labels = {
EngineState.STOPPED: "Отключено",
EngineState.STARTING: "Подключение…",
EngineState.RUNNING: "Подключено",
EngineState.STOPPING: "Отключение…",
EngineState.ERROR: "Ошибка",
EngineState.UNSUPPORTED: "Недоступно",
}
return card(
ft.Column(
[
ft.Text(labels[state], size=26, weight=ft.FontWeight.W_600),
ft.Text(f"{backend} · {mode.value}", color=ft.Colors.OUTLINE),
ft.Slider(
min=0,
max=1,
divisions=1,
value=1 if state in {EngineState.RUNNING, EngineState.STARTING} else 0,
label="Подключено"
if state is EngineState.RUNNING
else "Проведите для подключения" if enabled else "Недоступно",
disabled=not enabled,
on_change=on_slide if enabled else None,
),
],
spacing=8,
)
)
def section_title(title: str, subtitle: str | None = None) -> ft.Control:
controls: list[ft.Control] = [ft.Text(title, size=20, weight=ft.FontWeight.W_600)]
if subtitle:
controls.append(ft.Text(subtitle, color=ft.Colors.OUTLINE))
return ft.Column(controls, spacing=2)

7
app/screens/safe_area.py Normal file
View file

@ -0,0 +1,7 @@
from __future__ import annotations
import flet as ft
def safe_area(content: ft.Control) -> ft.SafeArea:
return ft.SafeArea(content=ft.Container(content=content, expand=True, padding=12))

39
app/screens/settings.py Normal file
View file

@ -0,0 +1,39 @@
from __future__ import annotations
import flet as ft
from app.services.settings_registry import SettingsRegistry
from app.theme import card
def build_settings_screen(page: ft.Page, registry: SettingsRegistry) -> ft.Control:
body = ft.Column(spacing=14, scroll=ft.ScrollMode.AUTO, expand=True)
sections = registry.sections()
body.controls = [
ft.Text("Настройки", size=22, weight=ft.FontWeight.W_600),
card(
ft.Column(
[
ft.Text("Динамические разделы", weight=ft.FontWeight.W_600),
*[
ft.Text(f"{item.title}: доступно из соответствующего экрана")
for item in sections
],
],
spacing=8,
)
),
card(
ft.Column(
[
ft.Text("Приватность", weight=ft.FontWeight.W_600),
ft.Text(
"Management/debug API выключены. Секреты не выводятся в логи.",
color=ft.Colors.OUTLINE,
),
],
spacing=8,
)
),
]
return body

188
app/screens/vpn.py Normal file
View file

@ -0,0 +1,188 @@
from __future__ import annotations
import base64
import io
import flet as ft
import qrcode
from app.models import EngineState, RunMode, Strategy
from app.screens.components import connection_card, section_title
from app.services.controller import RuntimeController
from app.services.settings_registry import SettingsRegistry
from app.theme import card
def build_vpn_screen(
page: ft.Page,
controller: RuntimeController,
registry: SettingsRegistry,
on_theme: callable,
) -> ft.Control:
content = ft.Column(expand=True, scroll=ft.ScrollMode.AUTO, spacing=14)
is_android = page.platform in {ft.PagePlatform.ANDROID, ft.PagePlatform.ANDROID_TV}
if not is_android:
controller.mode = RunMode.PROXY
def strategy_changed(event: ft.ControlEvent) -> None:
controller.select_strategy(
Strategy.ENHANCED if event.control.selected_index else Strategy.BASIC
)
on_theme(controller.strategy.value)
render()
def mode_changed(event: ft.ControlEvent) -> None:
selected = event.control.selected
if selected:
controller.mode = RunMode(selected[0])
render()
async def slider_changed(event: ft.ControlEvent) -> None:
if float(event.control.value or 0) < 0.8:
return
try:
await controller.connect()
except Exception as exc: # UI must show honest unsupported/error state.
controller.snapshot = controller.snapshot.model_copy(
update={"state": EngineState.ERROR, "message": controller.error_text(exc)}
)
render()
async def stop_click(_: ft.ControlEvent) -> None:
await controller.stop()
render()
async def share_click(_: ft.ControlEvent) -> None:
try:
await controller.start_hotspot_share()
except Exception as exc:
controller.snapshot = controller.snapshot.model_copy(
update={"state": EngineState.ERROR, "message": controller.error_text(exc)}
)
render()
if controller.snapshot.proxy_addresses:
show_share_dialog()
def show_share_dialog() -> None:
text = "\n".join(controller.snapshot.proxy_addresses)
image = io.BytesIO()
qrcode.make(text).save(image, format="PNG")
dialog = ft.AlertDialog(
modal=True,
title=ft.Text("Поделиться через Wi-Fi"),
content=ft.Column(
[
ft.Text("Подключите устройство к показанной точке доступа."),
ft.Text(text, selectable=True),
ft.Image(
src_base64=base64.b64encode(image.getvalue()).decode("ascii"),
width=220,
height=220,
),
],
tight=True,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
),
actions=[ft.TextButton("Закрыть", on_click=lambda _: page.close(dialog))],
)
page.open(dialog)
def render() -> None:
snapshot = controller.snapshot
backend = "SignBox"
capabilities = controller.capabilities()
engine_ready = capabilities.supports_proxy or capabilities.supports_vpn
mode_segments = []
if is_android and capabilities.supports_vpn:
mode_segments.append(ft.Segment(value=RunMode.VPN.value, label=ft.Text("TUN")))
if capabilities.supports_proxy:
mode_segments.append(ft.Segment(value=RunMode.PROXY.value, label=ft.Text("Proxy")))
if is_android and capabilities.supports_both:
mode_segments.append(ft.Segment(value=RunMode.BOTH.value, label=ft.Text("Both")))
if not mode_segments:
mode_segments = [ft.Segment(value=RunMode.PROXY.value, label=ft.Text("Proxy"))]
if controller.mode is not RunMode.PROXY:
controller.mode = RunMode.PROXY
content.controls = [
section_title("VPN", "Выберите стратегию и потяните слайдер вправо"),
ft.Tabs(
selected_index=0 if controller.strategy is Strategy.BASIC else 1,
tabs=[
ft.Tab(text="Основная", icon=ft.Icons.LIGHT_MODE),
ft.Tab(text="Усиленная", icon=ft.Icons.SHIELD),
],
on_change=strategy_changed,
),
ft.SegmentedButton(
selected=[controller.mode.value],
segments=mode_segments,
on_change=mode_changed,
),
connection_card(snapshot.state, controller.mode, backend, slider_changed, engine_ready),
ft.Row(
[
ft.OutlinedButton("Остановить", icon=ft.Icons.STOP, on_click=stop_click),
*(
[
ft.FloatingActionButton(
icon=ft.Icons.WIFI,
tooltip="Поделиться через Wi-Fi",
on_click=share_click,
)
]
if is_android and capabilities.supports_both
else []
),
]
),
card(
ft.Column(
[
ft.Text("Параметры", weight=ft.FontWeight.W_600),
ft.Text(f"MTU: {controller.vpn.mtu}"),
ft.Slider(
min=576,
max=1500,
divisions=77,
value=controller.vpn.mtu,
label="{value}",
on_change=lambda e: setattr(controller.vpn, "mtu", int(e.control.value)),
),
ft.Switch(
label="Split-tunneling",
value=controller.vpn.split_tunneling,
disabled=not is_android,
on_change=lambda e: setattr(
controller.vpn, "split_tunneling", bool(e.control.value)
),
),
ft.Text(
"Режимы TUN/Both и split-tunneling доступны на Android; desktop — proxy-only.",
color=ft.Colors.OUTLINE,
),
*([
ft.Text(
"SignBox native artifact/API ещё не подключён; подключение недоступно.",
color=ft.Colors.ERROR,
)
] if not engine_ready else []),
ft.Text(
"Настройки стратегии подключаются через этот экран.",
color=ft.Colors.OUTLINE,
),
ft.Text(
snapshot.message or "",
color=ft.Colors.ERROR if snapshot.message else None,
),
],
spacing=8,
)
),
]
if page is not None:
page.update()
registry.register("vpn", "VPN", lambda: content)
controller.add_listener(render)
render()
return content

155
app/screens/white_lists.py Normal file
View file

@ -0,0 +1,155 @@
from __future__ import annotations
import flet as ft
import flet_webview as fwv
from app.models import FreeTurnProfile
from app.screens.components import connection_card, section_title
from app.services.controller import RuntimeController
from app.services.settings_registry import SettingsRegistry
from app.theme import card
PRESET_TEXT = {
1: "4/4 · для сообщений",
2: "8/4 · для простого интернет-сёрфинга",
3: "12/6 · для видео в невысоком качестве",
4: "24/6 · для всего · ультра скорость, повышенная нагрузка",
}
CAPTCHA_ENTRY_URL = "https://vk.com"
def build_white_lists_screen(
page: ft.Page,
controller: RuntimeController,
registry: SettingsRegistry,
on_theme: callable,
) -> ft.Control:
content = ft.Column(expand=True, scroll=ft.ScrollMode.AUTO, spacing=14)
async def slide(event: ft.ControlEvent) -> None:
if float(event.control.value or 0) < 0.8:
return
try:
controller.select_freeturn()
await controller.connect()
except Exception as exc:
controller.snapshot = controller.snapshot.model_copy(
update={"state": "error", "message": controller.error_text(exc)}
)
render()
async def stop(_: ft.ControlEvent) -> None:
await controller.stop()
render()
def preset_change(event: ft.ControlEvent) -> None:
controller.freeturn.preset = int(round(float(event.control.value)))
render()
def stability_change(event: ft.ControlEvent) -> None:
controller.freeturn.stability_over_load = bool(event.control.value)
render()
def captcha_settings() -> ft.Control:
captcha_url = controller.snapshot.captcha_url or CAPTCHA_ENTRY_URL
def open_captcha(_: ft.ControlEvent, in_browser: bool = False) -> None:
if in_browser or controller.freeturn.captcha_always_browser:
page.launch_url(captcha_url)
return
dialog = ft.AlertDialog(
modal=True,
title=ft.Text("CAPTCHA"),
content=ft.Container(
fwv.WebView(url=captcha_url, expand=True),
width=520,
height=620,
),
actions=[
ft.TextButton(
"Открыть в браузере",
on_click=lambda _: page.launch_url(captcha_url),
),
ft.TextButton("Закрыть", on_click=lambda _: page.close(dialog)),
],
)
page.open(dialog)
return card(
ft.Column(
[
ft.Text("CAPTCHA", weight=ft.FontWeight.W_600),
ft.Text(
"Встроенное окно доступно на Android. На desktop используйте браузер.",
color=ft.Colors.OUTLINE,
),
ft.Switch(
label="Всегда открывать CAPTCHA в браузере",
value=controller.freeturn.captcha_always_browser,
on_change=lambda e: setattr(
controller.freeturn, "captcha_always_browser", bool(e.control.value)
),
),
ft.Row(
[
ft.FilledButton(
"Открыть внутри приложения",
on_click=open_captcha,
),
ft.OutlinedButton(
"Открыть в браузере",
on_click=lambda e: open_captcha(e, True),
),
]
),
],
spacing=8,
)
)
def render() -> None:
profile: FreeTurnProfile = controller.freeturn
threads, per_credential = profile.stream_settings
content.controls = [
section_title("Белые списки", "FreeTurn · отдельный runtime"),
connection_card(controller.snapshot.state, controller.mode, "FreeTurn", slide),
card(
ft.Column(
[
ft.Text("Скорость", weight=ft.FontWeight.W_600),
ft.Text(
PRESET_TEXT[profile.preset],
color=ft.Colors.ERROR if profile.preset == 4 else None,
),
ft.Slider(
min=1,
max=4,
divisions=3,
value=profile.preset,
label="Уровень {value}",
on_change=preset_change,
),
ft.Text(f"Потоки: {threads}/{per_credential}", color=ft.Colors.OUTLINE),
ft.Switch(
label="Повысить стабильность ценой нагрузки",
value=profile.stability_over_load,
disabled=profile.preset < 3,
on_change=stability_change,
),
],
spacing=8,
)
),
captcha_settings(),
ft.OutlinedButton("Остановить", icon=ft.Icons.STOP, on_click=stop),
ft.Text(
controller.snapshot.message or "",
color=ft.Colors.ERROR if controller.snapshot.message else None,
),
]
page.update()
registry.register("white-lists", "Белые списки", lambda: content)
controller.add_listener(render)
render()
return content

1
app/services/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Application services."""

26
app/services/billing.py Normal file
View file

@ -0,0 +1,26 @@
from __future__ import annotations
from datetime import date
from app.models import PaymentInfo
def payment_banner(payment: PaymentInfo, today: date | None = None) -> str | None:
today = today or date.today()
due = payment.due_date(today.year, today.month)
days = (due - today).days
if days < 0:
next_month = 1 if today.month == 12 else today.month + 1
next_year = today.year + 1 if today.month == 12 else today.year
due = payment.due_date(next_year, next_month)
days = (due - today).days
if days > 5:
return None
if days == 0:
return f"Сегодня день оплаты · {payment.monthly_price_rub} ₽"
return f"Оплата через {days} дн. · {due.strftime('%-d.%m')} · {payment.monthly_price_rub} ₽"
def month_key(today: date | None = None) -> str:
today = today or date.today()
return f"{today.year:04d}-{today.month:02d}"

168
app/services/controller.py Normal file
View file

@ -0,0 +1,168 @@
from __future__ import annotations
import base64
import json
from collections.abc import Callable
from app.core.errors import EngineError, UnsupportedEngineError
from app.core.factory import build_engine_manager
from app.models import (
CapabilitySet,
EngineKind,
FreeTurnProfile,
ProxyAccess,
ProxySettings,
RunMode,
RuntimeRequest,
RuntimeSnapshot,
SignBoxProfile,
Strategy,
VpnSettings,
)
class RuntimeController:
def __init__(self, native_bridge=None) -> None:
self.manager = build_engine_manager(native_bridge)
self.signbox = SignBoxProfile(name="Основная", strategy=Strategy.BASIC)
self.enhanced = SignBoxProfile(name="Усиленная", strategy=Strategy.ENHANCED)
self.freeturn = FreeTurnProfile(name="Белые списки")
self.proxy = ProxySettings()
self.vpn = VpnSettings()
self.mode = RunMode.VPN
self.strategy = Strategy.BASIC
self.engine_kind = EngineKind.SIGNBOX
self.snapshot = RuntimeSnapshot()
self._listeners: list[Callable[[], None]] = []
def add_listener(self, listener: Callable[[], None]) -> None:
self._listeners.append(listener)
def capabilities(self) -> CapabilitySet:
return self.manager.capabilities(self.engine_kind)
def _notify(self) -> None:
for listener in tuple(self._listeners):
listener()
@property
def active_profile_id(self) -> str:
if self.mode is RunMode.PROXY and self.strategy is Strategy.BASIC:
return self.signbox.id
return self.enhanced.id if self.strategy is Strategy.ENHANCED else self.signbox.id
async def connect(self) -> RuntimeSnapshot:
if self.engine_kind is EngineKind.SIGNBOX:
profile = self.enhanced if self.strategy is Strategy.ENHANCED else self.signbox
request = RuntimeRequest(
engine=EngineKind.SIGNBOX,
mode=self.mode,
profile_id=profile.id,
proxy=self.proxy,
vpn=self.vpn,
engine_config=self._signbox_config(profile),
)
else:
request = RuntimeRequest(
engine=EngineKind.FREETURN,
mode=RunMode.PROXY,
profile_id=self.freeturn.id,
proxy=self.proxy,
vpn=self.vpn,
engine_config=self._freeturn_config(),
)
self.snapshot = await self.manager.start(request)
return self.snapshot
async def stop(self) -> RuntimeSnapshot:
self.snapshot = await self.manager.stop()
return self.snapshot
async def start_hotspot_share(self) -> RuntimeSnapshot:
if self.engine_kind is not EngineKind.SIGNBOX:
raise UnsupportedEngineError("Wi-Fi sharing is available for VPN strategy only")
self.mode = RunMode.BOTH
self.proxy.access = ProxyAccess.HOTSPOT
return await self.connect()
async def refresh(self) -> RuntimeSnapshot:
self.snapshot = await self.manager.get_state()
if self.snapshot.engine is not None:
self.engine_kind = self.snapshot.engine
if self.snapshot.mode is not None:
self.mode = self.snapshot.mode
self._notify()
return self.snapshot
def select_freeturn(self) -> None:
self.engine_kind = EngineKind.FREETURN
self.strategy = Strategy.BASIC
self.mode = RunMode.PROXY
def select_strategy(self, strategy: Strategy) -> None:
self.engine_kind = EngineKind.SIGNBOX
self.strategy = strategy
def error_text(self, exc: Exception) -> str:
if isinstance(exc, UnsupportedEngineError):
return f"Недоступно: {exc}"
if isinstance(exc, EngineError):
return str(exc)
return "Не удалось изменить состояние соединения"
def _signbox_config(self, profile: SignBoxProfile) -> dict[str, object]:
if profile.raw_config:
try:
raw = json.loads(profile.raw_config)
if isinstance(raw, dict):
return raw
except json.JSONDecodeError:
return {"raw_config": profile.raw_config}
return {
**profile.config,
"strategy": profile.strategy.value,
"mtu": profile.mtu,
"split_tunneling": profile.split_tunneling,
}
def _freeturn_config(self) -> dict[str, object]:
threads, per_credential = self.freeturn.stream_settings
config = dict(self.freeturn.options)
config_json = self._freeturn_config_json()
if config_json:
config["config_json"] = config_json
config.update(
{
"key": self.freeturn.key,
"threads": threads,
"streams_per_cred": per_credential,
}
)
return config
def _freeturn_config_json(self) -> str:
raw = self.freeturn.options.get("config_json")
if not isinstance(raw, str) or not raw.strip():
raw = self.freeturn.key.strip()
if raw.startswith("freeturn://"):
encoded = raw.removeprefix("freeturn://")
try:
padding = "=" * (-len(encoded) % 4)
raw = base64.urlsafe_b64decode(encoded + padding).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return ""
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return ""
if not isinstance(parsed, dict):
return ""
parsed.setdefault("clientId", self.freeturn.id.replace("-", ""))
threads, per_credential = self.freeturn.stream_settings
turn = parsed.setdefault("turn", {})
if isinstance(turn, dict):
turn["n"] = threads
vk = parsed.get("vk")
if isinstance(vk, dict):
vk["streamsPerCred"] = per_credential
return json.dumps(parsed, separators=(",", ":"))

View file

@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class SettingsSection:
key: str
title: str
builder: Any
class SettingsRegistry:
def __init__(self) -> None:
self._sections: list[SettingsSection] = []
def register(self, key: str, title: str, builder: Any) -> None:
self._sections.append(SettingsSection(key, title, builder))
def sections(self) -> tuple[SettingsSection, ...]:
return tuple(self._sections)

View file

@ -0,0 +1,32 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
class StateStore:
"""Small JSON store for UI preferences; native runtime state is not stored here."""
def __init__(self) -> None:
storage_root = os.getenv("FLET_APP_STORAGE_DATA")
root = Path(
os.getenv(
"VPNAPP_STATE_DIR",
storage_root or str(Path.home() / ".vpnapp"),
)
)
root.mkdir(parents=True, exist_ok=True)
self.path = root / "ui-state.json"
def load(self) -> dict[str, Any]:
try:
return json.loads(self.path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError):
return {}
def save(self, value: dict[str, Any]) -> None:
temporary = self.path.with_suffix(".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(self.path)

View file

@ -0,0 +1,96 @@
from __future__ import annotations
import asyncio
import json
import urllib.error
import urllib.request
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from urllib.parse import parse_qs, urlsplit, urlunsplit
@dataclass(frozen=True)
class ClientVersion:
major: int = 0
minor: int = 1
def user_agent(self, device_id: str) -> str:
return f"vpnapp/v{self.major}.{self.minor}/{device_id}"
class SubscriptionError(RuntimeError):
pass
class SubscriptionClient:
"""Small device-side client for the backend contract.
Storage is injected deliberately: Android should provide a Keystore-backed
implementation through the native bridge, while desktop can use an OS
credential store. The client never logs the token or decrypted payload.
"""
def __init__(
self,
load_secret: Callable[[str], str | None],
save_secret: Callable[[str, str], None],
version: ClientVersion = ClientVersion(),
timeout: float = 15.0,
) -> None:
self._load_secret = load_secret
self._save_secret = save_secret
self.version = version
self.timeout = timeout
def enroll_url(self, url: str) -> dict[str, str]:
parsed = urlsplit(url)
query = parse_qs(parsed.query)
token = query.get("enroll", [""])[0]
parts = [part for part in parsed.path.split("/") if part]
if len(parts) != 2 or parts[0] != "d" or not parts[1] or not token:
raise SubscriptionError("enrollment URL must look like /d/{device_id}?enroll=...")
device_id = parts[1]
subscription_url = urlunsplit((parsed.scheme, parsed.netloc, f"/d/{device_id}", "", ""))
self._save_secret("device_id", device_id)
self._save_secret("device_token", token)
self._save_secret("subscription_url", subscription_url)
return {"device_id": device_id, "subscription_url": subscription_url}
async def refresh(self) -> dict[str, Any]:
url = self._load_secret("subscription_url")
device_id = self._load_secret("device_id")
token = self._load_secret("device_token")
if not url or not device_id or not token:
raise SubscriptionError("device is not enrolled")
headers = {
"Accept": "application/json",
"User-Agent": self.version.user_agent(device_id),
"X-Device-Token": token,
}
try:
return await asyncio.to_thread(self._request, url, headers)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:200]
raise SubscriptionError(f"subscription request failed ({exc.code}): {detail}") from exc
except (urllib.error.URLError, TimeoutError) as exc:
raise SubscriptionError("subscription server is unavailable") from exc
def apply(self, payload: dict[str, Any], controller: Any) -> None:
"""Map the server payload into the split local profile models."""
controller.signbox.config["vless_subscription_url"] = payload.get(
"vless_subscription_url", ""
)
controller.enhanced.config["amnezia_account_name"] = payload.get(
"amnezia_account_name", ""
)
controller.enhanced.config["amnezia_servers"] = payload.get("amnezia", {})
controller.freeturn.key = str(payload.get("freeturn_key", ""))
def _request(self, url: str, headers: dict[str, str]) -> dict[str, Any]:
request = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(request, timeout=self.timeout) as response:
value = json.loads(response.read().decode("utf-8"))
if not isinstance(value, dict):
raise SubscriptionError("subscription response is not an object")
return value

24
app/theme.py Normal file
View file

@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Any
import flet as ft
from app.models import Strategy
ACCENTS: dict[str, str] = {
"freeturn": "#9E9E9E",
Strategy.BASIC.value: "#8CC8FF",
Strategy.ENHANCED.value: "#E85D5D",
}
def apply_accent(page: ft.Page, key: str) -> None:
seed = ACCENTS.get(key, ACCENTS[Strategy.BASIC.value])
page.theme = ft.Theme(color_scheme_seed=seed)
page.dark_theme = ft.Theme(color_scheme_seed=seed)
page.theme_mode = ft.ThemeMode.SYSTEM
def card(content: Any) -> ft.Card:
return ft.Card(content=ft.Container(content=content, padding=16))

1
backend/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Small subscription backend."""

57
backend/admin.html Normal file
View file

@ -0,0 +1,57 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>VPN App — управление</title>
<style>
:root { color-scheme: light dark; font: 15px system-ui,sans-serif; }
body { max-width: 1100px; margin: 0 auto; padding: 24px; background: #f7f7fb; color: #202124; }
@media (prefers-color-scheme: dark) { body { background: #17181c; color: #eee; } }
section { background: color-mix(in srgb, Canvas 90%, transparent); border-radius: 18px; padding: 18px; margin: 16px 0; box-shadow: 0 2px 10px #0001; }
input,textarea,button { box-sizing: border-box; border: 1px solid #8885; border-radius: 10px; padding: 9px; margin: 4px; font: inherit; }
input,textarea { width: min(100%, 420px); } textarea { min-height: 80px; vertical-align: top; }
button { cursor: pointer; } table { width: 100%; border-collapse: collapse; } th,td { padding: 8px; border-bottom: 1px solid #8884; text-align: left; vertical-align: top; }
code { word-break: break-all; } .muted { opacity: .68; } .danger { color: #c62828; } #status { white-space: pre-wrap; }
</style>
</head>
<body>
<h1>VPN App · управление</h1>
<section>
<label>Админ-токен <input id="token" type="password" autocomplete="off"></label>
<button onclick="loadUsers()">Войти / обновить</button>
<div id="status" class="muted"></div>
</section>
<section>
<h2>Новый пользователь</h2>
<form onsubmit="createUser(event)">
<input name="name" placeholder="Имя" required>
<input name="price" type="number" min="0" placeholder="Руб./месяц" required>
<input name="day" type="number" min="1" max="31" placeholder="День оплаты" required>
<input name="vless" placeholder="VLESS subscription URL">
<input name="account" placeholder="Скрытое имя аккаунта Amnezia">
<input name="freeturn" placeholder="Ключ FreeTurn">
<button>Создать</button>
</form>
</section>
<section>
<h2>Пользователи</h2>
<div id="users" class="muted">Введите токен и нажмите «Войти / обновить».</div>
</section>
<script>
const token = document.querySelector('#token'); token.value = sessionStorage.getItem('vpnapp-admin-token') || '';
const statusEl = document.querySelector('#status');
function auth() { const value = token.value.trim(); sessionStorage.setItem('vpnapp-admin-token', value); return {'Authorization': 'Bearer ' + value, 'Content-Type':'application/json'}; }
async function api(path, options={}) { options.headers = {...auth(), ...(options.headers||{})}; const r=await fetch(path,options); const data=await r.json().catch(()=>({})); if(!r.ok) throw new Error(data.detail||r.statusText); return data; }
function esc(s) { return String(s??'').replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
async function loadUsers() { try { const data=await api('/admin/api/users'); renderUsers(data); statusEl.textContent='Готово'; } catch(e) { statusEl.textContent='Ошибка: '+e.message; } }
async function createUser(e) { e.preventDefault(); const f=new FormData(e.target); try { await api('/admin/api/users',{method:'POST',body:JSON.stringify({name:f.get('name'),monthly_price_rub:Number(f.get('price')),payment_day:Number(f.get('day')),vless_subscription_url:f.get('vless'),amnezia_account_name:f.get('account'),freeturn_key:f.get('freeturn')})}); e.target.reset(); await loadUsers(); } catch(x) { statusEl.textContent='Ошибка: '+x.message; } }
async function removeUser(id) { if(!confirm('Удалить пользователя и его устройства?')) return; await api('/admin/api/users/'+id,{method:'DELETE'}); loadUsers(); }
async function addDevice(id) { try { const d=await api('/admin/api/users/'+id+'/devices',{method:'POST'}); alert('Сохраните enrollment URL:\n'+d.enrollment_url+'\n\nSubscription URL:\n'+d.subscription_url+'\n\nТокен показывается только сейчас.'); loadUsers(); } catch(e) { statusEl.textContent='Ошибка: '+e.message; } }
async function removeDevice(id) { if(!confirm('Удалить устройство?')) return; await api('/admin/api/devices/'+id,{method:'DELETE'}); loadUsers(); }
async function editServer(userId) { const name=prompt('Название сервера:'); if(!name) return; const config=prompt('Конфиг AmneziaWG 3.1 для сервера:'); if(!config) return; try { await api('/admin/api/users/'+userId+'/servers',{method:'POST',body:JSON.stringify({server_name:name,amnezia_config:config})}); await loadUsers(); } catch(e) { statusEl.textContent='Ошибка: '+e.message; } }
async function removeServer(userId,name) { if(!confirm('Удалить конфиг сервера?')) return; try { await api('/admin/api/users/'+userId+'/servers/'+encodeURIComponent(name),{method:'DELETE'}); await loadUsers(); } catch(e) { statusEl.textContent='Ошибка: '+e.message; } }
function renderUsers(users) { if(!users.length){document.querySelector('#users').textContent='Пользователей пока нет.';return;} document.querySelector('#users').innerHTML='<table><tr><th>Пользователь</th><th>Оплата</th><th>Устройства</th><th>Amnezia-серверы</th><th></th></tr>'+users.map(u=>`<tr><td><strong>${esc(u.name)}</strong><br><span class="muted">${esc(u.id)}</span></td><td>${u.monthly_price_rub} ₽ / мес<br>день ${u.payment_day}</td><td>${u.devices.map(d=>`<div><code>${esc(d.id)}</code><br><span class="muted">последнее: ${esc(d.last_seen_at||'—')}<br>UA: ${esc(d.last_user_agent||'—')}</span><br><button onclick="removeDevice('${esc(d.id)}')">Удалить устройство</button></div>`).join('<hr>')||'—'}<br><button onclick="addDevice('${esc(u.id)}')">Добавить устройство / ссылку</button></td><td>${u.server_configs.map(s=>`<div><code>${esc(s.server_name)}</code> <button onclick="removeServer('${esc(u.id)}','${esc(s.server_name)}')">×</button></div>`).join('')||'—'}<br><button onclick="editServer('${esc(u.id)}')">Добавить/изменить конфиг</button></td><td><button class="danger" onclick="removeUser('${esc(u.id)}')">Удалить пользователя</button></td></tr>`).join('')+'</table>'; }
</script>
</body>
</html>

30
backend/config.py Normal file
View file

@ -0,0 +1,30 @@
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
admin_token: str
secret_key: str
base_url: str = "http://127.0.0.1:8080"
database_path: str = "backend/vpnapp.db"
@classmethod
def from_env(cls) -> Settings:
admin_token = os.getenv("VPNAPP_ADMIN_TOKEN", "")
secret_key = os.getenv("VPNAPP_SECRET_KEY", "")
if not admin_token:
raise RuntimeError("VPNAPP_ADMIN_TOKEN must be set")
if not secret_key:
raise RuntimeError(
"VPNAPP_SECRET_KEY must be set to a Fernet key; generate one with "
"python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'"
)
return cls(
admin_token=admin_token,
secret_key=secret_key,
base_url=os.getenv("VPNAPP_BASE_URL", "http://127.0.0.1:8080").rstrip("/"),
database_path=os.getenv("VPNAPP_DATABASE", "backend/vpnapp.db"),
)

183
backend/db.py Normal file
View file

@ -0,0 +1,183 @@
from __future__ import annotations
import secrets
import sqlite3
import uuid
from datetime import UTC, datetime
from pathlib import Path
from backend.security import SecretBox, token_hash, token_matches
def now_iso() -> str:
return datetime.now(UTC).isoformat()
class Database:
def __init__(self, path: str, secrets_box: SecretBox) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self.box = secrets_box
self._init_schema()
def connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
def _init_schema(self) -> None:
with self.connect() as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
monthly_price_rub INTEGER NOT NULL CHECK(monthly_price_rub >= 0),
payment_day INTEGER NOT NULL CHECK(payment_day BETWEEN 1 AND 31),
vless_subscription_url TEXT NOT NULL DEFAULT '',
amnezia_account_name TEXT NOT NULL DEFAULT '',
freeturn_key TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_name TEXT NOT NULL,
amnezia_config TEXT NOT NULL,
UNIQUE(user_id, server_name)
);
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
bound_user_agent TEXT,
created_at TEXT NOT NULL,
last_seen_at TEXT,
last_user_agent TEXT,
revoked_at TEXT
);
"""
)
def list_users(self) -> list[dict]:
with self.connect() as db:
users = [
dict(row) for row in db.execute("SELECT * FROM users ORDER BY created_at DESC")
]
for user in users:
user["devices"] = [
dict(row)
for row in db.execute(
"SELECT id, created_at, last_seen_at, last_user_agent, bound_user_agent, revoked_at "
"FROM devices WHERE user_id = ? ORDER BY created_at DESC",
(user["id"],),
)
]
user["server_configs"] = [
dict(row)
for row in db.execute(
"SELECT id, server_name FROM server_configs WHERE user_id = ? ORDER BY server_name",
(user["id"],),
)
]
for user in users:
user.pop("vless_subscription_url", None)
user.pop("amnezia_account_name", None)
user.pop("freeturn_key", None)
return users
def create_user(self, data: dict) -> dict:
user_id = str(uuid.uuid4())
timestamp = now_iso()
with self.connect() as db:
db.execute(
"INSERT INTO users VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
user_id,
data["name"],
data["monthly_price_rub"],
data["payment_day"],
self.box.seal(data.get("vless_subscription_url", "")),
self.box.seal(data.get("amnezia_account_name", "")),
self.box.seal(data.get("freeturn_key", "")),
timestamp,
timestamp,
),
)
return {"id": user_id, "name": data["name"]}
def delete_user(self, user_id: str) -> bool:
with self.connect() as db:
cursor = db.execute("DELETE FROM users WHERE id = ?", (user_id,))
return cursor.rowcount == 1
def create_device(self, user_id: str, base_url: str) -> dict:
device_id = str(uuid.uuid4())
token = secrets.token_urlsafe(32)
with self.connect() as db:
exists = db.execute("SELECT 1 FROM users WHERE id = ?", (user_id,)).fetchone()
if exists is None:
raise KeyError("user not found")
db.execute(
"INSERT INTO devices (id, user_id, token_hash, created_at) VALUES (?, ?, ?, ?)",
(device_id, user_id, token_hash(token), now_iso()),
)
return {
"device_id": device_id,
"subscription_url": f"{base_url}/d/{device_id}",
"enrollment_url": f"{base_url}/d/{device_id}?enroll={token}",
"device_token": token,
}
def delete_device(self, device_id: str) -> bool:
with self.connect() as db:
cursor = db.execute("DELETE FROM devices WHERE id = ?", (device_id,))
return cursor.rowcount == 1
def bind_and_get_subscription(
self, device_id: str, token: str, user_agent: str, user_agent_device: str
) -> dict:
now = now_iso()
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
device = db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)).fetchone()
if device is None or device["revoked_at"]:
raise KeyError("device not found or revoked")
if not token_matches(token, device["token_hash"]):
raise PermissionError("invalid device credential")
if user_agent_device != device_id:
raise ValueError("device id in user-agent does not match URL")
bound = device["bound_user_agent"]
if bound is not None and bound != user_agent:
raise PermissionError("device is already bound to a different installation")
if bound is None:
db.execute(
"UPDATE devices SET bound_user_agent = ? WHERE id = ?", (user_agent, device_id)
)
db.execute(
"UPDATE devices SET last_seen_at = ?, last_user_agent = ? WHERE id = ?",
(now, user_agent, device_id),
)
user = db.execute("SELECT * FROM users WHERE id = ?", (device["user_id"],)).fetchone()
configs = db.execute(
"SELECT server_name, amnezia_config FROM server_configs WHERE user_id = ?",
(device["user_id"],),
).fetchall()
if user is None:
raise KeyError("user not found")
return {
"user_id": user["id"],
"name": user["name"],
"monthly_price_rub": user["monthly_price_rub"],
"payment_day": user["payment_day"],
"vless_subscription_url": self.box.open(user["vless_subscription_url"]),
"amnezia_account_name": self.box.open(user["amnezia_account_name"]),
"amnezia": {
row["server_name"]: self.box.open(row["amnezia_config"]) for row in configs
},
"freeturn_key": self.box.open(user["freeturn_key"]),
"device_id": device_id,
"updated_at": user["updated_at"],
}

116
backend/main.py Normal file
View file

@ -0,0 +1,116 @@
from __future__ import annotations
from pathlib import Path
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
from fastapi.responses import HTMLResponse
from backend.config import Settings
from backend.db import Database
from backend.schemas import ServerConfigCreate, UserCreate
from backend.security import SecretBox, parse_user_agent
settings = Settings.from_env()
database = Database(settings.database_path, SecretBox(settings.secret_key))
app = FastAPI(title="VPN App backend", docs_url=None, redoc_url=None)
ADMIN_HTML = (Path(__file__).parent / "admin.html").read_text(encoding="utf-8")
def require_admin(authorization: str | None = Header(default=None)) -> None:
expected = f"Bearer {settings.admin_token}"
if authorization != expected:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token")
@app.get("/admin", response_class=HTMLResponse)
def admin_page() -> str:
return ADMIN_HTML
@app.get("/admin/api/users", dependencies=[Depends(require_admin)])
def list_users() -> list[dict]:
return database.list_users()
@app.post("/admin/api/users", dependencies=[Depends(require_admin)])
def create_user(payload: UserCreate) -> dict:
return database.create_user(payload.model_dump())
@app.delete("/admin/api/users/{user_id}", dependencies=[Depends(require_admin)])
def delete_user(user_id: str) -> dict:
if not database.delete_user(user_id):
raise HTTPException(status_code=404, detail="user not found")
return {"deleted": user_id}
@app.post("/admin/api/users/{user_id}/devices", dependencies=[Depends(require_admin)])
def create_device(user_id: str) -> dict:
try:
return database.create_device(user_id, settings.base_url)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.delete("/admin/api/devices/{device_id}", dependencies=[Depends(require_admin)])
def delete_device(device_id: str) -> dict:
if not database.delete_device(device_id):
raise HTTPException(status_code=404, detail="device not found")
return {"deleted": device_id}
@app.post("/admin/api/users/{user_id}/servers", dependencies=[Depends(require_admin)])
def upsert_server(user_id: str, payload: ServerConfigCreate) -> dict:
with database.connect() as db:
if db.execute("SELECT 1 FROM users WHERE id = ?", (user_id,)).fetchone() is None:
raise HTTPException(status_code=404, detail="user not found")
db.execute(
"INSERT INTO server_configs (id,user_id,server_name,amnezia_config) VALUES (?,?,?,?) "
"ON CONFLICT(user_id,server_name) DO UPDATE SET amnezia_config=excluded.amnezia_config",
(
user_id + ":" + payload.server_name,
user_id,
payload.server_name,
database.box.seal(payload.amnezia_config),
),
)
return {"user_id": user_id, "server_name": payload.server_name}
@app.delete(
"/admin/api/users/{user_id}/servers/{server_name}",
dependencies=[Depends(require_admin)],
)
def delete_server(user_id: str, server_name: str) -> dict:
with database.connect() as db:
cursor = db.execute(
"DELETE FROM server_configs WHERE user_id = ? AND server_name = ?",
(user_id, server_name),
)
if cursor.rowcount != 1:
raise HTTPException(status_code=404, detail="server config not found")
return {"deleted": server_name}
@app.get("/d/{device_id}")
def device_subscription(
device_id: str,
request: Request,
enroll: str | None = Query(default=None),
x_device_token: str | None = Header(default=None),
) -> dict:
token = x_device_token or enroll
if not token:
raise HTTPException(status_code=401, detail="X-Device-Token is required")
user_agent = request.headers.get("user-agent", "")
user_agent_device = parse_user_agent(user_agent)
if user_agent_device is None:
raise HTTPException(status_code=400, detail="invalid device user-agent")
try:
return database.bind_and_get_subscription(device_id, token, user_agent, user_agent_device)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc

20
backend/pyproject.toml Normal file
View file

@ -0,0 +1,20 @@
[project]
name = "vpnapp-backend"
version = "0.1.0"
description = "Small operator and device-subscription backend for VPN App."
requires-python = ">=3.12,<3.14"
dependencies = [
"cryptography>=45.0.0",
"fastapi>=0.116.0",
"pydantic>=2.11.0",
"uvicorn[standard]>=0.35.0",
]
[dependency-groups]
dev = ["pytest>=8.4.0"]
[tool.uv]
package = false
[tool.pytest.ini_options]
pythonpath = [".."]

21
backend/schemas.py Normal file
View file

@ -0,0 +1,21 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class UserCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
monthly_price_rub: int = Field(ge=0)
payment_day: int = Field(ge=1, le=31)
vless_subscription_url: str = ""
amnezia_account_name: str = ""
freeturn_key: str = ""
class AdminToken(BaseModel):
token: str
class ServerConfigCreate(BaseModel):
server_name: str = Field(min_length=1, max_length=120)
amnezia_config: str = Field(min_length=1)

33
backend/security.py Normal file
View file

@ -0,0 +1,33 @@
from __future__ import annotations
import hashlib
import hmac
import re
from cryptography.fernet import Fernet
DEVICE_UA_RE = re.compile(r"^vpnapp/v(?P<major>\d+)\.(?P<minor>\d+)/(?P<device>[0-9a-fA-F-]{36})$")
class SecretBox:
def __init__(self, key: str) -> None:
self._fernet = Fernet(key.encode("ascii"))
def seal(self, value: str) -> str:
return self._fernet.encrypt(value.encode("utf-8")).decode("ascii")
def open(self, value: str) -> str:
return self._fernet.decrypt(value.encode("ascii")).decode("utf-8")
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def token_matches(token: str, expected_hash: str) -> bool:
return hmac.compare_digest(token_hash(token), expected_hash)
def parse_user_agent(user_agent: str) -> str | None:
match = DEVICE_UA_RE.fullmatch(user_agent.strip())
return match.group("device") if match else None

View file

@ -0,0 +1,41 @@
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from backend.db import Database
from backend.security import SecretBox, parse_user_agent, token_hash, token_matches
def test_device_binding_and_secret_storage(tmp_path) -> None:
box = SecretBox(Fernet.generate_key().decode())
database = Database(str(tmp_path / "vpn.db"), box)
user = database.create_user(
{
"name": "Alice",
"monthly_price_rub": 500,
"payment_day": 31,
"vless_subscription_url": "https://secret/vless",
"amnezia_account_name": "hidden",
"freeturn_key": "private-key",
}
)
device = database.create_device(user["id"], "https://sub.example.com")
assert token_matches(device["device_token"], token_hash(device["device_token"]))
ua = f"vpnapp/v1.2/{device['device_id']}"
payload = database.bind_and_get_subscription(
device["device_id"], device["device_token"], ua, device["device_id"]
)
assert payload["vless_subscription_url"] == "https://secret/vless"
with pytest.raises(PermissionError):
database.bind_and_get_subscription(
device["device_id"], device["device_token"], "vpnapp/v1.2/other", device["device_id"]
)
listed = database.list_users()[0]
assert "vless_subscription_url" not in listed
assert box.seal("secret") != "secret"
def test_user_agent_parser() -> None:
assert parse_user_agent("vpnapp/v1.2/123e4567-e89b-12d3-a456-426614174000")
assert parse_user_agent("bad-agent") is None

518
backend/uv.lock generated Normal file
View file

@ -0,0 +1,518 @@
version = 1
revision = 3
requires-python = ">=3.12, <3.14"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "50.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
]
[[package]]
name = "fastapi"
version = "0.141.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
]
[[package]]
name = "starlette"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uvicorn"
version = "0.53.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
]
[[package]]
name = "vpnapp-backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=45.0.0" },
{ name = "fastapi", specifier = ">=0.116.0" },
{ name = "pydantic", specifier = ">=2.11.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.4.0" }]
[[package]]
name = "watchfiles"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
{ url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
{ url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
{ url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
{ url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
{ url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
{ url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
{ url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
{ url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
{ url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
{ url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
]
[[package]]
name = "websockets"
version = "17.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" },
{ url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" },
{ url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" },
{ url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" },
{ url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" },
{ url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" },
{ url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" },
{ url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" },
{ url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" },
{ url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" },
{ url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" },
{ url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" },
{ url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" },
{ url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" },
{ url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" },
{ url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" },
{ url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" },
{ url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" },
{ url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" },
{ url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" },
{ url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" },
{ url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" },
{ url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" },
{ url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" },
{ url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" },
{ url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" },
{ url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" },
{ url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" },
{ url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" },
{ url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" },
{ url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" },
{ url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" },
{ url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" },
{ url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" },
{ url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" },
{ url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" },
{ url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" },
{ url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" },
{ url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" },
{ url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" },
]

View file

@ -0,0 +1,29 @@
# Android manual/integration tests
These require a real Android device; an emulator cannot validate all hotspot/VPN ROM
behaviour.
1. Grant VPN consent, connect/disconnect Basic VLESS and Enhanced AmneziaWG 3.1.
2. Lock the screen and kill/reopen the Flet Activity. Native state must remain visible;
a second engine/service must not start.
3. Force-stop/restart and verify the documented recovery limitation: Android does not
guarantee recovery after an explicit force-stop.
4. Kill the engine and verify `ERROR`/`UNSUPPORTED`, no orphan process, and no fallback.
5. `DIRECT`/`VOID` are currently a model-only gate and must remain visibly unavailable;
after native routing is implemented, `DIRECT` must use the Android API and `VOID`
must drop in the native data path, never `addDisallowedApplication`.
6. After an adapter reports real listener addresses, start LocalOnlyHotspot, check actual
SSID/password/interface IP, and confirm listeners bind only to that IP. Verify SOCKS5
and HTTP CONNECT from a laptop on that network. Current adapters intentionally do not
claim this path.
7. Stop sharing and verify listeners close and the reservation is released.
8. Run Both and verify phone TUN plus laptop proxy use the same backend only when the
capability says `supports_both`.
9. Switch SignBox strategies and then FreeTurn. Conflicting runtimes must stop before
another one starts.
10. Deny VPN/hotspot permissions and verify an explicit error, never `CONNECTED`.
11. Trigger FreeTurn CAPTCHA. Verify in-app WebView where available, browser fallback,
and the “always open in browser” setting.
12. Run an aggressive VPN-detection app. Expected VOID result is limited: local VPN/TUN
signs may remain visible, but probes/telemetry entering our VPN data path must be
dropped. No stealth guarantee is made.

102
docs/architecture.md Normal file
View file

@ -0,0 +1,102 @@
# Архитектура
## Scope
Текущий продукт — Android + desktop. iOS исключён. macOS не собирается в первом MVP;
если платформа вернётся, на ней разрешён только proxy-only до отдельного native review.
В приложении две верхние вкладки: `VPN` и `Белые списки`.
- `VPN` использует `SignBox` как один движок для стратегий `Основная` (VLESS) и
`Усиленная` (AmneziaWG 3.1).
- `Белые списки` использует отдельный `FreeTurn` runtime. FreeTurn не является
VPN-стратегией SignBox.
## Boundaries
```text
Flet/Python
models, profiles, UI, theme, payment banner, settings registry
|
| versioned idempotent commands
v
Flet service extension / MethodChannel
|
v
Android native services (separate lifetime from Activity)
VpnRuntimeService (VpnService + foreground)
ProxyRuntimeService (foreground + proxy/hotspot)
|
+-----+--------------------+
| SignBox/libbox adapter | FreeTurn mobile adapter
| TUN/proxy/stats | Start/StartTunnel/Stop/Captcha
```
Python does not start an Android subprocess and does not own VPN lifetime. On desktop,
the SignBox adapter may own a configured executable with `asyncio` process management;
FreeTurn desktop is explicitly unsupported.
Android state is authoritative in native `SharedPreferences`. Reopening the Flet UI must
call `get_runtime_state`; missing Python state never implies that a service is stopped.
The current native bridge already has this contract, but the engine AAR API still must
be pinned before the adapter can report `RUNNING`.
## Engine abstraction
`app/core/interface.py` defines the common lifecycle. `BackendRegistry` and
`EngineManager` select adapters by registry and enforce one active engine. Capabilities
are checked before start. Unsupported combinations raise `UnsupportedEngineError` and
are rendered as `Недоступно`, not as connected.
Internals remain different:
- Once the exact native artifact is supplied, SignBox can expose VLESS and AmneziaWG
packet/TUN plus local SOCKS/HTTP. The current adapter deliberately reports
`UNSUPPORTED` until that API is pinned and implemented.
- FreeTurn uses its own mobile binding and event/CAPTCHA lifecycle. Its `Start` API is
not a generic SOCKS5/HTTP listener; standard sharing remains unsupported until a
separately implemented listener reports actual bound addresses.
- `VOID` is an Android VPN data-path policy and must be enforced before an engine or
direct fallback. It must never be implemented as `addDisallowedApplication`, because
Android then sends that app directly.
## Modes and sharing
Desktop is proxy-only in the first release. Android supports `TUN`, `Proxy`, and
`Both` only when the selected native artifact advertises the matching capability.
Proxy defaults to `127.0.0.1`; Wi-Fi sharing uses `LocalOnlyHotspot`, discovers its
actual interface address, and binds listeners only after the hotspot callback. It never
assumes `192.168.43.1`, does not use `0.0.0.0` as a user default, and does not use
system tethering/NAT. SOCKS5 and HTTP CONNECT share one optional credential pair.
## Backend and devices
The backend has separate operator and device authentication:
- operator CRUD uses one configured admin Bearer token;
- each device gets a UUID4 and a separate random credential, stored server-side only
as SHA-256 hash;
- enrollment is one-time via an admin-created URL; the first request must use
`vpnapp/vMAJOR.MINOR/DEVICE_UUID` and binds that User-Agent;
- subsequent requests use `X-Device-Token` over HTTPS and the same bound User-Agent;
- UUID/User-Agent alone are not secrets and cannot authenticate.
Secrets in SQLite are Fernet-encrypted with `VPNAPP_SECRET_KEY`. The admin HTML never
lists secret fields after creation. A stronger future option is a hardware-backed key
and signed requests with replay protection.
## Theme and UI
The accent is derived from active top-level/strategy context: neutral gray for FreeTurn,
light blue for Basic, red for Enhanced, while `ThemeMode.SYSTEM` respects the device
light/dark mode. Screens register their settings sections in `SettingsRegistry`, so the
settings screen can grow without a universal nullable profile.
## Deliberate non-goals
- iOS, Personal Hotspot automation and iOS per-app routing.
- Android application routing (`DIRECT`/`VOID`) until native UID ownership and the
fail-closed packet drop path are implemented and tested on real devices.
- bundled native cores without pinned source, license, ABI and API evidence.
- management/debug listeners exposed by SignBox.

26
docs/backend.md Normal file
View file

@ -0,0 +1,26 @@
# Backend contract
The admin UI is a single `backend/admin.html` file. It sends the configured admin
Bearer token in memory/session storage and calls only `/admin/api/*` endpoints.
## Device flow
1. Operator creates a user and device in the page.
2. Backend returns a one-time `enrollment_url` and a stable `subscription_url`.
3. The app makes the first request with `X-Device-Token: <random token>` and
`User-Agent: vpnapp/vMAJOR.MINOR/DEVICE_UUID`.
4. Backend checks URL UUID, credential, and User-Agent UUID atomically, records the
User-Agent, and returns the user's decrypted-for-transport config payload.
5. Polling every few hours uses the same device token and bound User-Agent. A different
installation cannot reuse the same device binding unless the token is copied; UUID
and User-Agent alone never authenticate.
The device endpoint must be deployed behind TLS. The admin token is not accepted on
`/d/{device_id}`. Server configs and user secrets are Fernet-encrypted at rest with
`VPNAPP_SECRET_KEY`; only sanitized fields are shown in the admin list.
The reusable client-side request code is in `app/services/subscription.py`. It accepts
an injected secret store, emits the required User-Agent, performs one-time enrollment,
refreshes the device subscription asynchronously, and maps the response into the
separate VLESS/Amnezia/FreeTurn profile models. Android should connect its injected
store to Keystore-backed native storage before shipping credentials.

70
docs/building.md Normal file
View file

@ -0,0 +1,70 @@
# Сборка и запуск
## Development
```bash
uv sync
uv run pytest
uv run flet run app/main.py
```
`uv` уже был установлен в рабочем окружении. Flet current build creates a Flutter
shell and packages the Python app; the native Android bridge is registered as a local
Flet extension from `native/android/flet_vpn_bridge`.
## Backend
Generate a Fernet key and configure the operator token:
```bash
export VPNAPP_ADMIN_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
export VPNAPP_SECRET_KEY="$(uv run --project backend python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"
export VPNAPP_BASE_URL=http://127.0.0.1:8080
./scripts/run-backend.sh
```
Open `http://127.0.0.1:8080/admin`. The page is one self-contained HTML file with no
frontend build. It asks for the configured token, lists users/devices, creates/deletes
them and generates enrollment/subscription links. Deploy behind HTTPS and do not put
the admin token in a public URL.
## Android
The Flet shell can be built without native core artifacts; runtime connections then
return `UNSUPPORTED` honestly. A release build can enforce both artifacts:
```bash
./scripts/fetch-engine-artifacts.sh
REQUIRE_NATIVE_ARTIFACTS=1 ./scripts/build-android.sh
```
For a native-bridge/permissions smoke APK without cores, run
`./scripts/build-android.sh`. The script targets `arm64-v8a`.
Build prerequisites are current JDK/Android SDK/Flutter as required by Flet. Use a
device/emulator with API 26+ for LocalOnlyHotspot; API 33+ needs runtime
`NEARBY_WIFI_DEVICES`. Android VPN consent must be granted interactively by the user.
FreeTurn AAR:
```bash
git clone --depth 1 --branch <pinned-ref> https://github.com/samosvalishe/free-turn-proxy.git /tmp/free-turn-proxy
cd /tmp/free-turn-proxy
task build:android
cp dist/freeturn.aar /path/to/repo/native/android/libs/
```
For SignBox, use the exact artifact that exposes the API expected by
`native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/.../EngineAdapter.kt`. A generic sing-box
`libbox.aar` is not silently assumed to be the user's private SignBox fork. Record its
commit, build tags, ABIs, license and SHA-256 in `THIRD_PARTY_NOTICES.md`, then replace
the adapter with calls from that artifact and add a native integration test.
## Desktop
```bash
SIGNBOX_BIN=/absolute/path/to/sign-box ./scripts/build-desktop.sh linux
SIGNBOX_BIN=/absolute/path/to/sign-box.exe ./scripts/build-desktop.sh windows
```
Desktop SignBox proxy mode is process based. System proxy changes are not performed.

View file

@ -0,0 +1,38 @@
# Текущий статус MVP
## Проверено локально
- Flet web shell starts; Linux desktop bundle builds.
- ARM64 Android APK builds with current Flet/Gradle toolchain.
- FreeTurn `freeturn.aar` from the pinned upstream commit is embedded as
`classes.jar + jniLibs`; Kotlin reflection calls the documented mobile API,
installs `EventSink`/`Protector`, and keeps its state in the foreground service.
- FastAPI backend, one-file admin UI, encrypted-at-rest secrets, device enrollment,
User-Agent binding and subscription response pass tests and a local HTTP smoke test.
- Python lifecycle, conflict rules, serialization, billing date, proxy settings,
enrollment URL and port-selection tests pass.
## Deliberately unavailable until the missing native work is supplied
- SignBox VLESS/AmneziaWG 3.1: the exact private AAR/API was not supplied. The
native adapter and Android VPN modes report `UNSUPPORTED`; no generic sing-box
artifact is guessed.
- Android SOCKS5/HTTP listener, Wi-Fi QR sharing and `Both`: the current adapter
does not report actual bound listeners, so hotspot sharing fails closed instead
of showing ports that do not exist.
- `DIRECT`/`VOID` application routing: models and UI boundary exist, but UID
ownership, `addDisallowedApplication` for DIRECT and native fail-closed packet
dropping for VOID are not implemented.
- Backend sync is a reusable client contract with injected secret storage; wiring
it to Android Keystore, an enrollment screen and automatic profile refresh remains.
These are functional blockers, not cosmetic polish. A successful APK build is only a
build/integration milestone; it is not evidence that the missing data path works.
## Device acceptance still required
Use `docs/android-manual-tests.md` on a physical Android API 26+ device after the
SignBox artifact and listener implementation are added. In particular test VPN
consent, screen lock/UI death, service conflict, backend crash, hotspot permissions,
actual interface address, laptop SOCKS/HTTP traffic and the aggressive VPN-detection
VOID case.

50
docs/upstream-research.md Normal file
View file

@ -0,0 +1,50 @@
# Upstream/API research snapshot
Checked 2026-09-22.
## Flet
Current Flet publishing docs describe a generated Flutter shell and support
`[tool.flet.app] path/module`, local developer packages and Flutter user extensions.
The bridge uses a Flet `Service` control and a Dart `FletService`, then a standard
Flutter `MethodChannel` for the Android plugin. It is intentionally not a Python
subprocess bridge.
## Android
Current Android docs require `VpnService.prepare()`, `protect()` for upstream sockets,
`Builder.establish()`, and a foreground service on API 26+. Android 14+ foreground
services require a declared type; this project uses the documented `specialUse` type and
an explanatory property. Android has one active VPN service per user/profile.
`Builder.addDisallowedApplication()` is valid for `DIRECT`, but it makes traffic bypass
the VPN and therefore cannot implement `VOID`. `VOID` needs a UID-aware native packet
path and a fail-closed drop policy; the Python model exists now, while enforcement is a
device-tested native milestone.
`LocalOnlyHotspot` is the public API for an app-created local network. Android 13+
requires `NEARBY_WIFI_DEVICES`; older targets use location permission. The reservation
returns SSID/password but the interface address must be discovered at runtime. The
implementation does not assume a fixed `192.168.43.1` address and does not enable
system tethering.
## FreeTurn
The current `samosvalishe/free-turn-proxy` mobile README documents the gomobile API:
`Start`, `StartTunnel`, `StartDirectTunnel`, `Restart`, `Stop`, `GetState`,
`TunnelStats`, `ParseTunnelConfig`, `SetProtect`, `SetEventSink`, `ValidateConfig` and
`OnCaptcha`. The Android AAR is built with the upstream `task build:android` target.
The Android reference app is GPL-3.0 and is used only as lifecycle/API evidence.
## SignBox / sing-box
The exact public name `signbox` and a public artifact matching the user's VLESS +
AmneziaWG 3.1 claim were not found. Upstream sing-box exposes `libbox.aar` through
gomobile, but its package/API and build tags must be checked against the actual SignBox
artifact before compiling calls. The repo therefore has a real adapter boundary and a
hard `UNSUPPORTED` result rather than a fabricated success.
## Removed scope
iOS and NetworkExtension are not implemented. macOS is omitted from the first build;
desktop target is Linux/Windows proxy-only.

40
native/android/README.md Normal file
View file

@ -0,0 +1,40 @@
# Android native layer
This directory is a Flet Flutter service extension. The Python `NativeBridge` control
sends small versioned commands over the extension; Kotlin owns the actual Android
lifecycle.
The native layer is deliberately split from the Flet Activity:
```text
Python/Flet -> FletService -> MethodChannel -> Kotlin bridge
-> VpnRuntimeService (VpnService)
-> ProxyRuntimeService (foreground service)
-> FreeTurn/libbox adapters
```
`VpnRuntimeService` calls `VpnService.prepare()` through the bridge, starts foreground
immediately, configures a predictable `10.0.0.1/24` TUN, and persists state in
`SharedPreferences`. The current adapter refuses to report success unless the expected
native engine classes are bundled. This avoids a fake VPN that installs a black-hole
interface.
## Native artifacts
Put reviewable, pinned artifacts in `libs/` locally:
- `freeturn.aar` from the FreeTurn `mobile` package.
- `libbox.aar` or the exact private SignBox AAR with VLESS + AmneziaWG 3.1 support.
The files are git-ignored. Record version, commit and SHA-256 in
`THIRD_PARTY_NOTICES.md` before distributing an APK. `scripts/fetch-engine-artifacts.sh`
prints the upstream build commands and checks for the files; it does not silently
download an unpinned binary.
`scripts/build-android.sh` extracts each present AAR's `classes.jar` and `jni/*` into
the packaged Flutter plugin. This is required because Gradle does not allow a direct
local `.aar` dependency while producing the plugin AAR. A missing artifact does not
make a smoke APK pretend to work: the corresponding adapter returns `UNSUPPORTED`.
The first native milestone still needs device testing for VPN consent, foreground
service timing, packet forwarding, hotspot interface discovery, and engine ABI/API.

View file

@ -0,0 +1,19 @@
[project]
name = "vpn-native-bridge"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["flet>=0.86.0"]
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[tool.flet.extensions]
vpn_native_bridge = "vpn_native_bridge.Extension"
[tool.setuptools.packages.find]
where = ["src"]
include = ["vpn_native_bridge*", "flutter.vpn_native_bridge*"]
[tool.setuptools.package-data]
"flutter.vpn_native_bridge" = ["**/*"]

View file

@ -0,0 +1 @@
"""Namespace package containing the bundled Flutter extension."""

View file

@ -0,0 +1 @@
"""Packaged Flutter extension files for Flet build discovery."""

View file

@ -0,0 +1,55 @@
group 'su.snw.vpn_native_bridge'
version '0.1.0'
buildscript {
ext.kotlin_version = '2.1.20'
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:8.9.1"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
namespace 'su.snw.vpn_native_bridge'
compileSdk 35
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
defaultConfig {
minSdk 26
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
// The build script extracts reviewed AARs into a local JAR plus jniLibs.
// Gradle cannot embed a direct local .aar dependency while producing this
// plugin AAR, while a local JAR and native library are packaged correctly.
if (file("libs/freeturn-classes.jar").exists()) {
implementation files("libs/freeturn-classes.jar")
}
if (file("libs/libbox-classes.jar").exists()) {
implementation files("libs/libbox-classes.jar")
}
}

View file

@ -0,0 +1,31 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="32" />
<application>
<service
android:name=".VpnRuntimeService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains the user's active VPN tunnel." />
</service>
<service
android:name=".ProxyRuntimeService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains an explicitly requested local proxy." />
</service>
</application>
</manifest>

View file

@ -0,0 +1,120 @@
package su.snw.vpn_native_bridge
import android.content.Context
import org.json.JSONObject
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Proxy
internal data class AdapterResult(
val supported: Boolean,
val message: String? = null,
val proxyAddresses: List<String> = emptyList(),
)
internal interface EngineAdapter {
fun start(context: Context, requestJson: String, tunFd: Int?): AdapterResult
fun stop(): AdapterResult
}
/**
* The libbox API is intentionally isolated here. The exact AAR/signbox package is a
* release input, not an invented compile-time API. Until it is pinned, refusing to
* start is safer than showing a connected TUN with no packet engine behind it.
*/
internal class SignBoxAdapter : EngineAdapter {
override fun start(context: Context, requestJson: String, tunFd: Int?): AdapterResult {
return AdapterResult(false, "SignBox/libbox AAR is not bundled or its API is not pinned")
}
override fun stop(): AdapterResult = AdapterResult(true)
}
internal class FreeTurnAdapter : EngineAdapter {
private var eventSink: Any? = null
private var protector: Any? = null
override fun start(context: Context, requestJson: String, tunFd: Int?): AdapterResult {
return try {
val config = JSONObject(requestJson).optJSONObject("engine_config")
val configJson = config?.optString("config_json", "") ?: ""
if (configJson.isBlank()) {
return AdapterResult(false, "FreeTurn config_json is not configured")
}
val mobile = Class.forName("com.freeturn.core.mobile.Mobile")
mobile.getMethod("setStateDir", String::class.java)
.invoke(null, context.filesDir.resolve("freeturn-state").absolutePath)
installEventSink(mobile)
if (tunFd != null) installProtector(mobile, context)
if (tunFd == null) {
mobile.getMethod("start", String::class.java).invoke(null, configJson)
} else {
mobile.getMethod("startTunnel", String::class.java, Long::class.javaPrimitiveType)
.invoke(null, configJson, tunFd.toLong())
}
AdapterResult(true)
} catch (error: ClassNotFoundException) {
AdapterResult(false, "FreeTurn AAR is not bundled; add the pinned freeturn.aar")
} catch (error: InvocationTargetException) {
AdapterResult(false, "FreeTurn rejected the configuration: ${error.targetException.message}")
} catch (error: ReflectiveOperationException) {
AdapterResult(false, "FreeTurn mobile API is incompatible: ${error.message}")
} catch (error: RuntimeException) {
AdapterResult(false, "FreeTurn startup failed: ${error.message}")
}
}
override fun stop(): AdapterResult {
return try {
val mobile = Class.forName("com.freeturn.core.mobile.Mobile")
mobile.getMethod("setEventSink", Class.forName("com.freeturn.core.mobile.EventSink"))
.invoke(null, null)
mobile.getMethod("setProtect", Class.forName("com.freeturn.core.mobile.Protector"))
.invoke(null, null)
mobile.getMethod("stop").invoke(null)
eventSink = null
protector = null
AdapterResult(true)
} catch (_: ClassNotFoundException) {
AdapterResult(true)
} catch (error: ReflectiveOperationException) {
AdapterResult(false, "FreeTurn stop failed: ${error.message}")
}
}
private fun installEventSink(mobile: Class<*>) {
val sinkType = Class.forName("com.freeturn.core.mobile.EventSink")
eventSink = Proxy.newProxyInstance(
sinkType.classLoader,
arrayOf(sinkType),
) { _, method, args ->
if (method.name == "onCaptcha" && args != null) {
val url = args.getOrNull(0)?.toString()?.ifBlank { null }
if (url != null) RuntimeStore.lastInstance?.updatePayload("captcha_url", url)
} else if (method.name == "onState" && args != null) {
val state = args.getOrNull(0)?.toString() ?: "connecting"
val message = args.getOrNull(3)?.toString()?.ifBlank { null }
RuntimeStore.lastInstance?.write(
if (state == "connected") "running" else if (state == "error") "error" else "starting",
message,
)
}
null
}
mobile.getMethod("setEventSink", sinkType).invoke(null, eventSink)
}
private fun installProtector(mobile: Class<*>, context: Context) {
val protectorType = Class.forName("com.freeturn.core.mobile.Protector")
protector = Proxy.newProxyInstance(
protectorType.classLoader,
arrayOf(protectorType),
) { _, method, args ->
if (method.name == "protect" && args != null) {
val fd = (args.firstOrNull() as? Number)?.toInt() ?: return@newProxyInstance false
return@newProxyInstance (context as android.net.VpnService).protect(fd)
}
null
}
mobile.getMethod("setProtect", protectorType).invoke(null, protector)
}
}

View file

@ -0,0 +1,82 @@
package su.snw.vpn_native_bridge
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import java.net.Inet4Address
import java.net.NetworkInterface
internal class HotspotController(private val context: Context) {
private val wifi = context.getSystemService(WifiManager::class.java)
private var reservation: WifiManager.LocalOnlyHotspotReservation? = null
fun start(onReady: (HotspotInfo) -> Unit, onError: (String) -> Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
context.checkSelfPermission(Manifest.permission.NEARBY_WIFI_DEVICES) != PackageManager.PERMISSION_GRANTED
) {
onError("NEARBY_WIFI_DEVICES permission is required")
return
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2 &&
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED
) {
onError("ACCESS_FINE_LOCATION permission is required on Android 12 and older")
return
}
wifi.startLocalOnlyHotspot(object : WifiManager.LocalOnlyHotspotCallback() {
override fun onStarted(value: WifiManager.LocalOnlyHotspotReservation) {
reservation = value
val configuration = value.wifiConfiguration
if (configuration == null) {
value.close()
reservation = null
onError("LocalOnlyHotspot started without configuration")
return
}
val address = findHotspotAddress()
if (address == null) {
value.close()
reservation = null
onError("LocalOnlyHotspot started but its interface address was not discovered")
return
}
onReady(HotspotInfo(configuration.SSID, configuration.preSharedKey, address))
}
override fun onStopped() {
reservation = null
onError("LocalOnlyHotspot stopped")
}
override fun onFailed(reason: Int) {
onError("LocalOnlyHotspot failed: $reason")
}
}, Handler(Looper.getMainLooper()))
}
fun stop() {
reservation?.close()
reservation = null
}
private fun findHotspotAddress(): String? {
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return null
while (interfaces.hasMoreElements()) {
val networkInterface = interfaces.nextElement()
val addresses = networkInterface.inetAddresses
while (addresses.hasMoreElements()) {
val address = addresses.nextElement()
if (address is Inet4Address && !address.isLoopbackAddress && address.isSiteLocalAddress) {
return address.hostAddress
}
}
}
return null
}
}
internal data class HotspotInfo(val ssid: String?, val password: String?, val address: String)

View file

@ -0,0 +1,151 @@
package su.snw.vpn_native_bridge
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import java.util.concurrent.Executors
class ProxyRuntimeService : Service() {
private lateinit var store: RuntimeStore
private var hotspot: HotspotController? = null
private var adapter: EngineAdapter? = null
private var intentRequest: String = "{}"
private val worker = Executors.newSingleThreadExecutor()
override fun onCreate() {
super.onCreate()
store = RuntimeStore(this)
store.setComponent("proxy")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "VPN App proxy", NotificationManager.IMPORTANCE_LOW)
)
}
startForeground(NOTIFICATION_ID, notification())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intentRequest = intent?.getStringExtra(VpnRuntimeService.EXTRA_REQUEST) ?: "{}"
when (intent?.action) {
ACTION_START -> worker.execute { startProxy(intentRequest) }
ACTION_START_HOTSPOT -> startHotspot()
ACTION_STOP -> {
worker.execute {
adapter?.stop()
adapter = null
hotspot?.stop()
hotspot = null
store.write("stopped")
stopSelf()
}
}
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
adapter?.stop()
adapter = null
hotspot?.stop()
hotspot = null
worker.shutdownNow()
super.onDestroy()
}
private fun startProxy(requestJson: String) {
val request = org.json.JSONObject(requestJson)
val engineName = request.optString("engine", "signbox")
if (!store.canStart(engineName)) {
store.write("error", "another engine is already active", request = request)
stopSelf()
return
}
store.write("starting", request = request)
adapter = if (engineName == "freeturn") FreeTurnAdapter() else SignBoxAdapter()
val result = adapter!!.start(this, requestJson, null)
if (!result.supported) {
store.write("unsupported", result.message, request = request)
adapter = null
stopSelf()
return
}
store.write("running", request = request)
}
private fun startHotspot() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
store.write("unsupported", "LocalOnlyHotspot requires Android API 26+")
stopSelf()
return
}
val request = org.json.JSONObject(intentRequest)
val engineName = request.optString("engine", "signbox")
if (!store.canStart(engineName)) {
store.write("error", "another engine is already active", request = request)
stopSelf()
return
}
hotspot = HotspotController(this)
hotspot!!.start(
onReady = { info ->
worker.execute {
val request = org.json.JSONObject(intentRequest)
store.write("starting", request = request)
val proxy = request.optJSONObject("proxy")
proxy?.put("bind_address", info.address)
val engineName = request.optString("engine", "signbox")
adapter = if (engineName == "freeturn") FreeTurnAdapter() else SignBoxAdapter()
val result = adapter!!.start(this, request.toString(), null)
if (!result.supported || result.proxyAddresses.isEmpty()) {
store.write(
"unsupported",
result.message ?: "native adapter did not expose a proxy listener",
)
adapter = null
hotspot?.stop()
hotspot = null
stopSelf()
return@execute
}
val payload = org.json.JSONObject()
.put("ssid", info.ssid)
.put("password", info.password)
.put("address", info.address)
val addresses = org.json.JSONArray()
result.proxyAddresses.forEach(addresses::put)
payload.put("addresses", addresses)
store.write("running", payload = payload, request = request)
}
},
onError = { message ->
store.write("error", message)
hotspot?.stop()
hotspot = null
stopSelf()
},
)
}
private fun notification(): Notification {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL).setContentTitle("VPN App").setContentText("Proxy service").setSmallIcon(android.R.drawable.stat_sys_warning).build()
} else {
@Suppress("DEPRECATION")
Notification.Builder(this).setContentTitle("VPN App").setContentText("Proxy service").setSmallIcon(android.R.drawable.stat_sys_warning).build()
}
}
companion object {
const val ACTION_START = "su.snw.vpnapp.START_PROXY"
const val ACTION_START_HOTSPOT = "su.snw.vpnapp.START_HOTSPOT_PROXY"
const val ACTION_STOP = "su.snw.vpnapp.STOP_PROXY"
private const val CHANNEL = "vpnapp-proxy"
private const val NOTIFICATION_ID = 7102
}
}

View file

@ -0,0 +1,109 @@
package su.snw.vpn_native_bridge
import android.content.Context
import org.json.JSONObject
internal class RuntimeStore(context: Context) {
private val prefs = context.getSharedPreferences("vpnapp-runtime", Context.MODE_PRIVATE)
private var componentName: String = "runtime"
init {
lastInstance = this
}
fun setComponent(name: String) {
componentName = name
}
@Synchronized
fun canStart(engine: String): Boolean {
val current = prefs.getString("snapshot", null)?.let { JSONObject(it) }
val currentEngine = current?.optString("engine", null)
val currentState = current?.optString("state", "stopped")
if (currentState !in setOf("starting", "running", "stopping")) return true
if (currentEngine != null && currentEngine != engine) return false
val component = prefs.getString("component_$componentName", null)
?.let { JSONObject(it) }
return component?.optString("state", "stopped") !in setOf("starting", "running", "stopping")
}
@Synchronized
fun write(
state: String,
message: String? = null,
payload: JSONObject? = null,
request: JSONObject? = null,
) {
val value = JSONObject()
.put("state", state)
.put("message", message)
.put("payload", payload ?: JSONObject())
val metadata = request ?: prefs.getString("snapshot", null)?.let { JSONObject(it) }
val editor = prefs.edit().putString("component_$componentName", value.toString())
if (metadata != null) {
listOf("engine", "mode", "profile_id").forEach { key ->
if (metadata.has(key)) editor.putString("meta_$key", metadata.optString(key))
}
}
editor.apply()
prefs.edit().putString("snapshot", aggregate().toString()).apply()
}
fun updatePayload(key: String, value: String) {
val current = prefs.getString("component_$componentName", null)
?.let { JSONObject(it) } ?: JSONObject()
val payload = current.optJSONObject("payload") ?: JSONObject()
payload.put(key, value)
current.put("payload", payload)
prefs.edit().putString("component_$componentName", current.toString()).apply()
prefs.edit().putString("snapshot", aggregate().toString()).apply()
}
fun read(): Map<String, Any?> {
val raw = prefs.getString("snapshot", null) ?: return mapOf("state" to "stopped")
val value = JSONObject(raw)
return mapOf(
"state" to value.optString("state", "stopped"),
"message" to value.optString("message", null),
"engine" to value.optString("engine", null),
"mode" to value.optString("mode", null),
"profile_id" to value.optString("profile_id", null),
"payload" to value.optJSONObject("payload")?.toString(),
)
}
private fun aggregate(): JSONObject {
val vpn = component("vpn")
val proxy = component("proxy")
val components = listOf(vpn, proxy)
val active = components.filter {
it.optString("state", "stopped") in setOf("starting", "running", "stopping")
}
val state = when {
active.any { it.optString("state") == "starting" } -> "starting"
active.isNotEmpty() -> "running"
components.any { it.optString("state") == "error" } -> "error"
components.any { it.optString("state") == "unsupported" } -> "unsupported"
else -> "stopped"
}
val message = components.firstOrNull {
it.optString("state") in setOf("error", "unsupported")
}?.optString("message", null)
val payload = proxy.optJSONObject("payload") ?: JSONObject()
return JSONObject()
.put("state", state)
.put("message", message)
.put("engine", prefs.getString("meta_engine", null))
.put("mode", prefs.getString("meta_mode", null))
.put("profile_id", prefs.getString("meta_profile_id", null))
.put("payload", payload)
}
private fun component(name: String): JSONObject =
prefs.getString("component_$name", null)?.let { JSONObject(it) } ?: JSONObject()
companion object {
@Volatile
var lastInstance: RuntimeStore? = null
}
}

View file

@ -0,0 +1,106 @@
package su.snw.vpn_native_bridge
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Build
import org.json.JSONObject
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
class VpnNativeBridgePlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
private lateinit var context: Context
private lateinit var channel: MethodChannel
private var activity: Activity? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
channel = MethodChannel(binding.binaryMessenger, "su.snw.vpnapp/native_bridge")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"get_runtime_state" -> result.success(RuntimeStore(context).read())
"prepare_vpn" -> {
val intent = VpnService.prepare(context)
if (intent == null) {
result.success(mapOf("state" to "ready"))
} else {
activity?.startActivityForResult(intent, REQUEST_VPN)
result.success(mapOf("state" to "permission_requested"))
}
}
"start_vpn" -> {
if (VpnService.prepare(context) != null) {
result.error("VPN_PERMISSION_REQUIRED", "User consent is required", null)
return
}
startService(
requestIntent(
Intent(context, VpnRuntimeService::class.java)
.setAction(VpnRuntimeService.ACTION_START),
call,
)
)
result.success(mapOf("state" to "starting"))
}
"stop_vpn" -> {
context.startService(Intent(context, VpnRuntimeService::class.java).setAction(VpnRuntimeService.ACTION_STOP))
result.success(mapOf("state" to "stopping"))
}
"start_proxy" -> {
startService(
requestIntent(
Intent(context, ProxyRuntimeService::class.java)
.setAction(ProxyRuntimeService.ACTION_START),
call,
)
)
result.success(mapOf("state" to "starting"))
}
"start_hotspot_proxy" -> {
startService(
requestIntent(
Intent(context, ProxyRuntimeService::class.java)
.setAction(ProxyRuntimeService.ACTION_START_HOTSPOT),
call,
)
)
result.success(mapOf("state" to "starting"))
}
"stop_proxy", "stop_hotspot_proxy" -> {
context.startService(Intent(context, ProxyRuntimeService::class.java).setAction(ProxyRuntimeService.ACTION_STOP))
result.success(mapOf("state" to "stopping"))
}
else -> result.notImplemented()
}
}
private fun startService(intent: Intent) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent)
else context.startService(intent)
}
private fun requestIntent(intent: Intent, call: MethodCall): Intent {
val arguments = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
return intent.putExtra(VpnRuntimeService.EXTRA_REQUEST, JSONObject(arguments).toString())
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity }
override fun onDetachedFromActivityForConfigChanges() { activity = null }
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { activity = binding.activity }
override fun onDetachedFromActivity() { activity = null }
companion object { private const val REQUEST_VPN = 7103 }
}

View file

@ -0,0 +1,129 @@
package su.snw.vpn_native_bridge
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.os.IBinder
import android.os.ParcelFileDescriptor
import java.util.concurrent.Executors
class VpnRuntimeService : VpnService() {
private lateinit var store: RuntimeStore
private var tun: ParcelFileDescriptor? = null
private var adapter: EngineAdapter? = null
private var intentRequest: String = "{}"
private val worker = Executors.newSingleThreadExecutor()
override fun onCreate() {
super.onCreate()
store = RuntimeStore(this)
store.setComponent("vpn")
createChannel()
// Must happen immediately after startForegroundService, before engine setup.
startForeground(NOTIFICATION_ID, notification())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intentRequest = intent?.getStringExtra(EXTRA_REQUEST) ?: "{}"
when (intent?.action) {
ACTION_START -> worker.execute { startVpn() }
ACTION_STOP -> worker.execute { stopVpn() }
}
return START_NOT_STICKY
}
private fun startVpn() {
val requestJson = intentRequest
val input = org.json.JSONObject(requestJson)
val engineName = input.optString("engine", "signbox")
if (!store.canStart(engineName)) {
store.write("error", "another engine is already active", request = input)
stopSelf()
return
}
store.write("starting", request = input)
val vpn = input.optJSONObject("vpn")
val builder = Builder()
.setSession("VPN App")
.setMtu(vpn?.optInt("mtu", 1280) ?: 1280)
.addAddress(
vpn?.optString("tun_address", "10.0.0.1") ?: "10.0.0.1",
vpn?.optInt("tun_prefix", 24) ?: 24,
)
.addRoute("0.0.0.0", 0)
.addRoute("::", 0)
tun = builder.establish()
if (tun == null) {
store.write("error", "Android did not establish the VPN interface", request = input)
stopSelf()
return
}
adapter = if (engineName == "freeturn") FreeTurnAdapter() else SignBoxAdapter()
val engineFd = if (engineName == "freeturn") {
ParcelFileDescriptor.dup(tun!!.fileDescriptor).detachFd()
} else {
tun!!.fd
}
val result = adapter!!.start(this, requestJson, engineFd)
if (!result.supported) {
tun?.close()
tun = null
store.write("unsupported", result.message, request = input)
stopSelf()
return
}
store.write("running", request = input)
}
private fun stopVpn() {
val wasActive = adapter != null || tun != null
adapter?.stop()
adapter = null
tun?.close()
tun = null
if (wasActive) store.write("stopped")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
stopVpn()
worker.shutdownNow()
super.onDestroy()
}
override fun onRevoke() {
stopVpn()
super.onRevoke()
}
override fun onBind(intent: Intent): IBinder? = super.onBind(intent)
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "VPN App", NotificationManager.IMPORTANCE_LOW)
)
}
}
private fun notification(): Notification {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL).setContentTitle("VPN App").setContentText("VPN service").setSmallIcon(android.R.drawable.stat_sys_warning).build()
} else {
@Suppress("DEPRECATION")
Notification.Builder(this).setContentTitle("VPN App").setContentText("VPN service").setSmallIcon(android.R.drawable.stat_sys_warning).build()
}
}
companion object {
const val ACTION_START = "su.snw.vpnapp.START_VPN"
const val ACTION_STOP = "su.snw.vpnapp.STOP_VPN"
const val EXTRA_REQUEST = "su.snw.vpnapp.REQUEST"
private const val CHANNEL = "vpnapp-runtime"
private const val NOTIFICATION_ID = 7101
}
}

View file

@ -0,0 +1,51 @@
import 'package:flet/flet.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class Extension extends FletExtension {
@override
void ensureInitialized() {}
@override
FletService? createService(Control control) {
if (control.type == 'vpn_native_bridge') {
return NativeBridgeService(control: control);
}
return null;
}
@override
Widget? createWidget(Key? key, Control control) => null;
}
class NativeBridgeService extends FletService {
NativeBridgeService({required super.control});
static const _channel = MethodChannel('su.snw.vpnapp/native_bridge');
@override
void init() {
super.init();
control.addInvokeMethodListener(_onInvokeMethod);
}
Future<dynamic> _onInvokeMethod(String name, dynamic args) async {
try {
return await _channel.invokeMethod<dynamic>(name, args);
} on PlatformException catch (error) {
return <String, dynamic>{
'state': 'error',
'code': error.code,
'message': error.message,
};
} catch (error) {
return <String, dynamic>{'state': 'error', 'message': error.toString()};
}
}
@override
void dispose() {
control.removeInvokeMethodListener(_onInvokeMethod);
super.dispose();
}
}

View file

@ -0,0 +1 @@
export 'extension.dart';

View file

@ -0,0 +1,17 @@
name: vpn_native_bridge
description: Native Android lifecycle bridge for VPN App.
version: 0.1.0
publish_to: none
environment:
sdk: ">=3.4.0 <4.0.0"
flutter: ">=3.22.0"
dependencies:
flutter:
sdk: flutter
flet: any
flutter:
plugin:
platforms:
android:
package: su.snw.vpn_native_bridge
pluginClass: VpnNativeBridgePlugin

View file

@ -0,0 +1,3 @@
from .bridge import NativeBridge
__all__ = ["NativeBridge"]

View file

@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Any
import flet as ft
@ft.control("vpn_native_bridge")
class NativeBridge(ft.Service):
"""Small Flet service control for the Android native runtime contract."""
protocol_version: int = 1
async def command(self, name: str, args: dict[str, Any] | None = None) -> Any:
return await self._invoke_method(
name, {"protocol_version": self.protocol_version, **(args or {})}
)
async def start_vpn(self, request: dict[str, Any]) -> Any:
return await self.command("start_vpn", request)
async def stop_vpn(self) -> Any:
return await self.command("stop_vpn")
async def start_proxy(self, request: dict[str, Any]) -> Any:
return await self.command("start_proxy", request)
async def stop_proxy(self) -> Any:
return await self.command("stop_proxy")
async def start_hotspot_proxy(self, request: dict[str, Any]) -> Any:
return await self.command("start_hotspot_proxy", request)
async def stop_hotspot_proxy(self) -> Any:
return await self.command("stop_hotspot_proxy")
async def get_runtime_state(self) -> Any:
return await self.command("get_runtime_state")
async def prepare_vpn(self) -> Any:
return await self.command("prepare_vpn")

View file

@ -0,0 +1,15 @@
# Local Android engine artifacts
This directory is intentionally ignored by git. Put reviewed, pinned native
artifacts here before making a release APK:
- `freeturn.aar` — built from the FreeTurn commit recorded in
`THIRD_PARTY_NOTICES.md`.
- `libbox.aar` — the exact SignBox artifact with the API used by
`EngineAdapter.kt`; a generic sing-box AAR is not assumed to be compatible.
`scripts/build-android.sh` extracts `classes.jar` and `jni/*` from each AAR into
the Flet Android extension because Gradle does not allow a direct local AAR
dependency while building the extension AAR. It then runs the normal Flet APK
build. Use `REQUIRE_NATIVE_ARTIFACTS=1` for a release build so no engine is
silently omitted.

66
pyproject.toml Normal file
View file

@ -0,0 +1,66 @@
[project]
name = "vpnapp"
version = "0.1.0"
description = "Private Android/Desktop VPN and proxy client with a small subscription backend."
readme = "README.md"
requires-python = ">=3.12,<3.14"
dependencies = [
"flet>=0.86.0",
"flet-webview>=0.86.5",
"pydantic>=2.11.0",
"qrcode>=8.2",
"vpn-native-bridge",
]
[dependency-groups]
dev = [
"pytest>=8.4.0",
"pytest-asyncio>=1.1.0",
"ruff>=0.12.0",
]
[tool.uv]
default-groups = ["dev"]
[tool.uv.sources]
vpn-native-bridge = { path = "native/android/flet_vpn_bridge" }
[tool.flet]
org = "su.snw"
product = "VPN App"
company = "SNW"
copyright = "Copyright (C) 2026 SNW"
platforms = ["android", "linux", "windows"]
[tool.flet.app]
path = "app"
module = "main"
[tool.flet.android]
min_sdk_version = 26
target_sdk_version = 35
[tool.flet.dev_packages]
vpn-native-bridge = "./native/android/flet_vpn_bridge"
[tool.flet.android.permission]
"android.permission.INTERNET" = true
"android.permission.ACCESS_NETWORK_STATE" = true
"android.permission.FOREGROUND_SERVICE" = true
"android.permission.FOREGROUND_SERVICE_SPECIAL_USE" = true
"android.permission.POST_NOTIFICATIONS" = true
"android.permission.NEARBY_WIFI_DEVICES" = true
"android.permission.ACCESS_FINE_LOCATION" = true
[tool.pytest.ini_options]
pythonpath = ["."]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
ignore = ["E501"]

56
scripts/build-android.sh Executable file
View file

@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
command -v uv >/dev/null || { echo "uv is required" >&2; exit 1; }
BRIDGE_ANDROID_DIR="native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android"
mkdir -p "$BRIDGE_ANDROID_DIR/libs"
# Remove artifacts from older builds that used the unsupported direct-AAR
# dependency path. The reviewed source artifacts remain under native/android.
rm -f "$BRIDGE_ANDROID_DIR/libs/freeturn.aar" "$BRIDGE_ANDROID_DIR/libs/libbox.aar"
JNI_DIR="$BRIDGE_ANDROID_DIR/src/main/jniLibs"
rm -rf "$JNI_DIR"
mkdir -p "$JNI_DIR"
# A Flutter/Flet extension is itself built as an Android AAR. Gradle rejects
# direct local .aar dependencies in that situation, so unpack only the AAR's
# classes.jar and native libraries into the extension. The resulting plugin
# AAR carries the same reviewed engine payload into the final APK.
prepare_engine_artifact() {
local artifact="$1"
local stem="${artifact%.aar}"
local source="native/android/libs/$artifact"
local jar_target="$BRIDGE_ANDROID_DIR/libs/${stem}-classes.jar"
rm -f "$jar_target"
if [[ -f "$source" ]]; then
unzip -p "$source" classes.jar > "$jar_target"
local unpack_dir
unpack_dir="$(mktemp -d)"
unzip -q "$source" 'jni/*' -d "$unpack_dir"
if [[ -d "$unpack_dir/jni" ]]; then
cp -R "$unpack_dir/jni/." "$JNI_DIR/"
fi
rm -rf "$unpack_dir"
fi
}
for artifact in freeturn.aar libbox.aar; do
prepare_engine_artifact "$artifact"
done
if [[ ! -f native/android/libs/freeturn.aar || ! -f native/android/libs/libbox.aar ]]; then
cat >&2 <<'EOF'
Warning: one or more native engine artifacts are missing.
The APK will build, but the affected runtime reports UNSUPPORTED and cannot connect.
For a release build set REQUIRE_NATIVE_ARTIFACTS=1 after placing reviewed, pinned files at:
native/android/libs/freeturn.aar
native/android/libs/libbox.aar
EOF
if [[ "${REQUIRE_NATIVE_ARTIFACTS:-0}" == "1" ]]; then
exit 2
fi
fi
uv run flet build apk --project vpn-app --org su.snw --artifact vpn-app-arm64 --arch arm64-v8a

10
scripts/build-desktop.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
target="${1:-linux}"
case "$target" in
linux|windows) ;;
*) echo "usage: $0 linux|windows" >&2; exit 2 ;;
esac
uv run flet build "$target" --project vpn-app

View file

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p native/android/libs
echo "This script does not download unpinned native binaries."
echo
echo "FreeTurn upstream (${FREETURN_REF:-master}):"
echo " git clone --depth 1 --branch ${FREETURN_REF:-master} https://github.com/samosvalishe/free-turn-proxy.git /tmp/free-turn-proxy"
echo " cd /tmp/free-turn-proxy && task build:android"
echo " cp dist/freeturn.aar $PWD/native/android/libs/freeturn.aar"
echo
echo "SignBox/sing-box artifact: provide the exact libbox.aar/private SignBox AAR and record its source/commit in THIRD_PARTY_NOTICES.md."
echo "The upstream build shape is: go install github.com/sagernet/gomobile/cmd/gomobile@<pinned>; make lib_android"
echo
for artifact in native/android/libs/freeturn.aar native/android/libs/libbox.aar; do
if [[ -f "$artifact" ]]; then
sha256sum "$artifact"
else
echo "MISSING $artifact"
fi
done

13
scripts/run-backend.sh Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ -f .env ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
: "${VPNAPP_ADMIN_TOKEN:?Set VPNAPP_ADMIN_TOKEN (see .env.example)}"
: "${VPNAPP_SECRET_KEY:?Set VPNAPP_SECRET_KEY to a Fernet key (see .env.example)}"
uv run --project backend uvicorn backend.main:app --host "${VPNAPP_HOST:-127.0.0.1}" --port "${VPNAPP_PORT:-8080}"

5
scripts/test.sh Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
uv run pytest
uv run --project backend pytest backend/tests

View file

@ -0,0 +1,81 @@
import asyncio
import pytest
from app.core.errors import EngineError
from app.core.interface import EngineBackend
from app.core.manager import EngineManager
from app.core.registry import BackendRegistry
from app.models import (
CapabilitySet,
EngineKind,
EngineState,
RuntimeRequest,
RuntimeSnapshot,
RuntimeStats,
)
class FakeBackend(EngineBackend):
name = "fake"
capabilities = CapabilitySet(supports_proxy=True, supports_desktop=True)
def __init__(self) -> None:
self.state = RuntimeSnapshot(engine=EngineKind.SIGNBOX)
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot:
self.state = RuntimeSnapshot(
state=EngineState.RUNNING, engine=request.engine, mode=request.mode
)
return self.state
async def stop(self) -> RuntimeSnapshot:
self.state = self.state.model_copy(update={"state": EngineState.STOPPED})
return self.state
async def restart(self, request: RuntimeRequest) -> RuntimeSnapshot:
return await self.start(request)
async def get_state(self) -> RuntimeSnapshot:
return self.state
async def get_stats(self) -> RuntimeStats:
return RuntimeStats()
def manager() -> EngineManager:
registry = BackendRegistry()
registry.register(EngineKind.SIGNBOX, FakeBackend)
registry.register(EngineKind.FREETURN, FakeBackend)
return EngineManager(registry)
def request(engine: EngineKind) -> RuntimeRequest:
return RuntimeRequest(engine=engine, mode="proxy", profile_id="profile")
def test_manager_serializes_and_prevents_conflict() -> None:
async def run() -> None:
value = manager()
assert (await value.start(request(EngineKind.SIGNBOX))).state is EngineState.RUNNING
with pytest.raises(EngineError):
await value.start(request(EngineKind.FREETURN))
assert (await value.stop()).state is EngineState.STOPPED
asyncio.run(run())
def test_registry_has_no_core_if_branch_requirement() -> None:
value = manager()
assert set(value.registry.kinds()) == {EngineKind.SIGNBOX, EngineKind.FREETURN}
def test_manager_can_switch_engine_after_stop() -> None:
async def run() -> None:
value = manager()
await value.start(request(EngineKind.SIGNBOX))
await value.stop()
snapshot = await value.start(request(EngineKind.FREETURN))
assert snapshot.engine is EngineKind.FREETURN
asyncio.run(run())

31
tests/test_models.py Normal file
View file

@ -0,0 +1,31 @@
from datetime import date
import pytest
from app.models import FreeTurnProfile, PaymentInfo, ProxyAccess, ProxySettings
def test_payment_day_uses_last_day_of_month() -> None:
payment = PaymentInfo(monthly_price_rub=500, payment_day=31)
assert payment.due_date(2026, 2) == date(2026, 2, 28)
assert payment.due_date(2028, 2) == date(2028, 2, 29)
@pytest.mark.parametrize(
("preset", "expected"),
[(1, (4, 4)), (2, (8, 4)), (3, (12, 6)), (4, (24, 6))],
)
def test_freeturn_presets(preset: int, expected: tuple[int, int]) -> None:
assert FreeTurnProfile(preset=preset).stream_settings == expected
def test_freeturn_stability_mode_caps_per_credential() -> None:
assert FreeTurnProfile(preset=3, stability_over_load=True).stream_settings == (12, 4)
assert FreeTurnProfile(preset=4, stability_over_load=True).stream_settings == (24, 4)
def test_hotspot_without_auth_is_allowed_but_auth_requires_credentials() -> None:
settings = ProxySettings(access=ProxyAccess.HOTSPOT, authentication=False)
settings.validate_for_access()
with pytest.raises(ValueError):
ProxySettings(authentication=True).validate_for_access()

6
tests/test_proxy.py Normal file
View file

@ -0,0 +1,6 @@
from app.core.proxy import pick_free_port
def test_pick_free_high_port() -> None:
port = pick_free_port(candidates=[49152, 49153])
assert 49152 <= port <= 49153

View file

@ -0,0 +1,19 @@
from app.models import AppPolicy, RunMode, RuntimeRequest, SignBoxProfile
def test_profile_and_runtime_json_roundtrip() -> None:
profile = SignBoxProfile(name="Основная", config={"type": "vless"})
restored = SignBoxProfile.model_validate_json(profile.model_dump_json())
assert restored.id == profile.id
request = RuntimeRequest(
engine="signbox",
mode=RunMode.VPN,
profile_id=profile.id,
vpn={"app_rules": {"com.example.void": AppPolicy.VOID}},
)
assert (
RuntimeRequest.model_validate_json(request.model_dump_json()).vpn.app_rules[
"com.example.void"
]
is AppPolicy.VOID
)

View file

@ -0,0 +1,21 @@
from app.services.subscription import ClientVersion, SubscriptionClient, SubscriptionError
def test_user_agent_and_enrollment_url() -> None:
values: dict[str, str] = {}
client = SubscriptionClient(values.get, values.__setitem__, ClientVersion(1, 7))
result = client.enroll_url("https://sub.example.com/d/123e4567-e89b-12d3-a456-426614174000?enroll=secret")
assert result["subscription_url"].endswith("/d/123e4567-e89b-12d3-a456-426614174000")
assert values["device_token"] == "secret"
assert client.version.user_agent(values["device_id"]).startswith("vpnapp/v1.7/")
def test_invalid_enrollment_url() -> None:
values: dict[str, str] = {}
client = SubscriptionClient(values.get, values.__setitem__)
try:
client.enroll_url("https://sub.example.com/not-an-enrollment")
except SubscriptionError:
pass
else:
raise AssertionError("invalid enrollment URL must be rejected")

405
uv.lock generated Normal file
View file

@ -0,0 +1,405 @@
version = 1
revision = 3
requires-python = ">=3.12, <3.14"
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "flet"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx", marker = "sys_platform != 'emscripten'" },
{ name = "msgpack" },
{ name = "oauthlib", marker = "sys_platform != 'emscripten'" },
{ name = "repath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/0f/ac7e2af03fd49dd28477a062f8022a362155a2608857c79d6a4272e4f4a3/flet-1.0.1.tar.gz", hash = "sha256:0c11ea010efbae34600556d964613324ea0ddc711bdac94d6592699b28dc0ba4", size = 571070, upload-time = "2026-09-22T20:24:47.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/86/6eaa2f957931ee03f9814838259d5df395295c7807673b9ea2a30f75f4c5/flet-1.0.1-py3-none-any.whl", hash = "sha256:94a15d75f587dc0b6a2479348f2e4b2bf40606469eb5a18dc62ab12a790f1ae2", size = 633955, upload-time = "2026-09-22T20:24:46.225Z" },
]
[[package]]
name = "flet-webview"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flet" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/3f/29d5e29afd4ef2dde7e84a724bc1a695aeab8f5af0a95877038dc971a237/flet_webview-1.0.1.tar.gz", hash = "sha256:1ff96f8430514a52d59e31cbe7313ba9417b12ccbc02836529f2bf88a683a647", size = 14630, upload-time = "2026-09-22T20:25:59.329Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/7b/047106dfb7e012e46ed3c6ca6032381afcc269715d8211b646eb2b616816/flet_webview-1.0.1-py3-none-any.whl", hash = "sha256:ce3143f5ab82eb23c8e173f57b5c1536bebeba60d90ca1ba84a5284f950b7b4c", size = 18345, upload-time = "2026-09-22T20:25:58.118Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "msgpack"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/44/ea2100ec54d30c46ee9dba10a3bfb79b655e96c6df237238a3234c75869b/msgpack-1.2.2.tar.gz", hash = "sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a", size = 187025, upload-time = "2026-08-27T10:03:47.793Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/78/90c15bebb1a72667349ca62d4507e9d9369e7f8f76b95f490b823d3622e5/msgpack-1.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a4348705be86e029d04e741cf9ed0dfe03e942d7d3b92e838fa80d3aa2c3ebc", size = 84275, upload-time = "2026-08-27T10:02:07.106Z" },
{ url = "https://files.pythonhosted.org/packages/88/88/c2b6d8e81571da87aa232c0e34a3f3a0e618e6235892065ec82d1d81fc7a/msgpack-1.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a652ceeededf71d3fa40c303a02a149d42338d310162367b91c539d4bd6e0a3", size = 83970, upload-time = "2026-08-27T10:02:08.488Z" },
{ url = "https://files.pythonhosted.org/packages/da/c0/d3ede9f5d16acb4c05a9281859f1e99ef9f877a928eb78454c37f70db001/msgpack-1.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90986cc9aab9d7d1d8f38bcbf65d3f7ac83bdd90c35765db7d691b4829698cba", size = 409401, upload-time = "2026-08-27T10:02:09.877Z" },
{ url = "https://files.pythonhosted.org/packages/41/f0/29f591bea185616cf417645ac03bd3ad9b317483ad8572160e325f7fe777/msgpack-1.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77c2e018417dc1d66f235e383877ee885b60ade9d29e494dd581e08af2cb1923", size = 420619, upload-time = "2026-08-27T10:02:11.526Z" },
{ url = "https://files.pythonhosted.org/packages/4e/8e/c70c8c9180c5ddf4440eb8658ebead98e22e7686fbf84f6b165031430750/msgpack-1.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e91332144f69bc3018c91232fac26da580ef748fb8eaddd7914d4458001cc4f", size = 379747, upload-time = "2026-08-27T10:02:13.345Z" },
{ url = "https://files.pythonhosted.org/packages/50/9a/f10ce11fa62700c9ab87a22e65b9ca272f7f673ddd31aeb2de6ae272ad35/msgpack-1.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e915d390d7068b257ca8b62f3fc59fad135c8631d1017ab03b0b924b07c5367", size = 398944, upload-time = "2026-08-27T10:02:15.006Z" },
{ url = "https://files.pythonhosted.org/packages/82/fe/d7be978456ff8552e69a8e270d882e7530e01513c096b293d83df03753ea/msgpack-1.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c522420d78db2431887d45b518e304d86e27b9ad0b30f24e3806a6ad5d8bdbfc", size = 373979, upload-time = "2026-08-27T10:02:16.618Z" },
{ url = "https://files.pythonhosted.org/packages/be/af/91b0d8d3fb3063e259daee3ea8515cea6282f68f4b0e5f0b6fea25762c6e/msgpack-1.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4b554d8164ebb526892194f71dcd96ef1fefe0c250087498785d3ffc04a80be3", size = 417781, upload-time = "2026-08-27T10:02:18.293Z" },
{ url = "https://files.pythonhosted.org/packages/8e/3c/ce8e9efe1fd9e95c78b3705e4300ba7feba3dc6c00fb76259895db155518/msgpack-1.2.2-cp312-cp312-win32.whl", hash = "sha256:0e3315de5a4b2920ccef48d96b4448025e064a10d0f5a250f6584477d839c8d4", size = 65267, upload-time = "2026-08-27T10:02:19.869Z" },
{ url = "https://files.pythonhosted.org/packages/85/98/a33b8b4af14e3476bb0da1b8c36ef7a0f28dcf95db1c5e68ff88cb89d591/msgpack-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b68614fba0570349833b7dd999ff0aed4e5cc8d9eb6e3a7d4527be33c65e33d3", size = 72275, upload-time = "2026-08-27T10:02:21.141Z" },
{ url = "https://files.pythonhosted.org/packages/df/5e/2f323a33a6aba5bd4b2d8b430e4fab21d92cd91c093b49ee287bc166ee54/msgpack-1.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:59d5b93efa45fd09f620d0c9ba81cde339a2c9937af3eea42ee9653094ce6640", size = 65488, upload-time = "2026-08-27T10:02:22.575Z" },
{ url = "https://files.pythonhosted.org/packages/1f/eb/42f31c5a48811787ff59a9869721f70a49654d65ab6c455f4463c39b044e/msgpack-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533", size = 83911, upload-time = "2026-08-27T10:02:24.06Z" },
{ url = "https://files.pythonhosted.org/packages/33/54/10c6c16ddba8a5112e3680176b838e3694e4aad7284f9daa6d6d70d98817/msgpack-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34", size = 83734, upload-time = "2026-08-27T10:02:25.613Z" },
{ url = "https://files.pythonhosted.org/packages/d7/75/35823e4419df8792191b2a17ae3fe71b41d02c162b2c491c94d1a87f0caa/msgpack-1.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc", size = 405635, upload-time = "2026-08-27T10:02:27.012Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d3/6592e4064619b04f2dd0054c5fa13e37e3d55eb26044483d871fadb2f46b/msgpack-1.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9", size = 417332, upload-time = "2026-08-27T10:02:28.776Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a1/b21c6818a545e9a4a976ac954a5c250eecde9a02e0ec82f415473dab1324/msgpack-1.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b", size = 374378, upload-time = "2026-08-27T10:02:30.678Z" },
{ url = "https://files.pythonhosted.org/packages/03/8b/7ada15c7b64151d6dbb562d1b091520efb2c37acf2403b1d4ae13797b27d/msgpack-1.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b", size = 395809, upload-time = "2026-08-27T10:02:32.322Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f7/96283e50f7020df4dfeacc55612b7a210c8cdf0dda48bc262f1f9b3e4c49/msgpack-1.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5", size = 373495, upload-time = "2026-08-27T10:02:33.832Z" },
{ url = "https://files.pythonhosted.org/packages/cc/fe/1548dede9d9ca482f2d424a2e110a9705d4e02627a16b8bc8d10ce0208a2/msgpack-1.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab", size = 414360, upload-time = "2026-08-27T10:02:35.396Z" },
{ url = "https://files.pythonhosted.org/packages/77/9d/4419b8f86c219174b1fb8bbd7faaf84a548935f7b1916d028401b9433417/msgpack-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd", size = 65196, upload-time = "2026-08-27T10:02:37.007Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f8/593f5caf0dacab41cde1564c5f0419e61af55ec9628006205e8fd5eb5e03/msgpack-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f", size = 72203, upload-time = "2026-08-27T10:02:38.28Z" },
{ url = "https://files.pythonhosted.org/packages/bc/9e/c6ef92046b4a2bbb9d3aa0cb581cbf4a4051afccf6e5fb301a1bd3086f39/msgpack-1.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896", size = 65435, upload-time = "2026-08-27T10:02:39.534Z" },
]
[[package]]
name = "oauthlib"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "qrcode"
version = "8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
[[package]]
name = "repath"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/65/e1/824989291d0f01886074fdf9504ba54598f5665bc4dd373b589b87e76608/repath-0.9.0.tar.gz", hash = "sha256:8292139bac6a0e43fd9d70605d4e8daeb25d46672e484ed31a24c7ce0aef0fb7", size = 5492, upload-time = "2019-10-08T00:25:22.3Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/ed/92e9b8a3ffc562f21df14ef2538f54e911df29730e1f0d79130a4edc86e7/repath-0.9.0-py3-none-any.whl", hash = "sha256:ee079d6c91faeb843274d22d8f786094ee01316ecfe293a1eb6546312bb6a318", size = 4738, upload-time = "2019-10-08T00:25:20.842Z" },
]
[[package]]
name = "ruff"
version = "0.16.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
{ url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
{ url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
{ url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
{ url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
{ url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
{ url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
{ url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
{ url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
{ url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "vpn-native-bridge"
version = "0.1.0"
source = { directory = "native/android/flet_vpn_bridge" }
dependencies = [
{ name = "flet" },
]
[package.metadata]
requires-dist = [{ name = "flet", specifier = ">=0.86.0" }]
[[package]]
name = "vpnapp"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "flet" },
{ name = "flet-webview" },
{ name = "pydantic" },
{ name = "qrcode" },
{ name = "vpn-native-bridge" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "flet", specifier = ">=0.86.0" },
{ name = "flet-webview", specifier = ">=0.86.5" },
{ name = "pydantic", specifier = ">=2.11.0" },
{ name = "qrcode", specifier = ">=8.2" },
{ name = "vpn-native-bridge", directory = "native/android/flet_vpn_bridge" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.4.0" },
{ name = "pytest-asyncio", specifier = ">=1.1.0" },
{ name = "ruff", specifier = ">=0.12.0" },
]