168 lines
6 KiB
Python
168 lines
6 KiB
Python
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=(",", ":"))
|