vpn/app/services/controller.py
2026-09-23 01:19:36 +00:00

168 lines
6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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=(",", ":"))