platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
10
selective-vpn-gui/controllers/__init__.py
Normal file
10
selective-vpn-gui/controllers/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .views import *
|
||||
from .core_controller import ControllerCoreMixin
|
||||
from .status_controller import StatusControllerMixin
|
||||
from .vpn_controller import VpnControllerMixin
|
||||
from .routes_controller import RoutesControllerMixin
|
||||
from .traffic_controller import TrafficControllerMixin
|
||||
from .transport_controller import TransportControllerMixin
|
||||
from .dns_controller import DNSControllerMixin
|
||||
from .domains_controller import DomainsControllerMixin
|
||||
from .trace_controller import TraceControllerMixin
|
||||
157
selective-vpn-gui/controllers/core_controller.py
Normal file
157
selective-vpn-gui/controllers/core_controller.py
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from api_client import CmdResult, Event, LoginState, VpnStatus
|
||||
|
||||
# Вырезаем спам автопроверки из логов (CLI любит писать "Next check in ...").
|
||||
_NEXT_CHECK_RE = re.compile(
|
||||
r"(?:\b\d+s\.)?\s*Next check in\s+\d+s\.?,?", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
class ControllerCoreMixin:
|
||||
# -------- logging --------
|
||||
|
||||
def log_gui(self, msg: str) -> None:
|
||||
self.client.trace_append("gui", msg)
|
||||
|
||||
def log_smartdns(self, msg: str) -> None:
|
||||
self.client.trace_append("smartdns", msg)
|
||||
|
||||
# -------- events stream --------
|
||||
|
||||
def iter_events(self, since: int = 0, stop=None):
|
||||
return self.client.events_stream(since=since, stop=stop)
|
||||
|
||||
def classify_event(self, ev: Event) -> List[str]:
|
||||
"""Return list of areas to refresh for given event kind."""
|
||||
k = (ev.kind or "").strip().lower()
|
||||
if not k:
|
||||
return []
|
||||
if k in ("status_changed", "status_error"):
|
||||
return ["status", "routes", "vpn"]
|
||||
if k in ("login_state_changed", "login_state_error"):
|
||||
return ["login", "vpn"]
|
||||
if k == "autoloop_status_changed":
|
||||
return ["vpn"]
|
||||
if k == "vpn_locations_changed":
|
||||
return ["vpn"]
|
||||
if k == "unit_state_changed":
|
||||
return ["status", "vpn", "routes", "dns"]
|
||||
if k in ("trace_changed", "trace_append"):
|
||||
return ["trace"]
|
||||
if k == "routes_nft_progress":
|
||||
# Перерисовать блок "routes" (кнопки + прогресс).
|
||||
return ["routes"]
|
||||
if k == "traffic_mode_changed":
|
||||
return ["routes", "status"]
|
||||
if k == "traffic_profiles_changed":
|
||||
# Used by Traffic mode dialog (Apps/runtime) for persistent app profiles.
|
||||
return ["routes"]
|
||||
if k in (
|
||||
"transport_client_state_changed",
|
||||
"transport_client_health_changed",
|
||||
"transport_client_provisioned",
|
||||
"transport_policy_validated",
|
||||
"transport_policy_applied",
|
||||
"transport_conflict_detected",
|
||||
):
|
||||
return ["transport", "status"]
|
||||
if k == "egress_identity_changed":
|
||||
return ["vpn", "transport"]
|
||||
return []
|
||||
|
||||
# -------- helpers --------
|
||||
|
||||
def _is_logged_in_state(self, st: LoginState) -> bool:
|
||||
# Backend "state" может быть любым, делаем устойчивую проверку.
|
||||
s = (st.state or "").strip().lower()
|
||||
if st.email:
|
||||
return True
|
||||
if s in ("ok", "logged", "logged_in", "success", "authorized", "ready"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _level_to_color(self, level: str) -> str:
|
||||
lv = (level or "").strip().lower()
|
||||
if lv in ("green", "ok", "true", "success"):
|
||||
return "green"
|
||||
if lv in ("red", "error", "false", "failed"):
|
||||
return "red"
|
||||
return "orange"
|
||||
|
||||
def _format_policy_route(
|
||||
self,
|
||||
policy_ok: Optional[bool],
|
||||
route_ok: Optional[bool],
|
||||
) -> str:
|
||||
if policy_ok is None and route_ok is None:
|
||||
return "unknown (not checked)"
|
||||
val = policy_ok if policy_ok is not None else route_ok
|
||||
if val is True:
|
||||
return "OK (default route present in VPN table)"
|
||||
return "MISSING default route in VPN table"
|
||||
|
||||
def _resolve_routes_unit(self, iface: str) -> str:
|
||||
forced = (self.routes_unit or "").strip()
|
||||
if forced:
|
||||
return forced
|
||||
ifc = (iface or "").strip()
|
||||
if ifc and ifc != "-":
|
||||
return f"selective-vpn2@{ifc}.service"
|
||||
return ""
|
||||
|
||||
# -------- formatting helpers --------
|
||||
|
||||
def _pretty_cmd(self, res: CmdResult) -> str:
|
||||
lines: List[str] = []
|
||||
lines.append("OK" if res.ok else "ERROR")
|
||||
if res.message:
|
||||
lines.append(res.message.strip())
|
||||
if res.exit_code is not None:
|
||||
lines.append(f"exit_code: {res.exit_code}")
|
||||
if res.stdout.strip():
|
||||
lines.append("")
|
||||
lines.append("stdout:")
|
||||
lines.append(res.stdout.rstrip())
|
||||
if res.stderr.strip() and res.stderr.strip() != res.stdout.strip():
|
||||
lines.append("")
|
||||
lines.append("stderr:")
|
||||
lines.append(res.stderr.rstrip())
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
def _pretty_cmd_then_status(self, res: CmdResult, st: VpnStatus) -> str:
|
||||
return (
|
||||
self._pretty_cmd(res).rstrip()
|
||||
+ "\n\n"
|
||||
+ self._pretty_vpn_status(st).rstrip()
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
def _clean_login_lines(self, lines: Iterable[str]) -> List[str]:
|
||||
out: List[str] = []
|
||||
for raw in lines or []:
|
||||
if raw is None:
|
||||
continue
|
||||
|
||||
s = str(raw).replace("\r", "\n")
|
||||
for part in s.splitlines():
|
||||
t = part.strip()
|
||||
if not t:
|
||||
continue
|
||||
|
||||
# Вырезаем спам "Next check in ...".
|
||||
t2 = _NEXT_CHECK_RE.sub("", t).strip()
|
||||
if not t2:
|
||||
continue
|
||||
|
||||
# На всякий повторно.
|
||||
t2 = _NEXT_CHECK_RE.sub("", t2).strip()
|
||||
if not t2:
|
||||
continue
|
||||
|
||||
out.append(t2)
|
||||
return out
|
||||
72
selective-vpn-gui/controllers/dns_controller.py
Normal file
72
selective-vpn-gui/controllers/dns_controller.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, cast
|
||||
|
||||
from api_client import (
|
||||
DNSBenchmarkResponse,
|
||||
DNSBenchmarkUpstream,
|
||||
DNSStatus,
|
||||
DNSUpstreamPoolState,
|
||||
DnsUpstreams,
|
||||
SmartdnsRuntimeState,
|
||||
)
|
||||
|
||||
from .views import ActionView, ServiceAction
|
||||
|
||||
|
||||
class DNSControllerMixin:
|
||||
def dns_upstreams_view(self) -> DnsUpstreams:
|
||||
return self.client.dns_upstreams_get()
|
||||
|
||||
def dns_upstreams_save(self, cfg: DnsUpstreams) -> None:
|
||||
self.client.dns_upstreams_set(cfg)
|
||||
|
||||
def dns_upstream_pool_view(self) -> DNSUpstreamPoolState:
|
||||
return self.client.dns_upstream_pool_get()
|
||||
|
||||
def dns_upstream_pool_save(self, items: List[DNSBenchmarkUpstream]) -> DNSUpstreamPoolState:
|
||||
return self.client.dns_upstream_pool_set(items)
|
||||
|
||||
def dns_benchmark(
|
||||
self,
|
||||
upstreams: List[DNSBenchmarkUpstream],
|
||||
domains: List[str],
|
||||
timeout_ms: int = 1800,
|
||||
attempts: int = 1,
|
||||
concurrency: int = 6,
|
||||
profile: str = "load",
|
||||
) -> DNSBenchmarkResponse:
|
||||
return self.client.dns_benchmark(
|
||||
upstreams=upstreams,
|
||||
domains=domains,
|
||||
timeout_ms=timeout_ms,
|
||||
attempts=attempts,
|
||||
concurrency=concurrency,
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
def dns_status_view(self) -> DNSStatus:
|
||||
return self.client.dns_status_get()
|
||||
|
||||
def dns_mode_set(self, via: bool, smartdns_addr: str) -> DNSStatus:
|
||||
return self.client.dns_mode_set(via, smartdns_addr)
|
||||
|
||||
def smartdns_service_action(self, action: str) -> DNSStatus:
|
||||
act = action.strip().lower()
|
||||
if act not in ("start", "stop", "restart"):
|
||||
raise ValueError(f"Invalid SmartDNS action: {action}")
|
||||
return self.client.dns_smartdns_service_set(cast(ServiceAction, act))
|
||||
|
||||
def smartdns_prewarm(self, limit: int = 0, aggressive_subs: bool = False) -> ActionView:
|
||||
res = self.client.smartdns_prewarm(limit=limit, aggressive_subs=aggressive_subs)
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def smartdns_runtime_view(self) -> SmartdnsRuntimeState:
|
||||
return self.client.smartdns_runtime_get()
|
||||
|
||||
def smartdns_runtime_set(self, enabled: bool, restart: bool = True) -> SmartdnsRuntimeState:
|
||||
return self.client.smartdns_runtime_set(enabled=enabled, restart=restart)
|
||||
|
||||
# -------- Domains --------
|
||||
|
||||
77
selective-vpn-gui/controllers/domains_controller.py
Normal file
77
selective-vpn-gui/controllers/domains_controller.py
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, cast
|
||||
|
||||
from api_client import DomainsFile, DomainsTable
|
||||
|
||||
|
||||
class DomainsControllerMixin:
|
||||
def domains_table_view(self) -> DomainsTable:
|
||||
return self.client.domains_table()
|
||||
|
||||
def domains_file_load(self, name: str) -> DomainsFile:
|
||||
nm = name.strip().lower()
|
||||
if nm not in (
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
):
|
||||
raise ValueError(f"Invalid domains file name: {name}")
|
||||
return self.client.domains_file_get(
|
||||
cast(
|
||||
Literal[
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
],
|
||||
nm,
|
||||
)
|
||||
)
|
||||
|
||||
def domains_file_save(self, name: str, content: str) -> None:
|
||||
nm = name.strip().lower()
|
||||
if nm not in (
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
):
|
||||
raise ValueError(f"Invalid domains file name: {name}")
|
||||
self.client.domains_file_set(
|
||||
cast(
|
||||
Literal[
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
],
|
||||
nm,
|
||||
),
|
||||
content,
|
||||
)
|
||||
|
||||
# -------- Trace --------
|
||||
|
||||
161
selective-vpn-gui/controllers/routes_controller.py
Normal file
161
selective-vpn-gui/controllers/routes_controller.py
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import cast
|
||||
|
||||
from api_client import CmdResult, Event
|
||||
|
||||
from .views import ActionView, RoutesNftProgressView, RoutesResolveSummaryView, ServiceAction
|
||||
|
||||
|
||||
class RoutesControllerMixin:
|
||||
def routes_service_action(self, action: str) -> ActionView:
|
||||
act = action.strip().lower()
|
||||
if act not in ("start", "stop", "restart"):
|
||||
raise ValueError(f"Invalid routes action: {action}")
|
||||
res = self.client.routes_service(cast(ServiceAction, act))
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def routes_clear(self) -> ActionView:
|
||||
res = self.client.routes_clear()
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def routes_cache_restore(self) -> ActionView:
|
||||
res = self.client.routes_cache_restore()
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def routes_precheck_debug(self, run_now: bool = True) -> ActionView:
|
||||
res = self.client.routes_precheck_debug(run_now=run_now)
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def routes_fix_policy_route(self) -> ActionView:
|
||||
res = self.client.routes_fix_policy_route()
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def routes_timer_enabled(self) -> bool:
|
||||
st = self.client.routes_timer_get()
|
||||
return bool(st.enabled)
|
||||
|
||||
def routes_timer_set(self, enabled: bool) -> ActionView:
|
||||
res = self.client.routes_timer_set(bool(enabled))
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def routes_resolve_summary_view(self) -> RoutesResolveSummaryView:
|
||||
dump = self.client.trace_get("full")
|
||||
lines = list(getattr(dump, "lines", []) or [])
|
||||
line = ""
|
||||
for raw in reversed(lines):
|
||||
s = str(raw or "")
|
||||
if "resolve summary:" in s:
|
||||
line = s
|
||||
break
|
||||
if not line:
|
||||
return RoutesResolveSummaryView(
|
||||
available=False,
|
||||
text="Resolve summary: no data yet",
|
||||
recheck_text="Timeout recheck: —",
|
||||
color="gray",
|
||||
recheck_color="gray",
|
||||
)
|
||||
|
||||
tail = line.split("resolve summary:", 1)[1]
|
||||
pairs: dict[str, int] = {}
|
||||
for m in re.finditer(r"([a-zA-Z0-9_]+)=(-?\d+)", tail):
|
||||
k = str(m.group(1) or "").strip().lower()
|
||||
try:
|
||||
pairs[k] = int(m.group(2))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
unique_ips = int(pairs.get("unique_ips", 0))
|
||||
direct_ips = int(pairs.get("direct_ips", 0))
|
||||
wildcard_ips = int(pairs.get("wildcard_ips", 0))
|
||||
unresolved = int(pairs.get("unresolved", 0))
|
||||
unresolved_live = int(pairs.get("unresolved_live", 0))
|
||||
unresolved_suppressed = int(pairs.get("unresolved_suppressed", 0))
|
||||
q_hits = int(pairs.get("quarantine_hits", 0))
|
||||
dns_attempts = int(pairs.get("dns_attempts", 0))
|
||||
dns_timeout = int(pairs.get("dns_timeout", 0))
|
||||
dns_cooldown_skips = int(pairs.get("dns_cooldown_skips", 0))
|
||||
live_batch_target = int(pairs.get("live_batch_target", 0))
|
||||
live_batch_deferred = int(pairs.get("live_batch_deferred", 0))
|
||||
live_batch_p1 = int(pairs.get("live_batch_p1", 0))
|
||||
live_batch_p2 = int(pairs.get("live_batch_p2", 0))
|
||||
live_batch_p3 = int(pairs.get("live_batch_p3", 0))
|
||||
live_batch_nxheavy_pct = int(pairs.get("live_batch_nxheavy_pct", 0))
|
||||
live_batch_nxheavy_skip = int(pairs.get("live_batch_nxheavy_skip", 0))
|
||||
|
||||
r_checked = int(pairs.get("timeout_recheck_checked", 0))
|
||||
r_recovered = int(pairs.get("timeout_recheck_recovered", 0))
|
||||
r_recovered_ips = int(pairs.get("timeout_recheck_recovered_ips", 0))
|
||||
r_still_timeout = int(pairs.get("timeout_recheck_still_timeout", 0))
|
||||
r_now_nx = int(pairs.get("timeout_recheck_now_nxdomain", 0))
|
||||
r_now_tmp = int(pairs.get("timeout_recheck_now_temporary", 0))
|
||||
|
||||
text = (
|
||||
f"Resolve: ips={unique_ips} (direct={direct_ips}, wildcard={wildcard_ips}, "
|
||||
f"+recheck_ips={r_recovered_ips}) | unresolved={unresolved} "
|
||||
f"(live={unresolved_live}, suppressed={unresolved_suppressed}) | "
|
||||
f"quarantine_hits={q_hits} | dns_timeout={dns_timeout} "
|
||||
f"| cooldown_skips={dns_cooldown_skips} | attempts={dns_attempts} "
|
||||
f"| live_batch={live_batch_target} deferred={live_batch_deferred} "
|
||||
f"(p1={live_batch_p1}, p2={live_batch_p2}, p3={live_batch_p3}, nx_pct={live_batch_nxheavy_pct}, nx_skip={live_batch_nxheavy_skip})"
|
||||
)
|
||||
recheck_text = (
|
||||
f"Timeout recheck: checked={r_checked} recovered={r_recovered} "
|
||||
f"still_timeout={r_still_timeout} now_nxdomain={r_now_nx} now_temporary={r_now_tmp}"
|
||||
)
|
||||
|
||||
color = "green" if unresolved < 4000 else ("#b58900" if unresolved < 10000 else "red")
|
||||
if dns_timeout > 500 and color == "green":
|
||||
color = "#b58900"
|
||||
if live_batch_p3 > 0 and (live_batch_p1+live_batch_p2) > 0:
|
||||
ratio = float(live_batch_p3) / float(live_batch_p1 + live_batch_p2 + live_batch_p3)
|
||||
if ratio > 0.8:
|
||||
color = "#b58900" if color == "green" else color
|
||||
if ratio > 0.95:
|
||||
color = "red"
|
||||
recheck_color = "green" if r_still_timeout <= 20 else ("#b58900" if r_still_timeout <= 100 else "red")
|
||||
return RoutesResolveSummaryView(
|
||||
available=True,
|
||||
text=text,
|
||||
recheck_text=recheck_text,
|
||||
color=color,
|
||||
recheck_color=recheck_color,
|
||||
)
|
||||
|
||||
|
||||
def routes_nft_progress_from_event(self, ev: Event) -> RoutesNftProgressView:
|
||||
"""
|
||||
Превращает Event(kind='routes_nft_progress') в удобную модель
|
||||
для прогресс-бара/лейбла.
|
||||
"""
|
||||
payload = (
|
||||
getattr(ev, "data", None)
|
||||
or getattr(ev, "payload", None)
|
||||
or getattr(ev, "extra", None)
|
||||
or {}
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
try:
|
||||
percent = int(payload.get("percent", 0))
|
||||
except Exception:
|
||||
percent = 0
|
||||
|
||||
msg = str(payload.get("message", "")) if payload is not None else ""
|
||||
if not msg:
|
||||
msg = "Updating nft set…"
|
||||
|
||||
active = 0 <= percent < 100
|
||||
|
||||
return RoutesNftProgressView(
|
||||
percent=percent,
|
||||
message=msg,
|
||||
active=active,
|
||||
)
|
||||
|
||||
# -------- DNS / SmartDNS --------
|
||||
74
selective-vpn-gui/controllers/status_controller.py
Normal file
74
selective-vpn-gui/controllers/status_controller.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from api_client import LoginState, Status, UnitState, VpnStatus
|
||||
|
||||
from .views import LoginView, StatusOverviewView
|
||||
|
||||
|
||||
class StatusControllerMixin:
|
||||
def get_login_view(self) -> LoginView:
|
||||
st: LoginState = self.client.get_login_state()
|
||||
|
||||
# Prefer backend UI-ready "text" if provided, else build it.
|
||||
if st.text:
|
||||
txt = st.text
|
||||
else:
|
||||
if st.email:
|
||||
txt = f"AdGuard VPN: logged in as {st.email}"
|
||||
else:
|
||||
txt = "AdGuard VPN: (no login data)"
|
||||
|
||||
logged_in = self._is_logged_in_state(st)
|
||||
|
||||
# Цвет: либо из backend, либо простой нормализованный вариант
|
||||
if st.color:
|
||||
color = st.color
|
||||
else:
|
||||
if logged_in:
|
||||
color = "green"
|
||||
else:
|
||||
s = (st.state or "").strip().lower()
|
||||
color = "orange" if s in ("unknown", "checking") else "red"
|
||||
|
||||
return LoginView(
|
||||
text=txt,
|
||||
color=color,
|
||||
logged_in=logged_in,
|
||||
email=st.email or "",
|
||||
)
|
||||
|
||||
def get_status_overview(self) -> StatusOverviewView:
|
||||
st: Status = self.client.get_status()
|
||||
|
||||
routes_unit = self._resolve_routes_unit(st.iface)
|
||||
routes_s: UnitState = (
|
||||
self.client.systemd_state(routes_unit)
|
||||
if routes_unit
|
||||
else UnitState(state="unknown")
|
||||
)
|
||||
smartdns_s: UnitState = self.client.systemd_state(self.smartdns_unit)
|
||||
vpn_st: VpnStatus = self.client.vpn_status()
|
||||
|
||||
counts = f"domains={st.domain_count}, ips={st.ip_count}"
|
||||
iface = f"iface={st.iface} table={st.table} mark={st.mark}"
|
||||
|
||||
policy_route = self._format_policy_route(st.policy_route_ok, st.route_ok)
|
||||
|
||||
# SmartDNS: если state пустой/unknown — считаем это ошибкой
|
||||
smart_state = smartdns_s.state or "unknown"
|
||||
if smart_state.lower() in ("", "unknown", "failed"):
|
||||
smart_state = "ERROR (unknown state)"
|
||||
|
||||
return StatusOverviewView(
|
||||
timestamp=st.timestamp or "—",
|
||||
counts=counts,
|
||||
iface_table_mark=iface,
|
||||
policy_route=policy_route,
|
||||
routes_service=f"{routes_unit or 'selective-vpn2@<auto>.service'}: {routes_s.state}",
|
||||
smartdns_service=f"{self.smartdns_unit}: {smart_state}",
|
||||
# это состояние самого VPN-юнита, НЕ autoloop:
|
||||
# т.е. работает ли AdGuardVPN-daemon / туннель
|
||||
vpn_service=f"VPN: {vpn_st.unit_state}",
|
||||
)
|
||||
|
||||
12
selective-vpn-gui/controllers/trace_controller.py
Normal file
12
selective-vpn-gui/controllers/trace_controller.py
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from .views import TraceMode
|
||||
from api_client import TraceDump
|
||||
|
||||
|
||||
class TraceControllerMixin:
|
||||
# -------- Trace --------
|
||||
|
||||
def trace_view(self, mode: TraceMode = "full") -> TraceDump:
|
||||
return self.client.trace_get(mode)
|
||||
238
selective-vpn-gui/controllers/traffic_controller.py
Normal file
238
selective-vpn-gui/controllers/traffic_controller.py
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from api_client import (
|
||||
CmdResult,
|
||||
TrafficAppMarkItem,
|
||||
TrafficAppMarksResult,
|
||||
TrafficAppMarksStatus,
|
||||
TrafficAppProfile,
|
||||
TrafficAppProfileSaveResult,
|
||||
TrafficAudit,
|
||||
TrafficCandidates,
|
||||
TrafficInterfaces,
|
||||
TrafficModeStatus,
|
||||
)
|
||||
|
||||
from .views import TrafficModeView
|
||||
|
||||
|
||||
class TrafficControllerMixin:
|
||||
def traffic_mode_view(self) -> TrafficModeView:
|
||||
st: TrafficModeStatus = self.client.traffic_mode_get()
|
||||
return TrafficModeView(
|
||||
desired_mode=(st.desired_mode or st.mode or "selective"),
|
||||
applied_mode=(st.applied_mode or "direct"),
|
||||
preferred_iface=st.preferred_iface or "",
|
||||
advanced_active=bool(st.advanced_active),
|
||||
auto_local_bypass=bool(st.auto_local_bypass),
|
||||
auto_local_active=bool(st.auto_local_active),
|
||||
ingress_reply_bypass=bool(st.ingress_reply_bypass),
|
||||
ingress_reply_active=bool(st.ingress_reply_active),
|
||||
bypass_candidates=int(st.bypass_candidates),
|
||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
|
||||
force_direct_subnets=list(st.force_direct_subnets or []),
|
||||
force_direct_uids=list(st.force_direct_uids or []),
|
||||
force_direct_cgroups=list(st.force_direct_cgroups or []),
|
||||
overrides_applied=int(st.overrides_applied),
|
||||
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
|
||||
cgroup_warning=st.cgroup_warning or "",
|
||||
active_iface=st.active_iface or "",
|
||||
iface_reason=st.iface_reason or "",
|
||||
ingress_rule_present=bool(st.ingress_rule_present),
|
||||
ingress_nft_active=bool(st.ingress_nft_active),
|
||||
probe_ok=bool(st.probe_ok),
|
||||
probe_message=st.probe_message or "",
|
||||
healthy=bool(st.healthy),
|
||||
message=st.message or "",
|
||||
)
|
||||
|
||||
def traffic_mode_set(
|
||||
self,
|
||||
mode: str,
|
||||
preferred_iface: Optional[str] = None,
|
||||
auto_local_bypass: Optional[bool] = None,
|
||||
ingress_reply_bypass: Optional[bool] = None,
|
||||
force_vpn_subnets: Optional[List[str]] = None,
|
||||
force_vpn_uids: Optional[List[str]] = None,
|
||||
force_vpn_cgroups: Optional[List[str]] = None,
|
||||
force_direct_subnets: Optional[List[str]] = None,
|
||||
force_direct_uids: Optional[List[str]] = None,
|
||||
force_direct_cgroups: Optional[List[str]] = None,
|
||||
) -> TrafficModeView:
|
||||
st: TrafficModeStatus = self.client.traffic_mode_set(
|
||||
mode,
|
||||
preferred_iface,
|
||||
auto_local_bypass,
|
||||
ingress_reply_bypass,
|
||||
force_vpn_subnets,
|
||||
force_vpn_uids,
|
||||
force_vpn_cgroups,
|
||||
force_direct_subnets,
|
||||
force_direct_uids,
|
||||
force_direct_cgroups,
|
||||
)
|
||||
return TrafficModeView(
|
||||
desired_mode=(st.desired_mode or st.mode or mode),
|
||||
applied_mode=(st.applied_mode or "direct"),
|
||||
preferred_iface=st.preferred_iface or "",
|
||||
advanced_active=bool(st.advanced_active),
|
||||
auto_local_bypass=bool(st.auto_local_bypass),
|
||||
auto_local_active=bool(st.auto_local_active),
|
||||
ingress_reply_bypass=bool(st.ingress_reply_bypass),
|
||||
ingress_reply_active=bool(st.ingress_reply_active),
|
||||
bypass_candidates=int(st.bypass_candidates),
|
||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
|
||||
force_direct_subnets=list(st.force_direct_subnets or []),
|
||||
force_direct_uids=list(st.force_direct_uids or []),
|
||||
force_direct_cgroups=list(st.force_direct_cgroups or []),
|
||||
overrides_applied=int(st.overrides_applied),
|
||||
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
|
||||
cgroup_warning=st.cgroup_warning or "",
|
||||
active_iface=st.active_iface or "",
|
||||
iface_reason=st.iface_reason or "",
|
||||
ingress_rule_present=bool(st.ingress_rule_present),
|
||||
ingress_nft_active=bool(st.ingress_nft_active),
|
||||
probe_ok=bool(st.probe_ok),
|
||||
probe_message=st.probe_message or "",
|
||||
healthy=bool(st.healthy),
|
||||
message=st.message or "",
|
||||
)
|
||||
|
||||
def traffic_mode_test(self) -> TrafficModeView:
|
||||
st: TrafficModeStatus = self.client.traffic_mode_test()
|
||||
return TrafficModeView(
|
||||
desired_mode=(st.desired_mode or st.mode or "selective"),
|
||||
applied_mode=(st.applied_mode or "direct"),
|
||||
preferred_iface=st.preferred_iface or "",
|
||||
advanced_active=bool(st.advanced_active),
|
||||
auto_local_bypass=bool(st.auto_local_bypass),
|
||||
auto_local_active=bool(st.auto_local_active),
|
||||
ingress_reply_bypass=bool(st.ingress_reply_bypass),
|
||||
ingress_reply_active=bool(st.ingress_reply_active),
|
||||
bypass_candidates=int(st.bypass_candidates),
|
||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
|
||||
force_direct_subnets=list(st.force_direct_subnets or []),
|
||||
force_direct_uids=list(st.force_direct_uids or []),
|
||||
force_direct_cgroups=list(st.force_direct_cgroups or []),
|
||||
overrides_applied=int(st.overrides_applied),
|
||||
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
|
||||
cgroup_warning=st.cgroup_warning or "",
|
||||
active_iface=st.active_iface or "",
|
||||
iface_reason=st.iface_reason or "",
|
||||
ingress_rule_present=bool(st.ingress_rule_present),
|
||||
ingress_nft_active=bool(st.ingress_nft_active),
|
||||
probe_ok=bool(st.probe_ok),
|
||||
probe_message=st.probe_message or "",
|
||||
healthy=bool(st.healthy),
|
||||
message=st.message or "",
|
||||
)
|
||||
|
||||
def traffic_advanced_reset(self) -> TrafficModeView:
|
||||
st: TrafficModeStatus = self.client.traffic_advanced_reset()
|
||||
return TrafficModeView(
|
||||
desired_mode=(st.desired_mode or st.mode or "selective"),
|
||||
applied_mode=(st.applied_mode or "direct"),
|
||||
preferred_iface=st.preferred_iface or "",
|
||||
advanced_active=bool(st.advanced_active),
|
||||
auto_local_bypass=bool(st.auto_local_bypass),
|
||||
auto_local_active=bool(st.auto_local_active),
|
||||
ingress_reply_bypass=bool(st.ingress_reply_bypass),
|
||||
ingress_reply_active=bool(st.ingress_reply_active),
|
||||
bypass_candidates=int(st.bypass_candidates),
|
||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
|
||||
force_direct_subnets=list(st.force_direct_subnets or []),
|
||||
force_direct_uids=list(st.force_direct_uids or []),
|
||||
force_direct_cgroups=list(st.force_direct_cgroups or []),
|
||||
overrides_applied=int(st.overrides_applied),
|
||||
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
|
||||
cgroup_warning=st.cgroup_warning or "",
|
||||
active_iface=st.active_iface or "",
|
||||
iface_reason=st.iface_reason or "",
|
||||
ingress_rule_present=bool(st.ingress_rule_present),
|
||||
ingress_nft_active=bool(st.ingress_nft_active),
|
||||
probe_ok=bool(st.probe_ok),
|
||||
probe_message=st.probe_message or "",
|
||||
healthy=bool(st.healthy),
|
||||
message=st.message or "",
|
||||
)
|
||||
|
||||
def traffic_interfaces(self) -> List[str]:
|
||||
st: TrafficInterfaces = self.client.traffic_interfaces_get()
|
||||
vals = [x for x in st.interfaces if x]
|
||||
if st.preferred_iface and st.preferred_iface not in vals:
|
||||
vals.insert(0, st.preferred_iface)
|
||||
return vals
|
||||
|
||||
def traffic_candidates(self) -> TrafficCandidates:
|
||||
return self.client.traffic_candidates_get()
|
||||
|
||||
def traffic_appmarks_status(self) -> TrafficAppMarksStatus:
|
||||
return self.client.traffic_appmarks_status()
|
||||
|
||||
def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]:
|
||||
return self.client.traffic_appmarks_items()
|
||||
|
||||
def traffic_appmarks_apply(
|
||||
self,
|
||||
*,
|
||||
op: str,
|
||||
target: str,
|
||||
cgroup: str = "",
|
||||
unit: str = "",
|
||||
command: str = "",
|
||||
app_key: str = "",
|
||||
timeout_sec: int = 0,
|
||||
) -> TrafficAppMarksResult:
|
||||
return self.client.traffic_appmarks_apply(
|
||||
op=op,
|
||||
target=target,
|
||||
cgroup=cgroup,
|
||||
unit=unit,
|
||||
command=command,
|
||||
app_key=app_key,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
|
||||
def traffic_app_profiles_list(self) -> List[TrafficAppProfile]:
|
||||
return self.client.traffic_app_profiles_list()
|
||||
|
||||
def traffic_app_profile_upsert(
|
||||
self,
|
||||
*,
|
||||
id: str = "",
|
||||
name: str = "",
|
||||
app_key: str = "",
|
||||
command: str,
|
||||
target: str,
|
||||
ttl_sec: int = 0,
|
||||
vpn_profile: str = "",
|
||||
) -> TrafficAppProfileSaveResult:
|
||||
return self.client.traffic_app_profile_upsert(
|
||||
id=id,
|
||||
name=name,
|
||||
app_key=app_key,
|
||||
command=command,
|
||||
target=target,
|
||||
ttl_sec=ttl_sec,
|
||||
vpn_profile=vpn_profile,
|
||||
)
|
||||
|
||||
def traffic_app_profile_delete(self, id: str) -> CmdResult:
|
||||
return self.client.traffic_app_profile_delete(id)
|
||||
|
||||
def traffic_audit(self) -> TrafficAudit:
|
||||
return self.client.traffic_audit_get()
|
||||
|
||||
# -------- Transport flow (E4.2 foundation) --------
|
||||
|
||||
807
selective-vpn-gui/controllers/transport_controller.py
Normal file
807
selective-vpn-gui/controllers/transport_controller.py
Normal file
@@ -0,0 +1,807 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
from api_client import (
|
||||
ApiError,
|
||||
CmdResult,
|
||||
SingBoxProfile,
|
||||
SingBoxProfileApplyResult,
|
||||
SingBoxProfileHistoryResult,
|
||||
SingBoxProfileIssue,
|
||||
SingBoxProfileRenderResult,
|
||||
SingBoxProfileRollbackResult,
|
||||
SingBoxProfilesState,
|
||||
SingBoxProfileValidateResult,
|
||||
TransportCapabilities,
|
||||
TransportClient,
|
||||
TransportClientActionResult,
|
||||
TransportClientHealthSnapshot,
|
||||
TransportInterfacesSnapshot,
|
||||
TransportConflict,
|
||||
TransportConflicts,
|
||||
TransportHealthRefreshResult,
|
||||
TransportNetnsToggleResult,
|
||||
TransportOwnerLocksClearResult,
|
||||
TransportOwnerLocksSnapshot,
|
||||
TransportOwnershipSnapshot,
|
||||
TransportPolicy,
|
||||
TransportPolicyApplyResult,
|
||||
TransportPolicyIntent,
|
||||
TransportPolicyValidateResult,
|
||||
)
|
||||
|
||||
from .views import ActionView, TransportClientAction, TransportFlowPhase, TransportPolicyFlowView
|
||||
|
||||
|
||||
class TransportControllerMixin:
|
||||
def transport_clients(
|
||||
self,
|
||||
enabled_only: bool = False,
|
||||
kind: str = "",
|
||||
include_virtual: bool = False,
|
||||
) -> List[TransportClient]:
|
||||
return self.client.transport_clients_get(
|
||||
enabled_only=enabled_only,
|
||||
kind=kind,
|
||||
include_virtual=include_virtual,
|
||||
)
|
||||
|
||||
def transport_interfaces(self) -> TransportInterfacesSnapshot:
|
||||
return self.client.transport_interfaces_get()
|
||||
|
||||
def transport_health_refresh(
|
||||
self,
|
||||
*,
|
||||
client_ids: Optional[List[str]] = None,
|
||||
force: bool = False,
|
||||
) -> TransportHealthRefreshResult:
|
||||
return self.client.transport_health_refresh(client_ids=client_ids, force=force)
|
||||
|
||||
def transport_client_health(self, client_id: str) -> TransportClientHealthSnapshot:
|
||||
return self.client.transport_client_health_get(client_id)
|
||||
|
||||
def transport_client_create_action(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
kind: str,
|
||||
name: str = "",
|
||||
enabled: bool = True,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> ActionView:
|
||||
cid = str(client_id or "").strip()
|
||||
if not cid:
|
||||
raise ValueError("missing transport client id")
|
||||
res: CmdResult = self.client.transport_client_create(
|
||||
client_id=cid,
|
||||
kind=str(kind or "").strip().lower(),
|
||||
name=name,
|
||||
enabled=enabled,
|
||||
config=config,
|
||||
)
|
||||
msg = res.message or "client create completed"
|
||||
return ActionView(ok=bool(res.ok), pretty_text=f"create {cid}: {msg}")
|
||||
|
||||
def transport_client_action(self, client_id: str, action: TransportClientAction) -> ActionView:
|
||||
res: TransportClientActionResult = self.client.transport_client_action(client_id, action)
|
||||
status_bits = []
|
||||
before = (res.status_before or "").strip()
|
||||
after = (res.status_after or "").strip()
|
||||
if before or after:
|
||||
status_bits.append(f"status {before or '-'} -> {after or '-'}")
|
||||
if res.code:
|
||||
status_bits.append(f"code={res.code}")
|
||||
if res.last_error:
|
||||
status_bits.append(f"last_error={res.last_error}")
|
||||
extra = f" ({'; '.join(status_bits)})" if status_bits else ""
|
||||
msg = res.message or f"{res.action} completed"
|
||||
return ActionView(ok=bool(res.ok), pretty_text=f"{res.action} {res.client_id}: {msg}{extra}")
|
||||
|
||||
def transport_client_patch_action(
|
||||
self,
|
||||
client_id: str,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> ActionView:
|
||||
cid = str(client_id or "").strip()
|
||||
if not cid:
|
||||
raise ValueError("missing transport client id")
|
||||
res: CmdResult = self.client.transport_client_patch(
|
||||
cid,
|
||||
name=name,
|
||||
enabled=enabled,
|
||||
config=config,
|
||||
)
|
||||
msg = res.message or "client patch completed"
|
||||
return ActionView(ok=bool(res.ok), pretty_text=f"patch {cid}: {msg}")
|
||||
|
||||
def transport_client_delete_action(
|
||||
self,
|
||||
client_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
cleanup: bool = True,
|
||||
) -> ActionView:
|
||||
cid = str(client_id or "").strip()
|
||||
if not cid:
|
||||
raise ValueError("missing transport client id")
|
||||
res: CmdResult = self.client.transport_client_delete(cid, force=force, cleanup=cleanup)
|
||||
msg = res.message or "delete completed"
|
||||
return ActionView(ok=bool(res.ok), pretty_text=f"delete {cid}: {msg}")
|
||||
|
||||
def transport_netns_toggle(
|
||||
self,
|
||||
*,
|
||||
enabled: Optional[bool] = None,
|
||||
client_ids: Optional[List[str]] = None,
|
||||
provision: bool = True,
|
||||
restart_running: bool = True,
|
||||
) -> TransportNetnsToggleResult:
|
||||
ids = [
|
||||
str(x).strip()
|
||||
for x in (client_ids or [])
|
||||
if str(x).strip()
|
||||
] if client_ids is not None else None
|
||||
return self.client.transport_netns_toggle(
|
||||
enabled=enabled,
|
||||
client_ids=ids,
|
||||
provision=provision,
|
||||
restart_running=restart_running,
|
||||
)
|
||||
|
||||
def transport_policy_rollback_action(self, base_revision: int = 0) -> ActionView:
|
||||
base = int(base_revision or 0)
|
||||
if base <= 0:
|
||||
base = int(self.client.transport_policy_get().revision or 0)
|
||||
res: TransportPolicyApplyResult = self.client.transport_policy_rollback(base_revision=base)
|
||||
if res.ok:
|
||||
msg = res.message or "policy rollback applied"
|
||||
bits = [f"revision={int(res.policy_revision or 0)}"]
|
||||
if res.apply_id:
|
||||
bits.append(f"apply_id={res.apply_id}")
|
||||
return ActionView(ok=True, pretty_text=f"{msg} ({', '.join(bits)})")
|
||||
msg = res.message or "policy rollback failed"
|
||||
if res.code:
|
||||
msg = f"{msg} (code={res.code})"
|
||||
return ActionView(ok=False, pretty_text=msg)
|
||||
|
||||
def transport_policy(self) -> TransportPolicy:
|
||||
return self.client.transport_policy_get()
|
||||
|
||||
def transport_ownership(self) -> TransportOwnershipSnapshot:
|
||||
return self.client.transport_ownership_get()
|
||||
|
||||
def transport_owner_locks(self) -> TransportOwnerLocksSnapshot:
|
||||
return self.client.transport_owner_locks_get()
|
||||
|
||||
def transport_owner_locks_clear(
|
||||
self,
|
||||
*,
|
||||
base_revision: int = 0,
|
||||
client_id: str = "",
|
||||
destination_ip: str = "",
|
||||
destination_ips: Optional[List[str]] = None,
|
||||
confirm_token: str = "",
|
||||
) -> TransportOwnerLocksClearResult:
|
||||
return self.client.transport_owner_locks_clear(
|
||||
base_revision=int(base_revision or 0),
|
||||
client_id=str(client_id or "").strip(),
|
||||
destination_ip=str(destination_ip or "").strip(),
|
||||
destination_ips=[
|
||||
str(x).strip()
|
||||
for x in list(destination_ips or [])
|
||||
if str(x).strip()
|
||||
],
|
||||
confirm_token=str(confirm_token or "").strip(),
|
||||
)
|
||||
|
||||
def transport_owner_locks_clear_action(
|
||||
self,
|
||||
*,
|
||||
base_revision: int = 0,
|
||||
client_id: str = "",
|
||||
destination_ip: str = "",
|
||||
destination_ips: Optional[List[str]] = None,
|
||||
confirm_token: str = "",
|
||||
) -> ActionView:
|
||||
res = self.transport_owner_locks_clear(
|
||||
base_revision=base_revision,
|
||||
client_id=client_id,
|
||||
destination_ip=destination_ip,
|
||||
destination_ips=destination_ips,
|
||||
confirm_token=confirm_token,
|
||||
)
|
||||
bits: List[str] = []
|
||||
if res.code:
|
||||
bits.append(f"code={res.code}")
|
||||
bits.append(f"match={int(res.match_count)}")
|
||||
bits.append(f"cleared={int(res.cleared_count)}")
|
||||
bits.append(f"remaining={int(res.remaining_count)}")
|
||||
msg = (res.message or "owner-lock clear").strip()
|
||||
return ActionView(ok=bool(res.ok), pretty_text=f"{msg} ({', '.join(bits)})")
|
||||
|
||||
def transport_conflicts(self) -> TransportConflicts:
|
||||
return self.client.transport_conflicts_get()
|
||||
|
||||
def transport_capabilities(self) -> TransportCapabilities:
|
||||
return self.client.transport_capabilities_get()
|
||||
|
||||
def transport_flow_draft(
|
||||
self,
|
||||
intents: Optional[List[TransportPolicyIntent]] = None,
|
||||
*,
|
||||
base_revision: int = 0,
|
||||
) -> TransportPolicyFlowView:
|
||||
pol = self.client.transport_policy_get()
|
||||
rev = int(base_revision) if int(base_revision or 0) > 0 else int(pol.revision)
|
||||
return TransportPolicyFlowView(
|
||||
phase="draft",
|
||||
intents=list(intents) if intents is not None else list(pol.intents),
|
||||
base_revision=rev,
|
||||
current_revision=int(pol.revision),
|
||||
applied_revision=0,
|
||||
confirm_token="",
|
||||
valid=False,
|
||||
block_count=0,
|
||||
warn_count=0,
|
||||
diff_added=0,
|
||||
diff_changed=0,
|
||||
diff_removed=0,
|
||||
conflicts=[],
|
||||
apply_id="",
|
||||
rollback_available=False,
|
||||
message="draft ready",
|
||||
code="",
|
||||
)
|
||||
|
||||
def transport_flow_update_draft(
|
||||
self,
|
||||
flow: TransportPolicyFlowView,
|
||||
intents: List[TransportPolicyIntent],
|
||||
*,
|
||||
base_revision: int = 0,
|
||||
) -> TransportPolicyFlowView:
|
||||
rev = int(base_revision) if int(base_revision or 0) > 0 else int(flow.current_revision or flow.base_revision)
|
||||
return replace(
|
||||
flow,
|
||||
phase="draft",
|
||||
intents=list(intents),
|
||||
base_revision=rev,
|
||||
applied_revision=0,
|
||||
confirm_token="",
|
||||
valid=False,
|
||||
block_count=0,
|
||||
warn_count=0,
|
||||
diff_added=0,
|
||||
diff_changed=0,
|
||||
diff_removed=0,
|
||||
conflicts=[],
|
||||
apply_id="",
|
||||
rollback_available=False,
|
||||
message="draft updated",
|
||||
code="",
|
||||
)
|
||||
|
||||
def transport_flow_validate(
|
||||
self,
|
||||
flow: TransportPolicyFlowView,
|
||||
*,
|
||||
allow_warnings: bool = True,
|
||||
) -> TransportPolicyFlowView:
|
||||
res: TransportPolicyValidateResult = self.client.transport_policy_validate(
|
||||
base_revision=int(flow.base_revision or 0),
|
||||
intents=list(flow.intents),
|
||||
allow_warnings=allow_warnings,
|
||||
force_override=False,
|
||||
)
|
||||
phase: TransportFlowPhase = "validated"
|
||||
if not res.valid or int(res.summary.block_count) > 0:
|
||||
phase = "risky"
|
||||
return replace(
|
||||
flow,
|
||||
phase=phase,
|
||||
base_revision=int(res.base_revision or flow.base_revision),
|
||||
current_revision=int(res.base_revision or flow.current_revision),
|
||||
confirm_token=res.confirm_token,
|
||||
valid=bool(res.valid),
|
||||
block_count=int(res.summary.block_count),
|
||||
warn_count=int(res.summary.warn_count),
|
||||
diff_added=int(res.diff.added),
|
||||
diff_changed=int(res.diff.changed),
|
||||
diff_removed=int(res.diff.removed),
|
||||
conflicts=list(res.conflicts or []),
|
||||
apply_id="",
|
||||
rollback_available=False,
|
||||
message=res.message or ("validated" if phase == "validated" else "blocking conflicts found"),
|
||||
code=res.code or "",
|
||||
)
|
||||
|
||||
def transport_flow_confirm(self, flow: TransportPolicyFlowView) -> TransportPolicyFlowView:
|
||||
if flow.phase != "risky":
|
||||
raise ValueError("confirm step is allowed only after risky validate")
|
||||
if not flow.confirm_token:
|
||||
raise ValueError("missing confirm token; run validate again")
|
||||
return replace(
|
||||
flow,
|
||||
phase="confirm",
|
||||
message="force apply requires explicit confirmation",
|
||||
code="FORCE_CONFIRM_REQUIRED",
|
||||
)
|
||||
|
||||
def transport_flow_apply(
|
||||
self,
|
||||
flow: TransportPolicyFlowView,
|
||||
*,
|
||||
force_override: bool = False,
|
||||
) -> TransportPolicyFlowView:
|
||||
if flow.phase == "draft":
|
||||
return replace(
|
||||
flow,
|
||||
message="policy must be validated before apply",
|
||||
code="VALIDATE_REQUIRED",
|
||||
)
|
||||
if flow.phase == "risky" and not force_override:
|
||||
return replace(
|
||||
flow,
|
||||
message="policy has blocking conflicts; open confirm step",
|
||||
code="POLICY_CONFLICT_BLOCK",
|
||||
)
|
||||
if force_override and flow.phase != "confirm":
|
||||
return replace(
|
||||
flow,
|
||||
phase="risky",
|
||||
message="force apply requires confirm state",
|
||||
code="FORCE_CONFIRM_REQUIRED",
|
||||
)
|
||||
if force_override and not flow.confirm_token:
|
||||
return replace(
|
||||
flow,
|
||||
phase="risky",
|
||||
message="confirm token is missing or expired; run validate again",
|
||||
code="FORCE_OVERRIDE_CONFIRM_REQUIRED",
|
||||
)
|
||||
|
||||
res: TransportPolicyApplyResult = self.client.transport_policy_apply(
|
||||
base_revision=int(flow.base_revision),
|
||||
intents=list(flow.intents),
|
||||
force_override=bool(force_override),
|
||||
confirm_token=flow.confirm_token if force_override else "",
|
||||
)
|
||||
return self._transport_flow_from_apply_result(flow, res)
|
||||
|
||||
def transport_flow_rollback(self, flow: TransportPolicyFlowView) -> TransportPolicyFlowView:
|
||||
base = int(flow.current_revision or flow.base_revision)
|
||||
res: TransportPolicyApplyResult = self.client.transport_policy_rollback(base_revision=base)
|
||||
return self._transport_flow_from_apply_result(flow, res)
|
||||
|
||||
def _transport_flow_from_apply_result(
|
||||
self,
|
||||
flow: TransportPolicyFlowView,
|
||||
res: TransportPolicyApplyResult,
|
||||
) -> TransportPolicyFlowView:
|
||||
if res.ok:
|
||||
pol = self.client.transport_policy_get()
|
||||
applied_rev = int(res.policy_revision or pol.revision)
|
||||
return TransportPolicyFlowView(
|
||||
phase="applied",
|
||||
intents=list(pol.intents),
|
||||
base_revision=applied_rev,
|
||||
current_revision=applied_rev,
|
||||
applied_revision=applied_rev,
|
||||
confirm_token="",
|
||||
valid=True,
|
||||
block_count=0,
|
||||
warn_count=0,
|
||||
diff_added=0,
|
||||
diff_changed=0,
|
||||
diff_removed=0,
|
||||
conflicts=[],
|
||||
apply_id=res.apply_id or "",
|
||||
rollback_available=bool(res.rollback_available),
|
||||
message=res.message or "policy applied",
|
||||
code=res.code or "",
|
||||
)
|
||||
|
||||
if res.code == "POLICY_REVISION_MISMATCH":
|
||||
current_rev = int(res.current_revision or 0)
|
||||
if current_rev <= 0:
|
||||
current_rev = int(self.client.transport_policy_get().revision)
|
||||
return replace(
|
||||
flow,
|
||||
phase="draft",
|
||||
base_revision=current_rev,
|
||||
current_revision=current_rev,
|
||||
confirm_token="",
|
||||
valid=False,
|
||||
message="policy revision changed; validate again",
|
||||
code=res.code,
|
||||
)
|
||||
|
||||
if res.code in ("POLICY_CONFLICT_BLOCK", "FORCE_OVERRIDE_CONFIRM_REQUIRED"):
|
||||
conflicts = list(res.conflicts or flow.conflicts)
|
||||
block_count = len([x for x in conflicts if (x.severity or "").strip().lower() == "block"])
|
||||
return replace(
|
||||
flow,
|
||||
phase="risky",
|
||||
valid=False,
|
||||
block_count=block_count,
|
||||
conflicts=conflicts,
|
||||
message=res.message or "blocking conflicts",
|
||||
code=res.code,
|
||||
)
|
||||
|
||||
return replace(
|
||||
flow,
|
||||
phase="error",
|
||||
valid=False,
|
||||
message=res.message or "transport apply failed",
|
||||
code=res.code or "TRANSPORT_APPLY_ERROR",
|
||||
)
|
||||
|
||||
def singbox_profile_id_for_client(self, client: Optional[TransportClient]) -> str:
|
||||
if client is None:
|
||||
return ""
|
||||
cfg = getattr(client, "config", {}) or {}
|
||||
if isinstance(cfg, dict):
|
||||
for key in ("profile_id", "singbox_profile_id", "profile"):
|
||||
v = str(cfg.get(key) or "").strip()
|
||||
if v:
|
||||
return v
|
||||
return str(getattr(client, "id", "") or "").strip()
|
||||
|
||||
def singbox_profile_ensure_linked(
|
||||
self,
|
||||
client: TransportClient,
|
||||
*,
|
||||
preferred_profile_id: str = "",
|
||||
) -> ActionView:
|
||||
pid, state = self._ensure_singbox_profile_for_client(
|
||||
client,
|
||||
preferred_profile_id=str(preferred_profile_id or "").strip(),
|
||||
)
|
||||
cid = str(getattr(client, "id", "") or "").strip()
|
||||
if state == "created":
|
||||
return ActionView(ok=True, pretty_text=f"profile {pid} created and linked to {cid}")
|
||||
if state == "linked":
|
||||
return ActionView(ok=True, pretty_text=f"profile {pid} linked to {cid}")
|
||||
return ActionView(ok=True, pretty_text=f"profile {pid} already linked to {cid}")
|
||||
|
||||
def singbox_profile_validate_action(
|
||||
self,
|
||||
profile_id: str,
|
||||
*,
|
||||
check_binary: Optional[bool] = None,
|
||||
client: Optional[TransportClient] = None,
|
||||
) -> ActionView:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
res: SingBoxProfileValidateResult = self.client.transport_singbox_profile_validate(
|
||||
pid,
|
||||
check_binary=check_binary,
|
||||
)
|
||||
ok = bool(res.ok and res.valid)
|
||||
if ok:
|
||||
msg = res.message or "profile is valid"
|
||||
return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("validate", pid, msg, res))
|
||||
msg = res.message or "profile validation failed"
|
||||
return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("validate", pid, msg, res))
|
||||
|
||||
def singbox_profile_render_preview_action(
|
||||
self,
|
||||
profile_id: str,
|
||||
*,
|
||||
check_binary: Optional[bool] = None,
|
||||
persist: bool = False,
|
||||
client: Optional[TransportClient] = None,
|
||||
) -> ActionView:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
res: SingBoxProfileRenderResult = self.client.transport_singbox_profile_render(
|
||||
pid,
|
||||
check_binary=check_binary,
|
||||
persist=bool(persist),
|
||||
)
|
||||
ok = bool(res.ok and res.valid)
|
||||
if ok:
|
||||
msg = res.message or ("rendered" if persist else "render preview ready")
|
||||
return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("render", pid, msg, res))
|
||||
msg = res.message or "render failed"
|
||||
return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("render", pid, msg, res))
|
||||
|
||||
def singbox_profile_apply_action(
|
||||
self,
|
||||
profile_id: str,
|
||||
*,
|
||||
client_id: str = "",
|
||||
restart: Optional[bool] = True,
|
||||
skip_runtime: bool = False,
|
||||
check_binary: Optional[bool] = None,
|
||||
client: Optional[TransportClient] = None,
|
||||
) -> ActionView:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
cid = str(client_id or "").strip()
|
||||
if not cid and client is not None:
|
||||
cid = str(getattr(client, "id", "") or "").strip()
|
||||
res: SingBoxProfileApplyResult = self.client.transport_singbox_profile_apply(
|
||||
pid,
|
||||
client_id=cid,
|
||||
restart=restart,
|
||||
skip_runtime=skip_runtime,
|
||||
check_binary=check_binary,
|
||||
)
|
||||
if res.ok:
|
||||
msg = res.message or "profile applied"
|
||||
return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("apply", pid, msg, res))
|
||||
msg = res.message or "profile apply failed"
|
||||
return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("apply", pid, msg, res))
|
||||
|
||||
def singbox_profile_rollback_action(
|
||||
self,
|
||||
profile_id: str,
|
||||
*,
|
||||
client_id: str = "",
|
||||
restart: Optional[bool] = True,
|
||||
skip_runtime: bool = False,
|
||||
history_id: str = "",
|
||||
client: Optional[TransportClient] = None,
|
||||
) -> ActionView:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
cid = str(client_id or "").strip()
|
||||
if not cid and client is not None:
|
||||
cid = str(getattr(client, "id", "") or "").strip()
|
||||
res: SingBoxProfileRollbackResult = self.client.transport_singbox_profile_rollback(
|
||||
pid,
|
||||
client_id=cid,
|
||||
history_id=history_id,
|
||||
restart=restart,
|
||||
skip_runtime=skip_runtime,
|
||||
)
|
||||
if res.ok:
|
||||
msg = res.message or "profile rollback applied"
|
||||
return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("rollback", pid, msg, res))
|
||||
msg = res.message or "profile rollback failed"
|
||||
return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("rollback", pid, msg, res))
|
||||
|
||||
def singbox_profile_history_lines(
|
||||
self,
|
||||
profile_id: str,
|
||||
*,
|
||||
limit: int = 20,
|
||||
client: Optional[TransportClient] = None,
|
||||
) -> List[str]:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
res: SingBoxProfileHistoryResult = self.client.transport_singbox_profile_history(pid, limit=limit)
|
||||
lines: List[str] = []
|
||||
for it in list(res.items or []):
|
||||
lines.append(self._format_singbox_history_line(it))
|
||||
return lines
|
||||
|
||||
def singbox_profile_get_for_client(
|
||||
self,
|
||||
client: TransportClient,
|
||||
*,
|
||||
profile_id: str = "",
|
||||
) -> SingBoxProfile:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
return self.client.transport_singbox_profile_get(pid)
|
||||
|
||||
def singbox_profile_save_raw_for_client(
|
||||
self,
|
||||
client: TransportClient,
|
||||
*,
|
||||
profile_id: str = "",
|
||||
name: str = "",
|
||||
enabled: bool = True,
|
||||
protocol: str = "vless",
|
||||
raw_config: Optional[Dict[str, Any]] = None,
|
||||
) -> ActionView:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
current = self.client.transport_singbox_profile_get(pid)
|
||||
snap = self.client.transport_singbox_profile_patch(
|
||||
pid,
|
||||
base_revision=int(current.profile_revision or 0),
|
||||
name=(str(name or "").strip() or current.name or pid),
|
||||
enabled=bool(enabled),
|
||||
protocol=(str(protocol or "").strip().lower() or "vless"),
|
||||
mode="raw",
|
||||
raw_config=cast(Dict[str, Any], raw_config or {}),
|
||||
)
|
||||
item = snap.item
|
||||
if item is None:
|
||||
return ActionView(ok=False, pretty_text=f"save profile {pid}: backend returned empty item")
|
||||
return ActionView(
|
||||
ok=True,
|
||||
pretty_text=(
|
||||
f"save profile {pid}: revision={int(item.profile_revision or 0)} "
|
||||
f"render_revision={int(item.render_revision or 0)}"
|
||||
),
|
||||
)
|
||||
|
||||
def _format_singbox_profile_action(
|
||||
self,
|
||||
action: str,
|
||||
profile_id: str,
|
||||
message: str,
|
||||
res: SingBoxProfileValidateResult | SingBoxProfileRenderResult | SingBoxProfileApplyResult | SingBoxProfileRollbackResult,
|
||||
) -> str:
|
||||
bits: List[str] = []
|
||||
if getattr(res, "code", ""):
|
||||
bits.append(f"code={str(getattr(res, 'code', '')).strip()}")
|
||||
|
||||
rev = int(getattr(res, "profile_revision", 0) or 0)
|
||||
if rev > 0:
|
||||
bits.append(f"rev={rev}")
|
||||
|
||||
diff = getattr(res, "diff", None)
|
||||
if diff is not None:
|
||||
added = int(getattr(diff, "added", 0) or 0)
|
||||
changed = int(getattr(diff, "changed", 0) or 0)
|
||||
removed = int(getattr(diff, "removed", 0) or 0)
|
||||
bits.append(f"diff=+{added}/~{changed}/-{removed}")
|
||||
|
||||
render_digest = str(getattr(res, "render_digest", "") or "").strip()
|
||||
if render_digest:
|
||||
bits.append(f"digest={render_digest[:12]}")
|
||||
|
||||
client_id = str(getattr(res, "client_id", "") or "").strip()
|
||||
if client_id:
|
||||
bits.append(f"client={client_id}")
|
||||
config_path = str(getattr(res, "config_path", "") or "").strip()
|
||||
if config_path:
|
||||
bits.append(f"config={config_path}")
|
||||
history_id = str(getattr(res, "history_id", "") or "").strip()
|
||||
if history_id:
|
||||
bits.append(f"history={history_id}")
|
||||
render_path = str(getattr(res, "render_path", "") or "").strip()
|
||||
if render_path:
|
||||
bits.append(f"render={render_path}")
|
||||
render_revision = int(getattr(res, "render_revision", 0) or 0)
|
||||
if render_revision > 0:
|
||||
bits.append(f"render_rev={render_revision}")
|
||||
|
||||
rollback_available = bool(getattr(res, "rollback_available", False))
|
||||
if rollback_available:
|
||||
bits.append("rollback=available")
|
||||
|
||||
errors = cast(List[SingBoxProfileIssue], list(getattr(res, "errors", []) or []))
|
||||
warnings = cast(List[SingBoxProfileIssue], list(getattr(res, "warnings", []) or []))
|
||||
if warnings:
|
||||
bits.append(f"warnings={len(warnings)}")
|
||||
if errors:
|
||||
bits.append(f"errors={len(errors)}")
|
||||
first = self._format_singbox_issue_brief(errors[0])
|
||||
if first:
|
||||
bits.append(f"first_error={first}")
|
||||
|
||||
tail = f" ({'; '.join(bits)})" if bits else ""
|
||||
return f"{action} profile {profile_id}: {message}{tail}"
|
||||
|
||||
def _format_singbox_history_line(self, it) -> str:
|
||||
at = str(getattr(it, "at", "") or "").strip() or "-"
|
||||
action = str(getattr(it, "action", "") or "").strip() or "event"
|
||||
status = str(getattr(it, "status", "") or "").strip() or "unknown"
|
||||
msg = str(getattr(it, "message", "") or "").strip()
|
||||
code = str(getattr(it, "code", "") or "").strip()
|
||||
digest = str(getattr(it, "render_digest", "") or "").strip()
|
||||
client_id = str(getattr(it, "client_id", "") or "").strip()
|
||||
bits: List[str] = []
|
||||
if code:
|
||||
bits.append(f"code={code}")
|
||||
if client_id:
|
||||
bits.append(f"client={client_id}")
|
||||
if digest:
|
||||
bits.append(f"digest={digest[:12]}")
|
||||
tail = f" ({'; '.join(bits)})" if bits else ""
|
||||
body = msg or "-"
|
||||
return f"{at} | {action} | {status} | {body}{tail}"
|
||||
|
||||
def _resolve_singbox_profile_id(self, profile_id: str, client: Optional[TransportClient]) -> str:
|
||||
pid = str(profile_id or "").strip()
|
||||
if client is not None:
|
||||
ensured_pid, _ = self._ensure_singbox_profile_for_client(client, preferred_profile_id=pid)
|
||||
pid = ensured_pid
|
||||
if not pid:
|
||||
raise ValueError("missing singbox profile id")
|
||||
return pid
|
||||
|
||||
def _ensure_singbox_profile_for_client(
|
||||
self,
|
||||
client: TransportClient,
|
||||
*,
|
||||
preferred_profile_id: str = "",
|
||||
) -> tuple[str, str]:
|
||||
cid = str(getattr(client, "id", "") or "").strip()
|
||||
if not cid:
|
||||
raise ValueError("missing transport client id")
|
||||
|
||||
pid = str(preferred_profile_id or "").strip()
|
||||
if not pid:
|
||||
pid = self.singbox_profile_id_for_client(client)
|
||||
if not pid:
|
||||
raise ValueError("cannot resolve singbox profile id for selected client")
|
||||
|
||||
try:
|
||||
cur = self.client.transport_singbox_profile_get(pid)
|
||||
except ApiError as e:
|
||||
if int(getattr(e, "status_code", 0) or 0) != 404:
|
||||
raise
|
||||
raw_cfg = self._load_singbox_raw_config_from_client(client)
|
||||
protocol = self._infer_singbox_protocol(client, raw_cfg)
|
||||
snap: SingBoxProfilesState = self.client.transport_singbox_profile_create(
|
||||
profile_id=pid,
|
||||
name=str(getattr(client, "name", "") or "").strip() or pid,
|
||||
mode="raw",
|
||||
protocol=protocol,
|
||||
raw_config=raw_cfg,
|
||||
meta={"client_id": cid},
|
||||
enabled=True,
|
||||
)
|
||||
created = snap.item
|
||||
if created is None:
|
||||
raise RuntimeError("profile create returned empty item")
|
||||
return str(created.id or pid).strip(), "created"
|
||||
|
||||
meta = dict(cur.meta or {})
|
||||
if str(meta.get("client_id") or "").strip() == cid:
|
||||
return pid, "ok"
|
||||
meta["client_id"] = cid
|
||||
snap = self.client.transport_singbox_profile_patch(
|
||||
pid,
|
||||
base_revision=int(cur.profile_revision or 0),
|
||||
meta=meta,
|
||||
)
|
||||
if snap.item is not None:
|
||||
pid = str(snap.item.id or pid).strip()
|
||||
return pid, "linked"
|
||||
|
||||
def _load_singbox_raw_config_from_client(self, client: TransportClient) -> dict:
|
||||
cfg = getattr(client, "config", {}) or {}
|
||||
if not isinstance(cfg, dict):
|
||||
return {}
|
||||
path = str(cfg.get("config_path") or "").strip()
|
||||
if not path:
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
parsed = json.load(f)
|
||||
if isinstance(parsed, dict):
|
||||
return cast(dict, parsed)
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def _infer_singbox_protocol(self, client: TransportClient, raw_cfg: dict) -> str:
|
||||
cfg = getattr(client, "config", {}) or {}
|
||||
if isinstance(cfg, dict):
|
||||
p = str(cfg.get("protocol") or "").strip().lower()
|
||||
if p:
|
||||
return p
|
||||
if isinstance(raw_cfg, dict):
|
||||
outbounds = raw_cfg.get("outbounds") or []
|
||||
if isinstance(outbounds, list):
|
||||
for row in outbounds:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
t = str(row.get("type") or "").strip().lower()
|
||||
if not t:
|
||||
continue
|
||||
if t in ("direct", "block", "dns"):
|
||||
continue
|
||||
return t
|
||||
return "vless"
|
||||
|
||||
def _format_singbox_issue_brief(self, issue: SingBoxProfileIssue) -> str:
|
||||
code = str(getattr(issue, "code", "") or "").strip()
|
||||
field = str(getattr(issue, "field", "") or "").strip()
|
||||
message = str(getattr(issue, "message", "") or "").strip()
|
||||
parts = [x for x in (code, field, message) if x]
|
||||
if not parts:
|
||||
return ""
|
||||
out = ": ".join(parts[:2]) if len(parts) > 1 else parts[0]
|
||||
if len(parts) > 2:
|
||||
out = f"{out}: {parts[2]}"
|
||||
return out if len(out) <= 140 else out[:137] + "..."
|
||||
136
selective-vpn-gui/controllers/views.py
Normal file
136
selective-vpn-gui/controllers/views.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Literal
|
||||
|
||||
from api_client import TransportConflict, TransportPolicyIntent
|
||||
|
||||
TraceMode = Literal["full", "gui", "smartdns"]
|
||||
ServiceAction = Literal["start", "stop", "restart"]
|
||||
LoginAction = Literal["open", "check", "cancel"]
|
||||
TransportClientAction = Literal["provision", "start", "stop", "restart"]
|
||||
TransportFlowPhase = Literal["draft", "validated", "risky", "confirm", "applied", "error"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginView:
|
||||
text: str
|
||||
color: str
|
||||
logged_in: bool
|
||||
email: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatusOverviewView:
|
||||
timestamp: str
|
||||
counts: str
|
||||
iface_table_mark: str
|
||||
policy_route: str
|
||||
routes_service: str
|
||||
smartdns_service: str
|
||||
vpn_service: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VpnStatusView:
|
||||
desired_location: str
|
||||
pretty_text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActionView:
|
||||
ok: bool
|
||||
pretty_text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginFlowView:
|
||||
phase: str
|
||||
level: str
|
||||
dot_color: str
|
||||
status_text: str
|
||||
url: str
|
||||
email: str
|
||||
alive: bool
|
||||
cursor: int
|
||||
lines: List[str]
|
||||
can_open: bool
|
||||
can_check: bool
|
||||
can_cancel: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VpnAutoconnectView:
|
||||
"""Для блока Autoconnect на вкладке AdGuardVPN."""
|
||||
enabled: bool # True = включён autoloop
|
||||
unit_text: str # строка вида "unit: active"
|
||||
color: str # "green" / "red" / "orange"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutesNftProgressView:
|
||||
"""Прогресс обновления nft-наборов (agvpn4)."""
|
||||
percent: int
|
||||
message: str
|
||||
active: bool # True — пока идёт апдейт, False — когда закончили / ничего не идёт
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrafficModeView:
|
||||
desired_mode: str
|
||||
applied_mode: str
|
||||
preferred_iface: str
|
||||
advanced_active: bool
|
||||
auto_local_bypass: bool
|
||||
auto_local_active: bool
|
||||
ingress_reply_bypass: bool
|
||||
ingress_reply_active: bool
|
||||
bypass_candidates: int
|
||||
force_vpn_subnets: List[str]
|
||||
force_vpn_uids: List[str]
|
||||
force_vpn_cgroups: List[str]
|
||||
force_direct_subnets: List[str]
|
||||
force_direct_uids: List[str]
|
||||
force_direct_cgroups: List[str]
|
||||
overrides_applied: int
|
||||
cgroup_resolved_uids: int
|
||||
cgroup_warning: str
|
||||
active_iface: str
|
||||
iface_reason: str
|
||||
ingress_rule_present: bool
|
||||
ingress_nft_active: bool
|
||||
probe_ok: bool
|
||||
probe_message: str
|
||||
healthy: bool
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutesResolveSummaryView:
|
||||
available: bool
|
||||
text: str
|
||||
recheck_text: str
|
||||
color: str
|
||||
recheck_color: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransportPolicyFlowView:
|
||||
phase: TransportFlowPhase
|
||||
intents: List[TransportPolicyIntent]
|
||||
base_revision: int
|
||||
current_revision: int
|
||||
applied_revision: int
|
||||
confirm_token: str
|
||||
valid: bool
|
||||
block_count: int
|
||||
warn_count: int
|
||||
diff_added: int
|
||||
diff_changed: int
|
||||
diff_removed: int
|
||||
conflicts: List[TransportConflict]
|
||||
apply_id: str
|
||||
rollback_available: bool
|
||||
message: str
|
||||
code: str
|
||||
305
selective-vpn-gui/controllers/vpn_controller.py
Normal file
305
selective-vpn-gui/controllers/vpn_controller.py
Normal file
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, cast
|
||||
|
||||
from api_client import (
|
||||
CmdResult,
|
||||
EgressIdentity,
|
||||
EgressIdentityRefreshResult,
|
||||
LoginSessionAction,
|
||||
LoginSessionStart,
|
||||
LoginSessionState,
|
||||
LoginState,
|
||||
VpnLocation,
|
||||
VpnLocationsState,
|
||||
VpnStatus,
|
||||
)
|
||||
|
||||
from .views import ActionView, LoginAction, LoginFlowView, VpnAutoconnectView, VpnStatusView
|
||||
|
||||
|
||||
class VpnControllerMixin:
|
||||
def vpn_locations_view(self) -> List[VpnLocation]:
|
||||
return self.client.vpn_locations()
|
||||
|
||||
def vpn_locations_state_view(self) -> VpnLocationsState:
|
||||
return self.client.vpn_locations_state()
|
||||
|
||||
def vpn_locations_refresh_trigger(self) -> None:
|
||||
self.client.vpn_locations_refresh_trigger()
|
||||
|
||||
def vpn_status_view(self) -> VpnStatusView:
|
||||
st = self.client.vpn_status()
|
||||
pretty = self._pretty_vpn_status(st)
|
||||
return VpnStatusView(
|
||||
desired_location=st.desired_location,
|
||||
pretty_text=pretty,
|
||||
)
|
||||
|
||||
def vpn_status_model(self) -> VpnStatus:
|
||||
return self.client.vpn_status()
|
||||
|
||||
# --- autoconnect / autoloop ---
|
||||
|
||||
def _autoconnect_from_auto(self, auto) -> bool:
|
||||
"""
|
||||
Вытаскиваем True/False из ответа /vpn/autoloop/status.
|
||||
|
||||
Приоритет:
|
||||
1) явное поле auto.enabled (bool)
|
||||
2) эвристика по status_word / raw_text
|
||||
"""
|
||||
enabled_field = getattr(auto, "enabled", None)
|
||||
if isinstance(enabled_field, bool):
|
||||
return enabled_field
|
||||
|
||||
word = (getattr(auto, "status_word", "") or "").strip().lower()
|
||||
raw = (getattr(auto, "raw_text", "") or "").lower()
|
||||
|
||||
# приоритет — явные статусы
|
||||
if word in (
|
||||
"active",
|
||||
"running",
|
||||
"enabled",
|
||||
"on",
|
||||
"up",
|
||||
"started",
|
||||
"ok",
|
||||
"true",
|
||||
"yes",
|
||||
):
|
||||
return True
|
||||
if word in ("inactive", "stopped", "disabled", "off", "down", "false", "no"):
|
||||
return False
|
||||
|
||||
# фоллбек — по raw_text
|
||||
if "inactive" in raw or "disabled" in raw or "failed" in raw:
|
||||
return False
|
||||
if "active" in raw or "running" in raw or "enabled" in raw:
|
||||
return True
|
||||
return False
|
||||
|
||||
def vpn_autoconnect_view(self) -> VpnAutoconnectView:
|
||||
try:
|
||||
auto = self.client.vpn_autoloop_status()
|
||||
except Exception as e:
|
||||
return VpnAutoconnectView(
|
||||
enabled=False,
|
||||
unit_text=f"unit: ERROR ({e})",
|
||||
color="red",
|
||||
)
|
||||
|
||||
enabled = self._autoconnect_from_auto(auto)
|
||||
|
||||
unit_state = (
|
||||
getattr(auto, "unit_state", "") # если backend так отдаёт
|
||||
or (auto.status_word or "")
|
||||
or "unknown"
|
||||
)
|
||||
|
||||
text = f"unit: {unit_state}"
|
||||
|
||||
low = f"{unit_state} {(auto.raw_text or '')}".lower()
|
||||
if any(x in low for x in ("failed", "error", "unknown", "inactive", "dead")):
|
||||
color = "red"
|
||||
elif "active" in low or "running" in low or "enabled" in low:
|
||||
color = "green"
|
||||
else:
|
||||
color = "orange"
|
||||
|
||||
return VpnAutoconnectView(enabled=enabled, unit_text=text, color=color)
|
||||
|
||||
def vpn_autoconnect_enabled(self) -> bool:
|
||||
"""Старый интерфейс — оставляем для кнопки toggle."""
|
||||
return self.vpn_autoconnect_view().enabled
|
||||
|
||||
def vpn_set_autoconnect(self, enable: bool) -> VpnStatusView:
|
||||
res = self.client.vpn_autoconnect(enable)
|
||||
st = self.client.vpn_status()
|
||||
pretty = self._pretty_cmd_then_status(res, st)
|
||||
return VpnStatusView(
|
||||
desired_location=st.desired_location,
|
||||
pretty_text=pretty,
|
||||
)
|
||||
|
||||
def vpn_set_location(self, target: str, iso: str = "", label: str = "") -> VpnStatusView:
|
||||
self.client.vpn_set_location(target=target, iso=iso, label=label)
|
||||
st = self.client.vpn_status()
|
||||
pretty = self._pretty_vpn_status(st)
|
||||
return VpnStatusView(
|
||||
desired_location=st.desired_location,
|
||||
pretty_text=pretty,
|
||||
)
|
||||
|
||||
def egress_identity(self, scope: str, *, refresh: bool = False) -> EgressIdentity:
|
||||
return self.client.egress_identity_get(scope, refresh=refresh)
|
||||
|
||||
def egress_identity_refresh(
|
||||
self,
|
||||
*,
|
||||
scopes: Optional[List[str]] = None,
|
||||
force: bool = False,
|
||||
) -> EgressIdentityRefreshResult:
|
||||
return self.client.egress_identity_refresh(scopes=scopes, force=force)
|
||||
|
||||
def _pretty_vpn_status(self, st: VpnStatus) -> str:
|
||||
lines = [
|
||||
f"unit_state: {st.unit_state}",
|
||||
f"desired_location: {st.desired_location or '—'}",
|
||||
f"status: {st.status_word}",
|
||||
]
|
||||
if st.raw_text:
|
||||
lines.append("")
|
||||
lines.append(st.raw_text.strip())
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
# -------- Login Flow (interactive) --------
|
||||
|
||||
def login_flow_start(self) -> LoginFlowView:
|
||||
s: LoginSessionStart = self.client.vpn_login_session_start()
|
||||
|
||||
dot = self._level_to_color(s.level)
|
||||
|
||||
if not s.ok:
|
||||
txt = s.error or "Failed to start login session"
|
||||
return LoginFlowView(
|
||||
phase=s.phase or "failed",
|
||||
level=s.level or "red",
|
||||
dot_color="red",
|
||||
status_text=txt,
|
||||
url="",
|
||||
email="",
|
||||
alive=False,
|
||||
cursor=0,
|
||||
lines=[txt],
|
||||
can_open=False,
|
||||
can_check=False,
|
||||
can_cancel=False,
|
||||
)
|
||||
|
||||
if (s.phase or "").lower() == "already_logged":
|
||||
txt = (
|
||||
f"Already logged in as {s.email}"
|
||||
if s.email
|
||||
else "Already logged in"
|
||||
)
|
||||
return LoginFlowView(
|
||||
phase="already_logged",
|
||||
level="green",
|
||||
dot_color="green",
|
||||
status_text=txt,
|
||||
url="",
|
||||
email=s.email or "",
|
||||
alive=False,
|
||||
cursor=0,
|
||||
lines=[txt],
|
||||
can_open=False,
|
||||
can_check=False,
|
||||
can_cancel=False,
|
||||
)
|
||||
|
||||
txt = f"Login started (pid={s.pid})" if s.pid else "Login started"
|
||||
return LoginFlowView(
|
||||
phase=s.phase or "starting",
|
||||
level=s.level or "yellow",
|
||||
dot_color=dot,
|
||||
status_text=txt,
|
||||
url="",
|
||||
email="",
|
||||
alive=True,
|
||||
cursor=0,
|
||||
lines=[],
|
||||
can_open=True,
|
||||
can_check=True,
|
||||
can_cancel=True,
|
||||
)
|
||||
|
||||
def login_flow_poll(self, since: int) -> LoginFlowView:
|
||||
st: LoginSessionState = self.client.vpn_login_session_state(since=since)
|
||||
|
||||
dot = self._level_to_color(st.level)
|
||||
|
||||
phase = (st.phase or "").lower()
|
||||
if phase == "waiting_browser":
|
||||
status_txt = "Waiting for browser authorization…"
|
||||
elif phase == "checking":
|
||||
status_txt = "Checking…"
|
||||
elif phase == "success":
|
||||
status_txt = "✅ Logged in"
|
||||
elif phase == "failed":
|
||||
status_txt = "❌ Login failed"
|
||||
elif phase == "cancelled":
|
||||
status_txt = "Cancelled"
|
||||
elif phase == "already_logged":
|
||||
status_txt = (
|
||||
f"Already logged in as {st.email}"
|
||||
if st.email
|
||||
else "Already logged in"
|
||||
)
|
||||
else:
|
||||
status_txt = st.phase or "…"
|
||||
|
||||
clean_lines = self._clean_login_lines(st.lines)
|
||||
|
||||
return LoginFlowView(
|
||||
phase=st.phase,
|
||||
level=st.level,
|
||||
dot_color=dot,
|
||||
status_text=status_txt,
|
||||
url=st.url,
|
||||
email=st.email,
|
||||
alive=st.alive,
|
||||
cursor=st.cursor,
|
||||
can_open=st.can_open,
|
||||
can_check=st.can_cancel,
|
||||
can_cancel=st.can_cancel,
|
||||
lines=clean_lines,
|
||||
)
|
||||
|
||||
def login_flow_action(self, action: str) -> ActionView:
|
||||
act = action.strip().lower()
|
||||
if act not in ("open", "check", "cancel"):
|
||||
raise ValueError(f"Invalid login action: {action}")
|
||||
|
||||
res: LoginSessionAction = self.client.vpn_login_session_action(
|
||||
cast(LoginAction, act)
|
||||
)
|
||||
|
||||
if not res.ok:
|
||||
txt = res.error or "Login action failed"
|
||||
return ActionView(ok=False, pretty_text=txt + "\n")
|
||||
|
||||
txt = f"OK: {act} → phase={res.phase} level={res.level}"
|
||||
return ActionView(ok=True, pretty_text=txt + "\n")
|
||||
|
||||
def login_flow_stop(self) -> ActionView:
|
||||
res = self.client.vpn_login_session_stop()
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def vpn_logout(self) -> ActionView:
|
||||
res = self.client.vpn_logout()
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
# Баннер "AdGuard VPN: logged in as ...", по клику показываем инфу как в CLI
|
||||
def login_banner_cli_text(self) -> str:
|
||||
try:
|
||||
st: LoginState = self.client.get_login_state()
|
||||
except Exception as e:
|
||||
return f"Failed to query login state: {e}"
|
||||
|
||||
# backend может не иметь поля error, поэтому через getattr
|
||||
err = getattr(st, "error", None) or getattr(st, "message", None)
|
||||
if err:
|
||||
return str(err)
|
||||
|
||||
if st.email:
|
||||
return f"You are already logged in.\nCurrent user is {st.email}"
|
||||
|
||||
if st.state:
|
||||
return f"Login state: {st.state}"
|
||||
|
||||
return "No login information available."
|
||||
|
||||
# -------- Routes --------
|
||||
|
||||
Reference in New Issue
Block a user