platform: modularize api/gui, add docs-tests-web foundation, and refresh root config

This commit is contained in:
beckline
2026-03-26 22:40:54 +03:00
parent 0e2d7f61ea
commit 6a56d734c2
562 changed files with 70151 additions and 16423 deletions

View 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

View 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

View 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 --------

View 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 --------

View 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 --------

View 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}",
)

View 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)

View 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) --------

View 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] + "..."

View 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

View 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 --------