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,4 @@
from .client import ApiClient
from .errors import ApiError
from .models import *
from .utils import strip_ansi

View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""Selective-VPN API client (UI-agnostic).
Design goals:
- The dashboard (GUI) must NOT know any URLs, HTTP methods, JSON keys, or payload shapes.
- All REST details live here.
- Returned values are normalized into dataclasses for clean UI usage.
Env:
- SELECTIVE_VPN_API (default: http://127.0.0.1:8080)
This file is meant to be imported by a controller (dashboard_controller.py) and UI.
"""
from __future__ import annotations
import json
import os
import time
from typing import Any, Callable, Dict, Iterator, List, Optional
import requests
from .errors import ApiError
from .models import *
from .utils import strip_ansi
from .dns import DNSApiMixin
from .domains import DomainsApiMixin
from .routes import RoutesApiMixin
from .status import StatusApiMixin
from .trace import TraceApiMixin
from .traffic import TrafficApiMixin
from .transport import TransportApiMixin
from .vpn import VpnApiMixin
class ApiClient(
TransportApiMixin,
StatusApiMixin,
RoutesApiMixin,
TrafficApiMixin,
DNSApiMixin,
DomainsApiMixin,
VpnApiMixin,
TraceApiMixin,
):
"""Domain API client.
Public methods here are the ONLY surface the dashboard/controller should use.
"""
def __init__(
self,
base_url: str,
*,
timeout: float = 5.0,
session: Optional[requests.Session] = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = float(timeout)
self._s = session or requests.Session()
@classmethod
def from_env(
cls,
env_var: str = "SELECTIVE_VPN_API",
default: str = "http://127.0.0.1:8080",
*,
timeout: float = 5.0,
) -> "ApiClient":
base = os.environ.get(env_var, default).rstrip("/")
return cls(base, timeout=timeout)
# ---- low-level internals (private) ----
def _url(self, path: str) -> str:
if not path.startswith("/"):
path = "/" + path
return self.base_url + path
def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None,
accept_json: bool = True,
) -> requests.Response:
url = self._url(path)
headers: Dict[str, str] = {}
if accept_json:
headers["Accept"] = "application/json"
try:
resp = self._s.request(
method=method.upper(),
url=url,
params=params,
json=json_body,
timeout=self.timeout if timeout is None else float(timeout),
headers=headers,
)
except requests.RequestException as e:
raise ApiError("API request failed", method.upper(), url, None, str(e)) from e
if not (200 <= resp.status_code < 300):
txt = resp.text.strip()
raise ApiError("API returned error", method.upper(), url, resp.status_code, txt)
return resp
def _json(self, resp: requests.Response) -> Any:
if not resp.content:
return None
try:
return resp.json()
except ValueError:
# Backend should be JSON, but keep safe fallback.
return {"raw": resp.text}
# ---- event stream (SSE) ----
def events_stream(self, since: int = 0, stop: Optional[Callable[[], bool]] = None) -> Iterator[Event]:
"""
Iterate over server-sent events. Reconnects automatically on errors.
Args:
since: last seen event id (inclusive). Server will replay newer ones.
stop: optional callable returning True to stop streaming.
"""
last = max(0, int(since))
backoff = 1.0
while True:
if stop and stop():
return
try:
for ev in self._sse_once(last, stop):
if stop and stop():
return
last = ev.id if ev.id else last
yield ev
# normal end -> reconnect
backoff = 1.0
except ApiError:
# bubble up API errors; caller decides
raise
except Exception:
# transient error, retry with backoff
time.sleep(backoff)
backoff = min(backoff * 2, 10.0)
def _sse_once(self, since: int, stop: Optional[Callable[[], bool]]) -> Iterator[Event]:
headers = {
"Accept": "text/event-stream",
"Cache-Control": "no-cache",
}
params = {}
if since > 0:
params["since"] = str(since)
url = self._url("/api/v1/events/stream")
# SSE соединение живёт долго: backend шлёт heartbeat каждые 15s,
# поэтому ставим более длинный read-timeout, иначе стандартные 5s
# приводят к ложным ошибкам чтения.
read_timeout = max(self.timeout * 3, 60.0)
try:
resp = self._s.request(
method="GET",
url=url,
headers=headers,
params=params,
stream=True,
timeout=(self.timeout, read_timeout),
)
except requests.RequestException as e:
raise ApiError("API request failed", "GET", url, None, str(e)) from e
if not (200 <= resp.status_code < 300):
txt = resp.text.strip()
raise ApiError("API returned error", "GET", url, resp.status_code, txt)
ev_id: Optional[int] = None
ev_kind: str = ""
data_lines: List[str] = []
for raw in resp.iter_lines(decode_unicode=True):
if stop and stop():
resp.close()
return
if raw is None:
continue
line = raw.strip("\r")
if line == "":
if data_lines or ev_kind or ev_id is not None:
ev = self._make_event(ev_id, ev_kind, data_lines)
if ev:
yield ev
ev_id = None
ev_kind = ""
data_lines = []
continue
if line.startswith(":"):
# heartbeat/comment
continue
if line.startswith("id:"):
try:
ev_id = int(line[3:].strip())
except ValueError:
ev_id = None
continue
if line.startswith("event:"):
ev_kind = line[6:].strip()
continue
if line.startswith("data:"):
data_lines.append(line[5:].lstrip())
continue
# unknown field -> ignore
def _make_event(self, ev_id: Optional[int], ev_kind: str, data_lines: List[str]) -> Optional[Event]:
payload: Any = None
if data_lines:
data_str = "\n".join(data_lines)
try:
payload = json.loads(data_str)
except Exception:
payload = data_str
if isinstance(payload, dict):
id_val = ev_id
if id_val is None:
try:
id_val = int(payload.get("id", 0))
except Exception:
id_val = 0
kind_val = ev_kind or str(payload.get("kind") or "")
ts_val = str(payload.get("ts") or "")
data_val = payload.get("data", payload)
return Event(id=id_val, kind=kind_val, ts=ts_val, data=data_val)
return Event(id=ev_id or 0, kind=ev_kind, ts="", data=payload)
# ---- shared helpers ----
def _to_int(self, value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return int(default)
def _parse_cmd_result(self, data: Dict[str, Any]) -> CmdResult:
ok = bool(data.get("ok", False))
msg = str(data.get("message") or "")
exit_code_val = data.get("exitCode", None)
exit_code: Optional[int]
try:
exit_code = int(exit_code_val) if exit_code_val is not None else None
except (TypeError, ValueError):
exit_code = None
stdout = strip_ansi(str(data.get("stdout") or ""))
stderr = strip_ansi(str(data.get("stderr") or ""))
return CmdResult(ok=ok, message=msg, exit_code=exit_code, stdout=stdout, stderr=stderr)

View File

@@ -0,0 +1,269 @@
from __future__ import annotations
from typing import Any, Dict, List, cast
from .models import *
class DNSApiMixin:
def dns_upstreams_get(self) -> DnsUpstreams:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns-upstreams")) or {})
return DnsUpstreams(
default1=str(data.get("default1") or ""),
default2=str(data.get("default2") or ""),
meta1=str(data.get("meta1") or ""),
meta2=str(data.get("meta2") or ""),
)
def dns_upstreams_set(self, cfg: DnsUpstreams) -> None:
self._request(
"POST",
"/api/v1/dns-upstreams",
json_body={
"default1": cfg.default1,
"default2": cfg.default2,
"meta1": cfg.meta1,
"meta2": cfg.meta2,
},
)
def dns_upstream_pool_get(self) -> DNSUpstreamPoolState:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/upstream-pool")) or {})
raw = data.get("items") or []
if not isinstance(raw, list):
raw = []
items: List[DNSBenchmarkUpstream] = []
for row in raw:
if not isinstance(row, dict):
continue
addr = str(row.get("addr") or "").strip()
if not addr:
continue
items.append(DNSBenchmarkUpstream(addr=addr, enabled=bool(row.get("enabled", True))))
return DNSUpstreamPoolState(items=items)
def dns_upstream_pool_set(self, items: List[DNSBenchmarkUpstream]) -> DNSUpstreamPoolState:
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/dns/upstream-pool",
json_body={
"items": [{"addr": u.addr, "enabled": bool(u.enabled)} for u in (items or [])],
},
)
)
or {},
)
raw = data.get("items") or []
if not isinstance(raw, list):
raw = []
out: List[DNSBenchmarkUpstream] = []
for row in raw:
if not isinstance(row, dict):
continue
addr = str(row.get("addr") or "").strip()
if not addr:
continue
out.append(DNSBenchmarkUpstream(addr=addr, enabled=bool(row.get("enabled", True))))
return DNSUpstreamPoolState(items=out)
def dns_benchmark(
self,
upstreams: List[DNSBenchmarkUpstream],
domains: List[str],
timeout_ms: int = 1800,
attempts: int = 1,
concurrency: int = 6,
profile: str = "load",
) -> DNSBenchmarkResponse:
# Benchmark can legitimately run much longer than the default 5s API timeout.
# Estimate a safe read-timeout from payload size and keep an upper cap.
upstream_count = len(upstreams or [])
domain_count = len(domains or [])
if domain_count <= 0:
domain_count = 6 # backend default domains
clamped_attempts = max(1, min(int(attempts), 3))
clamped_concurrency = max(1, min(int(concurrency), 32))
if upstream_count <= 0:
upstream_count = 1
waves = (upstream_count + clamped_concurrency - 1) // clamped_concurrency
mode = str(profile or "load").strip().lower()
if mode not in ("quick", "load"):
mode = "load"
# Rough estimator for backend load profile.
load_factor = 1.0 if mode == "quick" else 6.0
per_wave_sec = domain_count * max(1, clamped_attempts) * (max(300, int(timeout_ms)) / 1000.0) * load_factor
bench_timeout = min(420.0, max(20.0, waves * per_wave_sec * 1.1 + 8.0))
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/dns/benchmark",
json_body={
"upstreams": [{"addr": u.addr, "enabled": bool(u.enabled)} for u in (upstreams or [])],
"domains": [str(d or "").strip() for d in (domains or []) if str(d or "").strip()],
"timeout_ms": int(timeout_ms),
"attempts": int(attempts),
"concurrency": int(concurrency),
"profile": mode,
},
timeout=bench_timeout,
)
)
or {},
)
raw_results = data.get("results") or []
if not isinstance(raw_results, list):
raw_results = []
results: List[DNSBenchmarkResult] = []
for row in raw_results:
if not isinstance(row, dict):
continue
results.append(
DNSBenchmarkResult(
upstream=str(row.get("upstream") or "").strip(),
attempts=int(row.get("attempts", 0) or 0),
ok=int(row.get("ok", 0) or 0),
fail=int(row.get("fail", 0) or 0),
nxdomain=int(row.get("nxdomain", 0) or 0),
timeout=int(row.get("timeout", 0) or 0),
temporary=int(row.get("temporary", 0) or 0),
other=int(row.get("other", 0) or 0),
avg_ms=int(row.get("avg_ms", 0) or 0),
p95_ms=int(row.get("p95_ms", 0) or 0),
score=float(row.get("score", 0.0) or 0.0),
color=str(row.get("color") or "").strip().lower(),
)
)
return DNSBenchmarkResponse(
results=results,
domains_used=[str(d or "").strip() for d in (data.get("domains_used") or []) if str(d or "").strip()],
timeout_ms=int(data.get("timeout_ms", 0) or 0),
attempts_per_domain=int(data.get("attempts_per_domain", 0) or 0),
profile=str(data.get("profile") or mode),
recommended_default=[str(d or "").strip() for d in (data.get("recommended_default") or []) if str(d or "").strip()],
recommended_meta=[str(d or "").strip() for d in (data.get("recommended_meta") or []) if str(d or "").strip()],
)
def dns_status_get(self) -> DNSStatus:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/status")) or {})
return self._parse_dns_status(data)
def dns_mode_set(self, via_smartdns: bool, smartdns_addr: str) -> DNSStatus:
mode = "hybrid_wildcard" if bool(via_smartdns) else "direct"
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/dns/mode",
json_body={
"via_smartdns": bool(via_smartdns),
"smartdns_addr": str(smartdns_addr or ""),
"mode": mode,
},
)
)
or {},
)
return self._parse_dns_status(data)
def dns_smartdns_service_set(self, action: ServiceAction) -> DNSStatus:
act = action.lower()
if act not in ("start", "stop", "restart"):
raise ValueError(f"Invalid action: {action}")
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/dns/smartdns-service",
json_body={"action": act},
)
)
or {},
)
if not bool(data.get("ok", False)):
raise ValueError(str(data.get("message") or f"SmartDNS {act} failed"))
return self._parse_dns_status(data)
def smartdns_service_get(self) -> SmartdnsServiceState:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/smartdns/service")) or {})
return SmartdnsServiceState(state=str(data.get("state") or "unknown"))
def smartdns_service_set(self, action: ServiceAction) -> CmdResult:
act = action.lower()
if act not in ("start", "stop", "restart"):
raise ValueError(f"Invalid action: {action}")
data = cast(
Dict[str, Any],
self._json(self._request("POST", "/api/v1/smartdns/service", json_body={"action": act}))
or {},
)
return self._parse_cmd_result(data)
def smartdns_runtime_get(self) -> SmartdnsRuntimeState:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/smartdns/runtime")) or {})
return SmartdnsRuntimeState(
enabled=bool(data.get("enabled", False)),
applied_enabled=bool(data.get("applied_enabled", False)),
wildcard_source=str(data.get("wildcard_source") or ("both" if bool(data.get("enabled", False)) else "resolver")),
unit_state=str(data.get("unit_state") or "unknown"),
config_path=str(data.get("config_path") or ""),
changed=bool(data.get("changed", False)),
restarted=bool(data.get("restarted", False)),
message=str(data.get("message") or ""),
)
def smartdns_runtime_set(self, enabled: bool, restart: bool = True) -> SmartdnsRuntimeState:
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/smartdns/runtime",
json_body={"enabled": bool(enabled), "restart": bool(restart)},
)
)
or {},
)
return SmartdnsRuntimeState(
enabled=bool(data.get("enabled", False)),
applied_enabled=bool(data.get("applied_enabled", False)),
wildcard_source=str(data.get("wildcard_source") or ("both" if bool(data.get("enabled", False)) else "resolver")),
unit_state=str(data.get("unit_state") or "unknown"),
config_path=str(data.get("config_path") or ""),
changed=bool(data.get("changed", False)),
restarted=bool(data.get("restarted", False)),
message=str(data.get("message") or ""),
)
def smartdns_prewarm(self, limit: int = 0, aggressive_subs: bool = False) -> CmdResult:
payload: Dict[str, Any] = {}
if int(limit) > 0:
payload["limit"] = int(limit)
if aggressive_subs:
payload["aggressive_subs"] = True
data = cast(
Dict[str, Any],
self._json(self._request("POST", "/api/v1/smartdns/prewarm", json_body=payload)) or {},
)
return self._parse_cmd_result(data)
def _parse_dns_status(self, data: Dict[str, Any]) -> DNSStatus:
via = bool(data.get("via_smartdns", False))
runtime = bool(data.get("runtime_nftset", True))
return DNSStatus(
via_smartdns=via,
smartdns_addr=str(data.get("smartdns_addr") or ""),
mode=str(data.get("mode") or ("hybrid_wildcard" if via else "direct")),
unit_state=str(data.get("unit_state") or "unknown"),
runtime_nftset=runtime,
wildcard_source=str(data.get("wildcard_source") or ("both" if runtime else "resolver")),
runtime_config_path=str(data.get("runtime_config_path") or ""),
runtime_config_error=str(data.get("runtime_config_error") or ""),
)

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import Any, Dict, cast
from .models import *
class DomainsApiMixin:
# Domains editor
def domains_table(self) -> DomainsTable:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/domains/table")) or {})
lines = data.get("lines") or []
if not isinstance(lines, list):
lines = []
return DomainsTable(lines=[str(x) for x in lines])
def domains_file_get(
self,
name: Literal[
"bases",
"meta",
"subs",
"static",
"smartdns",
"last-ips-map",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
],
) -> DomainsFile:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/domains/file", params={"name": name})) or {},
)
content = str(data.get("content") or "")
source = str(data.get("source") or "")
return DomainsFile(name=name, content=content, source=source)
def domains_file_set(
self,
name: Literal[
"bases",
"meta",
"subs",
"static",
"smartdns",
"last-ips-map",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
],
content: str,
) -> None:
self._request("POST", "/api/v1/domains/file", json_body={"name": name, "content": content})

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class ApiError(Exception):
"""Raised when API call fails (network or non-2xx)."""
message: str
method: str
url: str
status_code: Optional[int] = None
response_text: str = ""
def __str__(self) -> str:
code = f" ({self.status_code})" if self.status_code is not None else ""
tail = f": {self.response_text}" if self.response_text else ""
return f"{self.message}{code} [{self.method} {self.url}]{tail}"

View File

@@ -0,0 +1,788 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Literal, Optional
# ---------------------------
# Models (UI-friendly)
# ---------------------------
@dataclass(frozen=True)
class Status:
timestamp: str
ip_count: int
domain_count: int
iface: str
table: str
mark: str
# NOTE: backend uses omitempty for these, so they may be absent.
policy_route_ok: Optional[bool]
route_ok: Optional[bool]
@dataclass(frozen=True)
class CmdResult:
ok: bool
message: str
exit_code: Optional[int] = None
stdout: str = ""
stderr: str = ""
@dataclass(frozen=True)
class LoginState:
state: str
email: str
msg: str
# backend may also provide UI-ready fields
text: str
color: str
@dataclass(frozen=True)
class UnitState:
state: str
@dataclass(frozen=True)
class RoutesTimerState:
enabled: bool
@dataclass(frozen=True)
class TrafficModeStatus:
mode: str
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
rule_mark: bool
rule_full: bool
ingress_rule_present: bool
ingress_nft_active: bool
table_default: bool
probe_ok: bool
probe_message: str
healthy: bool
message: str
@dataclass(frozen=True)
class TrafficInterfaces:
interfaces: List[str]
preferred_iface: str
active_iface: str
iface_reason: str
@dataclass(frozen=True)
class TrafficAppMarksStatus:
vpn_count: int
direct_count: int
message: str
@dataclass(frozen=True)
class TrafficAppMarksResult:
ok: bool
message: str
op: str = ""
target: str = ""
cgroup: str = ""
cgroup_id: int = 0
timeout_sec: int = 0
@dataclass(frozen=True)
class TrafficAppMarkItem:
id: int
target: str # vpn|direct
cgroup: str
cgroup_rel: str
level: int
unit: str
command: str
app_key: str
added_at: str
expires_at: str
remaining_sec: int
@dataclass(frozen=True)
class TrafficAppProfile:
id: str
name: str
app_key: str
command: str
target: str # vpn|direct
ttl_sec: int
vpn_profile: str
created_at: str
updated_at: str
@dataclass(frozen=True)
class TrafficAppProfileSaveResult:
ok: bool
message: str
profile: Optional[TrafficAppProfile] = None
@dataclass(frozen=True)
class TrafficAudit:
ok: bool
message: str
now: str
pretty: str
issues: List[str]
@dataclass(frozen=True)
class TransportClientHealth:
last_check: str
latency_ms: int
last_error: str
@dataclass(frozen=True)
class TransportClient:
id: str
name: str
kind: str
enabled: bool
status: str
iface: str
routing_table: str
mark_hex: str
priority_base: int
capabilities: List[str]
health: TransportClientHealth
config: Dict[str, Any]
updated_at: str
@dataclass(frozen=True)
class TransportHealthRefreshItem:
client_id: str
status: str
queued: bool
reason: str
@dataclass(frozen=True)
class TransportHealthRefreshResult:
ok: bool
message: str
code: str
count: int
queued: int
skipped: int
items: List[TransportHealthRefreshItem]
@dataclass(frozen=True)
class TransportClientHealthSnapshot:
client_id: str
status: str
latency_ms: int
last_error: str
last_check: str
@dataclass(frozen=True)
class TransportPolicyIntent:
selector_type: str
selector_value: str
client_id: str
priority: int = 100
mode: str = "strict"
@dataclass(frozen=True)
class TransportPolicy:
revision: int
intents: List[TransportPolicyIntent]
@dataclass(frozen=True)
class TransportConflict:
key: str
type: str
severity: str
owners: List[str]
reason: str
suggested_resolution: str
@dataclass(frozen=True)
class TransportPolicyValidateSummary:
block_count: int
warn_count: int
@dataclass(frozen=True)
class TransportPolicyDiff:
added: int
changed: int
removed: int
@dataclass(frozen=True)
class TransportPolicyValidateResult:
ok: bool
message: str
code: str
valid: bool
base_revision: int
confirm_token: str
summary: TransportPolicyValidateSummary
conflicts: List[TransportConflict]
diff: TransportPolicyDiff
@dataclass(frozen=True)
class TransportPolicyApplyResult:
ok: bool
message: str
code: str
policy_revision: int
current_revision: int
apply_id: str
rollback_available: bool
conflicts: List[TransportConflict]
@dataclass(frozen=True)
class TransportConflicts:
has_blocking: bool
items: List[TransportConflict]
@dataclass(frozen=True)
class TransportCapabilities:
clients: Dict[str, Dict[str, bool]]
@dataclass(frozen=True)
class TransportInterfaceItem:
id: str
name: str
mode: str
runtime_iface: str
netns_name: str
routing_table: str
client_ids: List[str]
client_count: int
up_count: int
updated_at: str
config: Dict[str, Any]
@dataclass(frozen=True)
class TransportInterfacesSnapshot:
ok: bool
message: str
code: str
count: int
items: List[TransportInterfaceItem]
@dataclass(frozen=True)
class TransportOwnershipRecord:
key: str
selector_type: str
selector_value: str
client_id: str
client_kind: str
owner_scope: str
owner_status: str
lock_active: bool
iface_id: str
routing_table: str
mark_hex: str
priority_base: int
mode: str
priority: int
updated_at: str
@dataclass(frozen=True)
class TransportOwnershipSnapshot:
ok: bool
message: str
code: str
policy_revision: int
plan_digest: str
count: int
lock_count: int
items: List[TransportOwnershipRecord]
@dataclass(frozen=True)
class TransportOwnerLockRecord:
destination_ip: str
client_id: str
client_kind: str
iface_id: str
mark_hex: str
proto: str
updated_at: str
@dataclass(frozen=True)
class TransportOwnerLocksSnapshot:
ok: bool
message: str
code: str
policy_revision: int
count: int
items: List[TransportOwnerLockRecord]
@dataclass(frozen=True)
class TransportOwnerLocksClearResult:
ok: bool
message: str
code: str
base_revision: int
confirm_required: bool
confirm_token: str
match_count: int
cleared_count: int
remaining_count: int
items: List[TransportOwnerLockRecord]
@dataclass(frozen=True)
class TransportClientActionResult:
ok: bool
message: str
code: str
client_id: str
kind: str
action: str
status_before: str
status_after: str
last_error: str
exit_code: Optional[int] = None
stdout: str = ""
stderr: str = ""
@dataclass(frozen=True)
class TransportNetnsToggleItem:
ok: bool
message: str
code: str
client_id: str
kind: str
status_before: str
status_after: str
netns_enabled: bool
config_updated: bool
provisioned: bool
restarted: bool
stdout: str = ""
stderr: str = ""
@dataclass(frozen=True)
class TransportNetnsToggleResult:
ok: bool
message: str
code: str
enabled: bool
count: int
success_count: int
failure_count: int
items: List[TransportNetnsToggleItem]
@dataclass(frozen=True)
class SingBoxProfileIssue:
field: str
severity: str
code: str
message: str
@dataclass(frozen=True)
class SingBoxProfileRenderDiff:
added: int
changed: int
removed: int
@dataclass(frozen=True)
class SingBoxProfileValidateResult:
ok: bool
message: str
code: str
profile_id: str
profile_revision: int
valid: bool
errors: List[SingBoxProfileIssue]
warnings: List[SingBoxProfileIssue]
render_digest: str
diff: SingBoxProfileRenderDiff
@dataclass(frozen=True)
class SingBoxProfileApplyResult:
ok: bool
message: str
code: str
profile_id: str
client_id: str
config_path: str
profile_revision: int
render_revision: int
last_applied_at: str
render_path: str
render_digest: str
rollback_available: bool
valid: bool
errors: List[SingBoxProfileIssue]
warnings: List[SingBoxProfileIssue]
diff: SingBoxProfileRenderDiff
@dataclass(frozen=True)
class SingBoxProfile:
id: str
name: str
mode: str
protocol: str
enabled: bool
schema_version: int
profile_revision: int
render_revision: int
last_validated_at: str
last_applied_at: str
last_error: str
typed: Dict[str, Any]
raw_config: Dict[str, Any]
meta: Dict[str, Any]
has_secrets: bool
secrets_masked: Dict[str, str]
created_at: str
updated_at: str
@dataclass(frozen=True)
class SingBoxProfilesState:
ok: bool
message: str
code: str
count: int
active_profile_id: str
items: List[SingBoxProfile]
item: Optional[SingBoxProfile] = None
@dataclass(frozen=True)
class SingBoxProfileRenderResult:
ok: bool
message: str
code: str
profile_id: str
profile_revision: int
render_revision: int
render_path: str
render_digest: str
changed: bool
valid: bool
errors: List[SingBoxProfileIssue]
warnings: List[SingBoxProfileIssue]
diff: SingBoxProfileRenderDiff
config: Dict[str, Any]
@dataclass(frozen=True)
class SingBoxProfileRollbackResult:
ok: bool
message: str
code: str
profile_id: str
client_id: str
config_path: str
history_id: str
profile_revision: int
last_applied_at: str
@dataclass(frozen=True)
class SingBoxProfileHistoryEntry:
id: str
at: str
profile_id: str
action: str
status: str
code: str
message: str
profile_revision: int
render_revision: int
render_digest: str
render_path: str
client_id: str
@dataclass(frozen=True)
class SingBoxProfileHistoryResult:
ok: bool
message: str
code: str
profile_id: str
count: int
items: List[SingBoxProfileHistoryEntry]
@dataclass(frozen=True)
class TrafficCandidateSubnet:
cidr: str
dev: str
kind: str
linkdown: bool
@dataclass(frozen=True)
class TrafficCandidateUnit:
unit: str
description: str
cgroup: str
@dataclass(frozen=True)
class TrafficCandidateUID:
uid: int
user: str
examples: List[str]
@dataclass(frozen=True)
class TrafficCandidates:
generated_at: str
subnets: List[TrafficCandidateSubnet]
units: List[TrafficCandidateUnit]
uids: List[TrafficCandidateUID]
@dataclass(frozen=True)
class DnsUpstreams:
default1: str
default2: str
meta1: str
meta2: str
@dataclass(frozen=True)
class DNSBenchmarkUpstream:
addr: str
enabled: bool = True
@dataclass(frozen=True)
class DNSBenchmarkResult:
upstream: str
attempts: int
ok: int
fail: int
nxdomain: int
timeout: int
temporary: int
other: int
avg_ms: int
p95_ms: int
score: float
color: str
@dataclass(frozen=True)
class DNSBenchmarkResponse:
results: List[DNSBenchmarkResult]
domains_used: List[str]
timeout_ms: int
attempts_per_domain: int
profile: str
recommended_default: List[str]
recommended_meta: List[str]
@dataclass(frozen=True)
class DNSUpstreamPoolState:
items: List[DNSBenchmarkUpstream]
@dataclass(frozen=True)
class SmartdnsServiceState:
state: str
@dataclass(frozen=True)
class DNSStatus:
via_smartdns: bool
smartdns_addr: str
mode: str
unit_state: str
runtime_nftset: bool
wildcard_source: str
runtime_config_path: str
runtime_config_error: str
@dataclass(frozen=True)
class SmartdnsRuntimeState:
enabled: bool
applied_enabled: bool
wildcard_source: str
unit_state: str
config_path: str
changed: bool = False
restarted: bool = False
message: str = ""
@dataclass(frozen=True)
class DomainsTable:
lines: List[str]
@dataclass(frozen=True)
class DomainsFile:
name: str
content: str
source: str = ""
@dataclass(frozen=True)
class VpnAutoloopStatus:
raw_text: str
status_word: str
@dataclass(frozen=True)
class VpnStatus:
desired_location: str
status_word: str
raw_text: str
unit_state: str
@dataclass(frozen=True)
class VpnLocation:
label: str
iso: str
target: str
@dataclass(frozen=True)
class VpnLocationsState:
locations: List[VpnLocation]
updated_at: str
stale: bool
refresh_in_progress: bool
last_error: str
next_retry_at: str
@dataclass(frozen=True)
class EgressIdentity:
scope: str
source: str
source_id: str
ip: str
country_code: str
country_name: str
updated_at: str
stale: bool
refresh_in_progress: bool
last_error: str
next_retry_at: str
@dataclass(frozen=True)
class EgressIdentityRefreshItem:
scope: str
status: str
queued: bool
reason: str
@dataclass(frozen=True)
class EgressIdentityRefreshResult:
ok: bool
message: str
count: int
queued: int
skipped: int
items: List[EgressIdentityRefreshItem]
@dataclass(frozen=True)
class TraceDump:
lines: List[str]
@dataclass(frozen=True)
class Event:
id: int
kind: str
ts: str
data: Any
# ---------------------------
# AdGuard VPN interactive login-session (PTY)
# ---------------------------
@dataclass(frozen=True)
class LoginSessionStart:
ok: bool
phase: str
level: str
pid: Optional[int] = None
email: str = ""
error: str = ""
@dataclass(frozen=True)
class LoginSessionState:
ok: bool
phase: str
level: str
alive: bool
url: str
email: str
cursor: int
lines: List[str]
can_open: bool
can_check: bool
can_cancel: bool
@dataclass(frozen=True)
class LoginSessionAction:
ok: bool
phase: str = ""
level: str = ""
error: str = ""
TraceMode = Literal["full", "gui", "smartdns"]
ServiceAction = Literal["start", "stop", "restart"]
TransportClientAction = Literal["provision", "start", "stop", "restart"]

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
from typing import Any, Dict, cast
import requests
from .errors import ApiError
from .models import *
class RoutesApiMixin:
# Routes
def routes_service(self, action: ServiceAction) -> CmdResult:
action_l = action.lower()
if action_l not in ("start", "stop", "restart"):
raise ValueError(f"Invalid action: {action}")
url = self._url("/api/v1/routes/service")
payload = {"action": action_l}
try:
# Короткий read-timeout: если systemctl завис на минуты, выходим,
# но backend продолжает выполнение (не привязан к request context).
resp = self._s.post(url, json=payload, timeout=(self.timeout, 2.0))
except requests.Timeout:
return CmdResult(
ok=True,
message=f"{action_l} accepted; backend is still running systemctl",
exit_code=None,
)
except requests.RequestException as e:
raise ApiError("API request failed", "POST", url, None, str(e)) from e
if not (200 <= resp.status_code < 300):
txt = resp.text.strip()
raise ApiError("API returned error", "POST", url, resp.status_code, txt)
data = cast(Dict[str, Any], self._json(resp) or {})
return self._parse_cmd_result(data)
def routes_clear(self) -> CmdResult:
data = cast(Dict[str, Any], self._json(self._request("POST", "/api/v1/routes/clear")) or {})
return self._parse_cmd_result(data)
def routes_cache_restore(self) -> CmdResult:
data = cast(
Dict[str, Any],
self._json(self._request("POST", "/api/v1/routes/cache/restore")) or {},
)
return self._parse_cmd_result(data)
def routes_precheck_debug(self, run_now: bool = True) -> CmdResult:
url = self._url("/api/v1/routes/precheck/debug")
payload = {"run_now": bool(run_now)}
try:
# Endpoint может запускать routes restart и выходить за 5s timeout.
# Для GUI считаем timeout признаком фонового принятого действия.
resp = self._s.post(url, json=payload, timeout=(self.timeout, 2.0))
except requests.Timeout:
return CmdResult(
ok=True,
message="precheck debug accepted; backend is still running",
exit_code=None,
)
except requests.RequestException as e:
raise ApiError("API request failed", "POST", url, None, str(e)) from e
if not (200 <= resp.status_code < 300):
txt = resp.text.strip()
raise ApiError("API returned error", "POST", url, resp.status_code, txt)
data = cast(Dict[str, Any], self._json(resp) or {})
return self._parse_cmd_result(data)
def routes_fix_policy_route(self) -> CmdResult:
data = cast(Dict[str, Any], self._json(self._request("POST", "/api/v1/routes/fix-policy-route")) or {})
return self._parse_cmd_result(data)
def routes_timer_get(self) -> RoutesTimerState:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/routes/timer")) or {})
return RoutesTimerState(enabled=bool(data.get("enabled", False)))
def routes_timer_set(self, enabled: bool) -> CmdResult:
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/routes/timer",
json_body={"enabled": bool(enabled)},
)
)
or {},
)
return self._parse_cmd_result(data)

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from typing import Any, Dict, cast
from .models import *
from .utils import strip_ansi
class StatusApiMixin:
# Status / system
def get_status(self) -> Status:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/status")) or {})
return Status(
timestamp=str(data.get("timestamp") or ""),
ip_count=int(data.get("ip_count") or 0),
domain_count=int(data.get("domain_count") or 0),
iface=str(data.get("iface") or ""),
table=str(data.get("table") or ""),
mark=str(data.get("mark") or ""),
policy_route_ok=cast(Optional[bool], data.get("policy_route_ok", None)),
route_ok=cast(Optional[bool], data.get("route_ok", None)),
)
def systemd_state(self, unit: str) -> UnitState:
data = cast(
Dict[str, Any],
self._json(
self._request("GET", "/api/v1/systemd/state", params={"unit": unit}, timeout=2.0)
)
or {},
)
st = str(data.get("state") or "unknown").strip() or "unknown"
return UnitState(state=st)
def get_login_state(self) -> LoginState:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/vpn/login-state", timeout=2.0)) or {})
# Normalize and strip ANSI.
state = str(data.get("state") or "unknown").strip()
email = strip_ansi(str(data.get("email") or "").strip())
msg = strip_ansi(str(data.get("msg") or "").strip())
text = strip_ansi(str(data.get("text") or "").strip())
color = str(data.get("color") or "").strip()
return LoginState(
state=state,
email=email,
msg=msg,
text=text,
color=color,
)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Any, Dict, cast
from .errors import ApiError
from .models import *
from .utils import strip_ansi
class TraceApiMixin:
# Trace
def trace_get(self, mode: TraceMode = "full") -> TraceDump:
m = str(mode).lower().strip()
if m not in ("full", "gui", "smartdns"):
m = "full"
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/trace-json", params={"mode": m}, timeout=5.0)) or {},
)
lines = data.get("lines") or []
if not isinstance(lines, list):
lines = []
return TraceDump(lines=[strip_ansi(str(x)) for x in lines])
def trace_append(self, kind: Literal["gui", "smartdns", "info"], line: str) -> None:
try:
self._request(
"POST",
"/api/v1/trace/append",
json_body={"kind": kind, "line": str(line)},
timeout=2.0,
)
except ApiError:
# Logging must never crash UI.
pass

View File

@@ -0,0 +1,397 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, cast
from .models import *
from .utils import strip_ansi
class TrafficApiMixin:
def traffic_mode_get(self) -> TrafficModeStatus:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/traffic/mode")) or {})
return self._parse_traffic_mode_status(data, fallback_mode="selective")
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,
) -> TrafficModeStatus:
m = str(mode or "").strip().lower()
if m not in ("selective", "full_tunnel", "direct"):
raise ValueError(f"Invalid traffic mode: {mode}")
payload: Dict[str, Any] = {"mode": m}
if preferred_iface is not None:
payload["preferred_iface"] = str(preferred_iface).strip()
if auto_local_bypass is not None:
payload["auto_local_bypass"] = bool(auto_local_bypass)
if ingress_reply_bypass is not None:
payload["ingress_reply_bypass"] = bool(ingress_reply_bypass)
if force_vpn_subnets is not None:
payload["force_vpn_subnets"] = [str(x) for x in force_vpn_subnets]
if force_vpn_uids is not None:
payload["force_vpn_uids"] = [str(x) for x in force_vpn_uids]
if force_vpn_cgroups is not None:
payload["force_vpn_cgroups"] = [str(x) for x in force_vpn_cgroups]
if force_direct_subnets is not None:
payload["force_direct_subnets"] = [str(x) for x in force_direct_subnets]
if force_direct_uids is not None:
payload["force_direct_uids"] = [str(x) for x in force_direct_uids]
if force_direct_cgroups is not None:
payload["force_direct_cgroups"] = [str(x) for x in force_direct_cgroups]
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/traffic/mode",
json_body=payload,
)
)
or {},
)
return self._parse_traffic_mode_status(data, fallback_mode=m)
def traffic_mode_test(self) -> TrafficModeStatus:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/traffic/mode/test")) or {},
)
return self._parse_traffic_mode_status(data, fallback_mode="selective")
def traffic_advanced_reset(self) -> TrafficModeStatus:
data = cast(
Dict[str, Any],
self._json(self._request("POST", "/api/v1/traffic/advanced/reset")) or {},
)
return self._parse_traffic_mode_status(data, fallback_mode="selective")
def traffic_interfaces_get(self) -> TrafficInterfaces:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/traffic/interfaces")) or {},
)
raw = data.get("interfaces") or []
if not isinstance(raw, list):
raw = []
return TrafficInterfaces(
interfaces=[str(x) for x in raw if str(x).strip()],
preferred_iface=str(data.get("preferred_iface") or ""),
active_iface=str(data.get("active_iface") or ""),
iface_reason=str(data.get("iface_reason") or ""),
)
def traffic_candidates_get(self) -> TrafficCandidates:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/traffic/candidates")) or {},
)
subnets: List[TrafficCandidateSubnet] = []
for it in (data.get("subnets") or []):
if not isinstance(it, dict):
continue
cidr = str(it.get("cidr") or "").strip()
if not cidr:
continue
subnets.append(
TrafficCandidateSubnet(
cidr=cidr,
dev=str(it.get("dev") or "").strip(),
kind=str(it.get("kind") or "").strip(),
linkdown=bool(it.get("linkdown", False)),
)
)
units: List[TrafficCandidateUnit] = []
for it in (data.get("units") or []):
if not isinstance(it, dict):
continue
unit = str(it.get("unit") or "").strip()
if not unit:
continue
units.append(
TrafficCandidateUnit(
unit=unit,
description=str(it.get("description") or "").strip(),
cgroup=str(it.get("cgroup") or "").strip(),
)
)
uids: List[TrafficCandidateUID] = []
for it in (data.get("uids") or []):
if not isinstance(it, dict):
continue
try:
uid = int(it.get("uid", 0) or 0)
except Exception:
continue
user = str(it.get("user") or "").strip()
raw_ex = it.get("examples") or []
if not isinstance(raw_ex, list):
raw_ex = []
examples = [str(x) for x in raw_ex if str(x).strip()]
uids.append(TrafficCandidateUID(uid=uid, user=user, examples=examples))
return TrafficCandidates(
generated_at=str(data.get("generated_at") or ""),
subnets=subnets,
units=units,
uids=uids,
)
def traffic_appmarks_status(self) -> TrafficAppMarksStatus:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/traffic/appmarks")) or {},
)
return TrafficAppMarksStatus(
vpn_count=int(data.get("vpn_count", 0) or 0),
direct_count=int(data.get("direct_count", 0) or 0),
message=str(data.get("message") or ""),
)
def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/traffic/appmarks/items")) or {},
)
raw = data.get("items") or []
if not isinstance(raw, list):
raw = []
out: List[TrafficAppMarkItem] = []
for it in raw:
if not isinstance(it, dict):
continue
try:
mid = int(it.get("id", 0) or 0)
except Exception:
mid = 0
tgt = str(it.get("target") or "").strip().lower()
if mid <= 0 or tgt not in ("vpn", "direct"):
continue
out.append(
TrafficAppMarkItem(
id=mid,
target=tgt,
cgroup=str(it.get("cgroup") or "").strip(),
cgroup_rel=str(it.get("cgroup_rel") or "").strip(),
level=int(it.get("level", 0) or 0),
unit=str(it.get("unit") or "").strip(),
command=str(it.get("command") or "").strip(),
app_key=str(it.get("app_key") or "").strip(),
added_at=str(it.get("added_at") or "").strip(),
expires_at=str(it.get("expires_at") or "").strip(),
remaining_sec=int(it.get("remaining_sec", 0) or 0),
)
)
return out
def traffic_appmarks_apply(
self,
*,
op: str,
target: str,
cgroup: str = "",
unit: str = "",
command: str = "",
app_key: str = "",
timeout_sec: int = 0,
) -> TrafficAppMarksResult:
payload: Dict[str, Any] = {
"op": str(op or "").strip().lower(),
"target": str(target or "").strip().lower(),
}
if cgroup:
payload["cgroup"] = str(cgroup).strip()
if unit:
payload["unit"] = str(unit).strip()
if command:
payload["command"] = str(command).strip()
if app_key:
payload["app_key"] = str(app_key).strip()
if int(timeout_sec or 0) > 0:
payload["timeout_sec"] = int(timeout_sec)
data = cast(
Dict[str, Any],
self._json(self._request("POST", "/api/v1/traffic/appmarks", json_body=payload))
or {},
)
return TrafficAppMarksResult(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or ""),
op=str(data.get("op") or payload["op"]),
target=str(data.get("target") or payload["target"]),
cgroup=str(data.get("cgroup") or payload.get("cgroup") or ""),
cgroup_id=int(data.get("cgroup_id", 0) or 0),
timeout_sec=int(data.get("timeout_sec", 0) or 0),
)
def traffic_app_profiles_list(self) -> List[TrafficAppProfile]:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/traffic/app-profiles")) or {},
)
raw = data.get("profiles") or []
if not isinstance(raw, list):
raw = []
out: List[TrafficAppProfile] = []
for it in raw:
if not isinstance(it, dict):
continue
pid = str(it.get("id") or "").strip()
if not pid:
continue
out.append(
TrafficAppProfile(
id=pid,
name=str(it.get("name") or "").strip(),
app_key=str(it.get("app_key") or "").strip(),
command=str(it.get("command") or "").strip(),
target=str(it.get("target") or "").strip().lower(),
ttl_sec=int(it.get("ttl_sec", 0) or 0),
vpn_profile=str(it.get("vpn_profile") or "").strip(),
created_at=str(it.get("created_at") or "").strip(),
updated_at=str(it.get("updated_at") or "").strip(),
)
)
return out
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:
payload: Dict[str, Any] = {
"command": str(command or "").strip(),
"target": str(target or "").strip().lower(),
}
if id:
payload["id"] = str(id).strip()
if name:
payload["name"] = str(name).strip()
if app_key:
payload["app_key"] = str(app_key).strip()
if int(ttl_sec or 0) > 0:
payload["ttl_sec"] = int(ttl_sec)
if vpn_profile:
payload["vpn_profile"] = str(vpn_profile).strip()
data = cast(
Dict[str, Any],
self._json(
self._request("POST", "/api/v1/traffic/app-profiles", json_body=payload)
)
or {},
)
msg = str(data.get("message") or "")
raw = data.get("profiles") or []
if not isinstance(raw, list):
raw = []
prof: Optional[TrafficAppProfile] = None
if raw and isinstance(raw[0], dict):
it = cast(Dict[str, Any], raw[0])
pid = str(it.get("id") or "").strip()
if pid:
prof = TrafficAppProfile(
id=pid,
name=str(it.get("name") or "").strip(),
app_key=str(it.get("app_key") or "").strip(),
command=str(it.get("command") or "").strip(),
target=str(it.get("target") or "").strip().lower(),
ttl_sec=int(it.get("ttl_sec", 0) or 0),
vpn_profile=str(it.get("vpn_profile") or "").strip(),
created_at=str(it.get("created_at") or "").strip(),
updated_at=str(it.get("updated_at") or "").strip(),
)
ok = bool(prof) and (msg.strip().lower() in ("saved", "ok"))
if not msg and ok:
msg = "saved"
return TrafficAppProfileSaveResult(ok=ok, message=msg, profile=prof)
def traffic_app_profile_delete(self, id: str) -> CmdResult:
pid = str(id or "").strip()
if not pid:
raise ValueError("missing id")
data = cast(
Dict[str, Any],
self._json(
self._request("DELETE", "/api/v1/traffic/app-profiles", params={"id": pid})
)
or {},
)
return CmdResult(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or ""),
exit_code=None,
stdout="",
stderr="",
)
def traffic_audit_get(self) -> TrafficAudit:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/traffic/audit")) or {},
)
raw_issues = data.get("issues") or []
if not isinstance(raw_issues, list):
raw_issues = []
return TrafficAudit(
ok=bool(data.get("ok", False)),
message=strip_ansi(str(data.get("message") or "").strip()),
now=str(data.get("now") or "").strip(),
pretty=strip_ansi(str(data.get("pretty") or "").strip()),
issues=[strip_ansi(str(x)).strip() for x in raw_issues if str(x).strip()],
)
def _parse_traffic_mode_status(self, data: Dict[str, Any], *, fallback_mode: str) -> TrafficModeStatus:
mode = str(data.get("mode") or fallback_mode or "selective")
return TrafficModeStatus(
mode=mode,
desired_mode=str(data.get("desired_mode") or data.get("mode") or mode),
applied_mode=str(data.get("applied_mode") or "direct"),
preferred_iface=str(data.get("preferred_iface") or ""),
advanced_active=bool(data.get("advanced_active", False)),
auto_local_bypass=bool(data.get("auto_local_bypass", True)),
auto_local_active=bool(data.get("auto_local_active", False)),
ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)),
ingress_reply_active=bool(data.get("ingress_reply_active", False)),
bypass_candidates=int(data.get("bypass_candidates", 0) or 0),
force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()],
force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()],
force_vpn_cgroups=[str(x) for x in (data.get("force_vpn_cgroups") or []) if str(x).strip()],
force_direct_subnets=[str(x) for x in (data.get("force_direct_subnets") or []) if str(x).strip()],
force_direct_uids=[str(x) for x in (data.get("force_direct_uids") or []) if str(x).strip()],
force_direct_cgroups=[str(x) for x in (data.get("force_direct_cgroups") or []) if str(x).strip()],
overrides_applied=int(data.get("overrides_applied", 0) or 0),
cgroup_resolved_uids=int(data.get("cgroup_resolved_uids", 0) or 0),
cgroup_warning=str(data.get("cgroup_warning") or ""),
active_iface=str(data.get("active_iface") or ""),
iface_reason=str(data.get("iface_reason") or ""),
rule_mark=bool(data.get("rule_mark", False)),
rule_full=bool(data.get("rule_full", False)),
ingress_rule_present=bool(data.get("ingress_rule_present", False)),
ingress_nft_active=bool(data.get("ingress_nft_active", False)),
table_default=bool(data.get("table_default", False)),
probe_ok=bool(data.get("probe_ok", False)),
probe_message=str(data.get("probe_message") or ""),
healthy=bool(data.get("healthy", False)),
message=str(data.get("message") or ""),
)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from .transport_clients import TransportClientsApiMixin
from .transport_policy import TransportPolicyApiMixin
from .transport_singbox import TransportSingBoxApiMixin
class TransportApiMixin(
TransportClientsApiMixin,
TransportPolicyApiMixin,
TransportSingBoxApiMixin,
):
"""Facade mixin for transport domain API.
Kept for backward compatibility with existing `from api.transport import TransportApiMixin`
imports while implementation is split by subdomain.
"""
pass

View File

@@ -0,0 +1,463 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, cast
from .models import *
from .utils import strip_ansi
class TransportClientsApiMixin:
def transport_interfaces_get(self) -> TransportInterfacesSnapshot:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/transport/interfaces")) or {},
)
raw = data.get("items") or []
if not isinstance(raw, list):
raw = []
items: List[TransportInterfaceItem] = []
for row in raw:
parsed = self._parse_transport_interface_item(row)
if parsed is not None:
items.append(parsed)
return TransportInterfacesSnapshot(
ok=bool(data.get("ok", False)),
message=strip_ansi(str(data.get("message") or "").strip()),
code=str(data.get("code") or "").strip(),
count=self._to_int(data.get("count")),
items=items,
)
def transport_clients_get(self, enabled_only: bool = False, kind: str = "", include_virtual: bool = False) -> List[TransportClient]:
params: Dict[str, Any] = {}
if enabled_only:
params["enabled_only"] = "true"
kind_l = str(kind or "").strip().lower()
if kind_l:
params["kind"] = kind_l
if include_virtual:
params["include_virtual"] = "true"
data = cast(
Dict[str, Any],
self._json(
self._request("GET", "/api/v1/transport/clients", params=(params or None))
)
or {},
)
raw = data.get("items") or []
if not isinstance(raw, list):
raw = []
out: List[TransportClient] = []
for row in raw:
item = self._parse_transport_client(row)
if item is not None:
out.append(item)
return out
def transport_health_refresh(
self,
*,
client_ids: Optional[List[str]] = None,
force: bool = False,
) -> TransportHealthRefreshResult:
payload: Dict[str, Any] = {}
ids: List[str] = []
for raw in list(client_ids or []):
cid = str(raw or "").strip()
if cid:
ids.append(cid)
if ids:
payload["client_ids"] = ids
if force:
payload["force"] = True
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/transport/health/refresh",
json_body=payload,
)
)
or {},
)
raw_items = data.get("items") or []
if not isinstance(raw_items, list):
raw_items = []
items: List[TransportHealthRefreshItem] = []
for row in raw_items:
if not isinstance(row, dict):
continue
items.append(
TransportHealthRefreshItem(
client_id=str(row.get("client_id") or "").strip(),
status=str(row.get("status") or "").strip().lower(),
queued=bool(row.get("queued", False)),
reason=strip_ansi(str(row.get("reason") or "").strip()),
)
)
return TransportHealthRefreshResult(
ok=bool(data.get("ok", False)),
message=strip_ansi(str(data.get("message") or "").strip()),
code=str(data.get("code") or "").strip(),
count=self._to_int(data.get("count")),
queued=self._to_int(data.get("queued")),
skipped=self._to_int(data.get("skipped")),
items=items,
)
def transport_client_health_get(self, client_id: str) -> TransportClientHealthSnapshot:
cid = str(client_id or "").strip()
if not cid:
raise ValueError("missing transport client id")
data = cast(
Dict[str, Any],
self._json(
self._request(
"GET",
f"/api/v1/transport/clients/{cid}/health",
)
)
or {},
)
raw_health = data.get("health") or {}
if not isinstance(raw_health, dict):
raw_health = {}
latency_raw = raw_health.get("latency_ms")
if latency_raw is None:
latency_raw = data.get("latency_ms")
last_err = (
str(raw_health.get("last_error") or "").strip()
or str(data.get("last_error") or "").strip()
)
last_check = (
str(raw_health.get("last_check") or "").strip()
or str(data.get("last_check") or "").strip()
)
return TransportClientHealthSnapshot(
client_id=str(data.get("client_id") or cid).strip(),
status=str(data.get("status") or "").strip().lower(),
latency_ms=self._to_int(latency_raw),
last_error=strip_ansi(last_err),
last_check=last_check,
)
def transport_client_create(
self,
*,
client_id: str,
kind: str,
name: str = "",
enabled: bool = True,
config: Optional[Dict[str, Any]] = None,
) -> CmdResult:
cid = str(client_id or "").strip()
if not cid:
raise ValueError("missing transport client id")
k = str(kind or "").strip().lower()
if not k:
raise ValueError("missing transport client kind")
payload: Dict[str, Any] = {
"id": cid,
"kind": k,
"enabled": bool(enabled),
}
if str(name or "").strip():
payload["name"] = str(name).strip()
if config is not None:
payload["config"] = cast(Dict[str, Any], config)
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/transport/clients",
json_body=payload,
)
)
or {},
)
return CmdResult(
ok=bool(data.get("ok", False)),
message=strip_ansi(str(data.get("message") or "").strip()),
exit_code=None,
stdout="",
stderr="",
)
def transport_client_patch(
self,
client_id: str,
*,
name: Optional[str] = None,
enabled: Optional[bool] = None,
config: Optional[Dict[str, Any]] = None,
) -> CmdResult:
cid = str(client_id or "").strip()
if not cid:
raise ValueError("missing transport client id")
payload: Dict[str, Any] = {}
if name is not None:
payload["name"] = str(name).strip()
if enabled is not None:
payload["enabled"] = bool(enabled)
if config is not None:
payload["config"] = cast(Dict[str, Any], config)
if not payload:
raise ValueError("empty patch payload")
data = cast(
Dict[str, Any],
self._json(
self._request(
"PATCH",
f"/api/v1/transport/clients/{cid}",
json_body=payload,
)
)
or {},
)
return CmdResult(
ok=bool(data.get("ok", False)),
message=strip_ansi(str(data.get("message") or "").strip()),
exit_code=None,
stdout="",
stderr="",
)
def transport_client_action(
self,
client_id: str,
action: TransportClientAction,
) -> TransportClientActionResult:
cid = str(client_id or "").strip()
if not cid:
raise ValueError("missing transport client id")
act = str(action or "").strip().lower()
if act not in ("provision", "start", "stop", "restart"):
raise ValueError(f"invalid transport action: {action}")
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
f"/api/v1/transport/clients/{cid}/{act}",
)
)
or {},
)
health_raw = data.get("health") or {}
if not isinstance(health_raw, dict):
health_raw = {}
runtime_raw = data.get("runtime") or {}
if not isinstance(runtime_raw, dict):
runtime_raw = {}
runtime_err_raw = runtime_raw.get("last_error") or {}
if not isinstance(runtime_err_raw, dict):
runtime_err_raw = {}
last_error = (
str(health_raw.get("last_error") or "").strip()
or str(runtime_err_raw.get("message") or "").strip()
or str(data.get("stderr") or "").strip()
)
exit_code_val = data.get("exitCode", None)
exit_code: Optional[int]
try:
exit_code = int(exit_code_val) if exit_code_val is not None else None
except (TypeError, ValueError):
exit_code = None
return TransportClientActionResult(
ok=bool(data.get("ok", False)),
message=strip_ansi(str(data.get("message") or "").strip()),
code=str(data.get("code") or "").strip(),
client_id=str(data.get("client_id") or cid).strip(),
kind=str(data.get("kind") or "").strip().lower(),
action=str(data.get("action") or act).strip().lower(),
status_before=str(data.get("status_before") or "").strip().lower(),
status_after=str(data.get("status_after") or "").strip().lower(),
last_error=strip_ansi(last_error),
exit_code=exit_code,
stdout=strip_ansi(str(data.get("stdout") or "")),
stderr=strip_ansi(str(data.get("stderr") or "")),
)
def transport_client_delete(
self,
client_id: str,
*,
force: bool = False,
cleanup: bool = True,
) -> CmdResult:
cid = str(client_id or "").strip()
if not cid:
raise ValueError("missing transport client id")
params: Dict[str, Any] = {}
if force:
params["force"] = "true"
if not cleanup:
params["cleanup"] = "false"
data = cast(
Dict[str, Any],
self._json(
self._request(
"DELETE",
f"/api/v1/transport/clients/{cid}",
params=(params or None),
)
)
or {},
)
return CmdResult(
ok=bool(data.get("ok", False)),
message=strip_ansi(str(data.get("message") or "").strip()),
exit_code=None,
stdout="",
stderr="",
)
def transport_netns_toggle(
self,
*,
enabled: Optional[bool] = None,
client_ids: Optional[List[str]] = None,
provision: bool = True,
restart_running: bool = True,
) -> TransportNetnsToggleResult:
payload: Dict[str, Any] = {
"provision": bool(provision),
"restart_running": bool(restart_running),
}
if enabled is not None:
payload["enabled"] = bool(enabled)
if client_ids is not None:
payload["client_ids"] = [
str(x).strip()
for x in (client_ids or [])
if str(x).strip()
]
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/transport/netns/toggle",
json_body=payload,
)
)
or {},
)
raw_items = data.get("items") or []
if not isinstance(raw_items, list):
raw_items = []
items: List[TransportNetnsToggleItem] = []
for row in raw_items:
if not isinstance(row, dict):
continue
items.append(
TransportNetnsToggleItem(
ok=bool(row.get("ok", False)),
message=strip_ansi(str(row.get("message") or "").strip()),
code=str(row.get("code") or "").strip(),
client_id=str(row.get("client_id") or "").strip(),
kind=str(row.get("kind") or "").strip().lower(),
status_before=str(row.get("status_before") or "").strip().lower(),
status_after=str(row.get("status_after") or "").strip().lower(),
netns_enabled=bool(row.get("netns_enabled", False)),
config_updated=bool(row.get("config_updated", False)),
provisioned=bool(row.get("provisioned", False)),
restarted=bool(row.get("restarted", False)),
stdout=strip_ansi(str(row.get("stdout") or "")),
stderr=strip_ansi(str(row.get("stderr") or "")),
)
)
return TransportNetnsToggleResult(
ok=bool(data.get("ok", False)),
message=strip_ansi(str(data.get("message") or "").strip()),
code=str(data.get("code") or "").strip(),
enabled=bool(data.get("enabled", False)),
count=self._to_int(data.get("count")),
success_count=self._to_int(data.get("success_count")),
failure_count=self._to_int(data.get("failure_count")),
items=items,
)
def _parse_transport_interface_item(self, row: Any) -> Optional[TransportInterfaceItem]:
if not isinstance(row, dict):
return None
iface_id = str(row.get("id") or "").strip()
if not iface_id:
return None
raw_ids = row.get("client_ids") or []
if not isinstance(raw_ids, list):
raw_ids = []
client_ids = [str(x).strip() for x in raw_ids if str(x).strip()]
cfg = row.get("config") or {}
if not isinstance(cfg, dict):
cfg = {}
return TransportInterfaceItem(
id=iface_id,
name=str(row.get("name") or "").strip(),
mode=str(row.get("mode") or "").strip().lower(),
runtime_iface=str(row.get("runtime_iface") or "").strip(),
netns_name=str(row.get("netns_name") or "").strip(),
routing_table=str(row.get("routing_table") or "").strip(),
client_ids=client_ids,
client_count=self._to_int(row.get("client_count")),
up_count=self._to_int(row.get("up_count")),
updated_at=str(row.get("updated_at") or "").strip(),
config=cast(Dict[str, Any], cfg),
)
def _parse_transport_client(self, row: Any) -> Optional[TransportClient]:
if not isinstance(row, dict):
return None
cid = str(row.get("id") or "").strip()
if not cid:
return None
raw_health = row.get("health") or {}
if not isinstance(raw_health, dict):
raw_health = {}
raw_caps = row.get("capabilities") or []
if not isinstance(raw_caps, list):
raw_caps = []
raw_cfg = row.get("config") or {}
if not isinstance(raw_cfg, dict):
raw_cfg = {}
return TransportClient(
id=cid,
name=str(row.get("name") or "").strip(),
kind=str(row.get("kind") or "").strip().lower(),
enabled=bool(row.get("enabled", False)),
status=str(row.get("status") or "").strip().lower(),
iface=str(row.get("iface") or "").strip(),
routing_table=str(row.get("routing_table") or "").strip(),
mark_hex=str(row.get("mark_hex") or "").strip(),
priority_base=self._to_int(row.get("priority_base")),
capabilities=[str(x).strip() for x in raw_caps if str(x).strip()],
health=TransportClientHealth(
last_check=str(raw_health.get("last_check") or "").strip(),
latency_ms=self._to_int(raw_health.get("latency_ms")),
last_error=str(raw_health.get("last_error") or "").strip(),
),
config=cast(Dict[str, Any], raw_cfg),
updated_at=str(row.get("updated_at") or "").strip(),
)

View File

@@ -0,0 +1,422 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, cast
from .models import *
class TransportPolicyApiMixin:
def transport_policy_get(self) -> TransportPolicy:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/transport/policies")) or {},
)
raw = data.get("intents") or []
if not isinstance(raw, list):
raw = []
intents: List[TransportPolicyIntent] = []
for row in raw:
it = self._parse_transport_intent(row)
if it is not None:
intents.append(it)
return TransportPolicy(
revision=self._to_int(data.get("policy_revision")),
intents=intents,
)
def transport_policy_validate(
self,
*,
base_revision: int = 0,
intents: List[TransportPolicyIntent],
allow_warnings: bool = True,
force_override: bool = False,
) -> TransportPolicyValidateResult:
payload: Dict[str, Any] = {
"intents": [self._transport_intent_payload(it) for it in (intents or [])],
}
if int(base_revision or 0) > 0:
payload["base_revision"] = int(base_revision)
payload["options"] = {
"allow_warnings": bool(allow_warnings),
"force_override": bool(force_override),
}
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/transport/policies/validate",
json_body=payload,
)
)
or {},
)
summary_raw = data.get("summary") or {}
if not isinstance(summary_raw, dict):
summary_raw = {}
diff_raw = data.get("diff") or {}
if not isinstance(diff_raw, dict):
diff_raw = {}
conflicts_raw = data.get("conflicts") or []
if not isinstance(conflicts_raw, list):
conflicts_raw = []
conflicts: List[TransportConflict] = []
for row in conflicts_raw:
c = self._parse_transport_conflict(row)
if c is not None:
conflicts.append(c)
return TransportPolicyValidateResult(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or ""),
code=str(data.get("code") or ""),
valid=bool(data.get("valid", False)),
base_revision=self._to_int(data.get("base_revision")),
confirm_token=str(data.get("confirm_token") or "").strip(),
summary=TransportPolicyValidateSummary(
block_count=self._to_int(summary_raw.get("block_count")),
warn_count=self._to_int(summary_raw.get("warn_count")),
),
conflicts=conflicts,
diff=TransportPolicyDiff(
added=self._to_int(diff_raw.get("added")),
changed=self._to_int(diff_raw.get("changed")),
removed=self._to_int(diff_raw.get("removed")),
),
)
def transport_policy_apply(
self,
*,
base_revision: int,
intents: List[TransportPolicyIntent],
force_override: bool = False,
confirm_token: str = "",
) -> TransportPolicyApplyResult:
payload: Dict[str, Any] = {
"base_revision": int(base_revision),
"intents": [self._transport_intent_payload(it) for it in (intents or [])],
}
opts: Dict[str, Any] = {}
if force_override:
opts["force_override"] = True
if confirm_token:
opts["confirm_token"] = str(confirm_token).strip()
if opts:
payload["options"] = opts
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/transport/policies/apply",
json_body=payload,
)
)
or {},
)
return self._parse_transport_policy_apply(data)
def transport_policy_rollback(self, *, base_revision: int = 0) -> TransportPolicyApplyResult:
payload: Dict[str, Any] = {}
if int(base_revision or 0) > 0:
payload["base_revision"] = int(base_revision)
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/transport/policies/rollback",
json_body=payload,
)
)
or {},
)
return self._parse_transport_policy_apply(data)
def transport_conflicts_get(self) -> TransportConflicts:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/transport/conflicts")) or {},
)
raw = data.get("items") or []
if not isinstance(raw, list):
raw = []
items: List[TransportConflict] = []
for row in raw:
c = self._parse_transport_conflict(row)
if c is not None:
items.append(c)
return TransportConflicts(
has_blocking=bool(data.get("has_blocking", False)),
items=items,
)
def transport_capabilities_get(self) -> TransportCapabilities:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/transport/capabilities")) or {},
)
raw = data.get("clients") or {}
if not isinstance(raw, dict):
raw = {}
clients: Dict[str, Dict[str, bool]] = {}
for kind, caps_raw in raw.items():
key = str(kind or "").strip().lower()
if not key:
continue
if not isinstance(caps_raw, dict):
continue
caps: Dict[str, bool] = {}
for cap_name, cap_value in caps_raw.items():
cname = str(cap_name or "").strip().lower()
if not cname:
continue
caps[cname] = bool(cap_value)
clients[key] = caps
return TransportCapabilities(clients=clients)
def transport_ownership_get(self) -> TransportOwnershipSnapshot:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/transport/owners")) or {},
)
raw = data.get("items") or []
if not isinstance(raw, list):
raw = []
items: List[TransportOwnershipRecord] = []
for row in raw:
rec = self._parse_transport_ownership_record(row)
if rec is not None:
items.append(rec)
return TransportOwnershipSnapshot(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or "").strip(),
code=str(data.get("code") or "").strip(),
policy_revision=self._to_int(data.get("policy_revision")),
plan_digest=str(data.get("plan_digest") or "").strip(),
count=self._to_int(data.get("count")),
lock_count=self._to_int(data.get("lock_count")),
items=items,
)
def transport_owner_locks_get(self) -> TransportOwnerLocksSnapshot:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/transport/owner-locks")) or {},
)
raw = data.get("items") or []
if not isinstance(raw, list):
raw = []
items: List[TransportOwnerLockRecord] = []
for row in raw:
rec = self._parse_transport_owner_lock_record(row)
if rec is not None:
items.append(rec)
return TransportOwnerLocksSnapshot(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or "").strip(),
code=str(data.get("code") or "").strip(),
policy_revision=self._to_int(data.get("policy_revision")),
count=self._to_int(data.get("count")),
items=items,
)
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:
payload: Dict[str, Any] = {}
if int(base_revision or 0) > 0:
payload["base_revision"] = int(base_revision)
cid = str(client_id or "").strip()
if cid:
payload["client_id"] = cid
dst = str(destination_ip or "").strip()
if dst:
payload["destination_ip"] = dst
ips: List[str] = []
for raw in list(destination_ips or []):
value = str(raw or "").strip()
if not value:
continue
ips.append(value)
if ips:
payload["destination_ips"] = ips
token = str(confirm_token or "").strip()
if token:
payload["confirm_token"] = token
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/transport/owner-locks/clear",
json_body=payload,
)
)
or {},
)
raw = data.get("items") or []
if not isinstance(raw, list):
raw = []
items: List[TransportOwnerLockRecord] = []
for row in raw:
rec = self._parse_transport_owner_lock_record(row)
if rec is not None:
items.append(rec)
return TransportOwnerLocksClearResult(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or "").strip(),
code=str(data.get("code") or "").strip(),
base_revision=self._to_int(data.get("base_revision")),
confirm_required=bool(data.get("confirm_required", False)),
confirm_token=str(data.get("confirm_token") or "").strip(),
match_count=self._to_int(data.get("match_count")),
cleared_count=self._to_int(data.get("cleared_count")),
remaining_count=self._to_int(data.get("remaining_count")),
items=items,
)
def _parse_transport_intent(self, row: Any) -> Optional[TransportPolicyIntent]:
if not isinstance(row, dict):
return None
selector_type = str(row.get("selector_type") or "").strip().lower()
selector_value = str(row.get("selector_value") or "").strip()
client_id = str(row.get("client_id") or "").strip()
if not selector_type or not selector_value or not client_id:
return None
mode = str(row.get("mode") or "strict").strip().lower() or "strict"
if mode not in ("strict", "fallback"):
mode = "strict"
priority = self._to_int(row.get("priority"), default=100)
if priority <= 0:
priority = 100
return TransportPolicyIntent(
selector_type=selector_type,
selector_value=selector_value,
client_id=client_id,
priority=priority,
mode=mode,
)
def _transport_intent_payload(self, intent: TransportPolicyIntent) -> Dict[str, Any]:
if isinstance(intent, dict):
src = cast(Dict[str, Any], intent)
priority = self._to_int(src.get("priority"), default=100)
mode = str(src.get("mode") or "strict").strip().lower()
selector_type = str(src.get("selector_type") or "").strip().lower()
selector_value = str(src.get("selector_value") or "").strip()
client_id = str(src.get("client_id") or "").strip()
else:
priority = int(getattr(intent, "priority", 100) or 100)
mode = str(getattr(intent, "mode", "strict") or "strict").strip().lower()
selector_type = str(getattr(intent, "selector_type", "") or "").strip().lower()
selector_value = str(getattr(intent, "selector_value", "") or "").strip()
client_id = str(getattr(intent, "client_id", "") or "").strip()
if mode not in ("strict", "fallback"):
mode = "strict"
payload: Dict[str, Any] = {
"selector_type": selector_type,
"selector_value": selector_value,
"client_id": client_id,
"priority": priority,
"mode": mode,
}
return payload
def _parse_transport_conflict(self, row: Any) -> Optional[TransportConflict]:
if not isinstance(row, dict):
return None
key = str(row.get("key") or "").strip()
if not key:
return None
raw_owners = row.get("owners") or []
if not isinstance(raw_owners, list):
raw_owners = []
owners = [str(x).strip() for x in raw_owners if str(x).strip()]
return TransportConflict(
key=key,
type=str(row.get("type") or "").strip().lower(),
severity=str(row.get("severity") or "warn").strip().lower(),
owners=owners,
reason=str(row.get("reason") or "").strip(),
suggested_resolution=str(row.get("suggested_resolution") or "").strip(),
)
def _parse_transport_ownership_record(self, row: Any) -> Optional[TransportOwnershipRecord]:
if not isinstance(row, dict):
return None
key = str(row.get("key") or "").strip()
selector_type = str(row.get("selector_type") or "").strip().lower()
selector_value = str(row.get("selector_value") or "").strip()
client_id = str(row.get("client_id") or "").strip()
if not key or not selector_type or not selector_value or not client_id:
return None
return TransportOwnershipRecord(
key=key,
selector_type=selector_type,
selector_value=selector_value,
client_id=client_id,
client_kind=str(row.get("client_kind") or "").strip().lower(),
owner_scope=str(row.get("owner_scope") or "").strip(),
owner_status=str(row.get("owner_status") or "").strip().lower(),
lock_active=bool(row.get("lock_active", False)),
iface_id=str(row.get("iface_id") or "").strip(),
routing_table=str(row.get("routing_table") or "").strip(),
mark_hex=str(row.get("mark_hex") or "").strip(),
priority_base=self._to_int(row.get("priority_base")),
mode=str(row.get("mode") or "").strip().lower(),
priority=self._to_int(row.get("priority")),
updated_at=str(row.get("updated_at") or "").strip(),
)
def _parse_transport_owner_lock_record(self, row: Any) -> Optional[TransportOwnerLockRecord]:
if not isinstance(row, dict):
return None
destination_ip = str(row.get("destination_ip") or "").strip()
client_id = str(row.get("client_id") or "").strip()
if not destination_ip or not client_id:
return None
return TransportOwnerLockRecord(
destination_ip=destination_ip,
client_id=client_id,
client_kind=str(row.get("client_kind") or "").strip().lower(),
iface_id=str(row.get("iface_id") or "").strip(),
mark_hex=str(row.get("mark_hex") or "").strip(),
proto=str(row.get("proto") or "").strip().lower(),
updated_at=str(row.get("updated_at") or "").strip(),
)
def _parse_transport_policy_apply(self, data: Dict[str, Any]) -> TransportPolicyApplyResult:
raw = data.get("conflicts") or []
if not isinstance(raw, list):
raw = []
conflicts: List[TransportConflict] = []
for row in raw:
c = self._parse_transport_conflict(row)
if c is not None:
conflicts.append(c)
return TransportPolicyApplyResult(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or "").strip(),
code=str(data.get("code") or "").strip(),
policy_revision=self._to_int(data.get("policy_revision")),
current_revision=self._to_int(data.get("current_revision")),
apply_id=str(data.get("apply_id") or "").strip(),
rollback_available=bool(data.get("rollback_available", False)),
conflicts=conflicts,
)

View File

@@ -0,0 +1,623 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, cast
from .errors import ApiError
from .models import *
class TransportSingBoxApiMixin:
def transport_singbox_profiles_get(
self,
*,
enabled_only: bool = False,
mode: str = "",
protocol: str = "",
) -> SingBoxProfilesState:
params: Dict[str, Any] = {}
if enabled_only:
params["enabled_only"] = "true"
mode_v = str(mode or "").strip().lower()
if mode_v:
params["mode"] = mode_v
protocol_v = str(protocol or "").strip().lower()
if protocol_v:
params["protocol"] = protocol_v
data = cast(
Dict[str, Any],
self._json(
self._request(
"GET",
"/api/v1/transport/singbox/profiles",
params=(params or None),
)
)
or {},
)
return self._parse_singbox_profiles_state(data)
def transport_singbox_profile_get(self, profile_id: str) -> SingBoxProfile:
pid = str(profile_id or "").strip()
if not pid:
raise ValueError("missing singbox profile id")
data = cast(
Dict[str, Any],
self._json(self._request("GET", f"/api/v1/transport/singbox/profiles/{pid}")) or {},
)
snap = self._parse_singbox_profiles_state(data)
if snap.item is None:
raise ApiError(
"API returned malformed singbox profile payload",
"GET",
self._url(f"/api/v1/transport/singbox/profiles/{pid}"),
)
return snap.item
def transport_singbox_profile_create(
self,
*,
profile_id: str = "",
name: str = "",
mode: str = "raw",
protocol: str = "",
enabled: Optional[bool] = None,
schema_version: int = 1,
typed: Optional[Dict[str, Any]] = None,
raw_config: Optional[Dict[str, Any]] = None,
meta: Optional[Dict[str, Any]] = None,
secrets: Optional[Dict[str, str]] = None,
) -> SingBoxProfilesState:
payload: Dict[str, Any] = {
"id": str(profile_id or "").strip(),
"name": str(name or "").strip(),
"mode": str(mode or "raw").strip().lower(),
"protocol": str(protocol or "").strip().lower(),
"schema_version": int(schema_version or 1),
}
if enabled is not None:
payload["enabled"] = bool(enabled)
if typed is not None:
payload["typed"] = cast(Dict[str, Any], typed)
if raw_config is not None:
payload["raw_config"] = cast(Dict[str, Any], raw_config)
if meta is not None:
payload["meta"] = cast(Dict[str, Any], meta)
if secrets is not None:
payload["secrets"] = {
str(k).strip(): str(v)
for k, v in cast(Dict[str, Any], secrets).items()
if str(k).strip()
}
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/transport/singbox/profiles",
json_body=payload,
)
)
or {},
)
return self._parse_singbox_profiles_state(data)
def transport_singbox_profile_patch(
self,
profile_id: str,
*,
base_revision: int = 0,
name: Optional[str] = None,
mode: Optional[str] = None,
protocol: Optional[str] = None,
enabled: Optional[bool] = None,
schema_version: Optional[int] = None,
typed: Optional[Dict[str, Any]] = None,
raw_config: Optional[Dict[str, Any]] = None,
meta: Optional[Dict[str, Any]] = None,
secrets: Optional[Dict[str, str]] = None,
clear_secrets: bool = False,
) -> SingBoxProfilesState:
pid = str(profile_id or "").strip()
if not pid:
raise ValueError("missing singbox profile id")
payload: Dict[str, Any] = {}
if int(base_revision or 0) > 0:
payload["base_revision"] = int(base_revision)
if name is not None:
payload["name"] = str(name)
if mode is not None:
payload["mode"] = str(mode or "").strip().lower()
if protocol is not None:
payload["protocol"] = str(protocol or "").strip().lower()
if enabled is not None:
payload["enabled"] = bool(enabled)
if schema_version is not None:
payload["schema_version"] = int(schema_version)
if typed is not None:
payload["typed"] = cast(Dict[str, Any], typed)
if raw_config is not None:
payload["raw_config"] = cast(Dict[str, Any], raw_config)
if meta is not None:
payload["meta"] = cast(Dict[str, Any], meta)
if secrets is not None:
payload["secrets"] = {
str(k).strip(): str(v)
for k, v in cast(Dict[str, Any], secrets).items()
if str(k).strip()
}
if clear_secrets:
payload["clear_secrets"] = True
data = cast(
Dict[str, Any],
self._json(
self._request(
"PATCH",
f"/api/v1/transport/singbox/profiles/{pid}",
json_body=payload,
)
)
or {},
)
return self._parse_singbox_profiles_state(data)
def transport_singbox_profile_render(
self,
profile_id: str,
*,
base_revision: int = 0,
check_binary: Optional[bool] = None,
persist: Optional[bool] = None,
) -> SingBoxProfileRenderResult:
pid = str(profile_id or "").strip()
if not pid:
raise ValueError("missing singbox profile id")
payload: Dict[str, Any] = {}
if int(base_revision or 0) > 0:
payload["base_revision"] = int(base_revision)
if check_binary is not None:
payload["check_binary"] = bool(check_binary)
if persist is not None:
payload["persist"] = bool(persist)
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
f"/api/v1/transport/singbox/profiles/{pid}/render",
json_body=payload,
)
)
or {},
)
return self._parse_singbox_profile_render(data, fallback_id=pid)
def transport_singbox_profile_validate(
self,
profile_id: str,
*,
base_revision: int = 0,
check_binary: Optional[bool] = None,
) -> SingBoxProfileValidateResult:
pid = str(profile_id or "").strip()
if not pid:
raise ValueError("missing singbox profile id")
payload: Dict[str, Any] = {}
if int(base_revision or 0) > 0:
payload["base_revision"] = int(base_revision)
if check_binary is not None:
payload["check_binary"] = bool(check_binary)
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
f"/api/v1/transport/singbox/profiles/{pid}/validate",
json_body=payload,
)
)
or {},
)
return self._parse_singbox_profile_validate(data, fallback_id=pid)
def transport_singbox_profile_apply(
self,
profile_id: str,
*,
client_id: str = "",
config_path: str = "",
restart: Optional[bool] = None,
skip_runtime: bool = False,
check_binary: Optional[bool] = None,
base_revision: int = 0,
) -> SingBoxProfileApplyResult:
pid = str(profile_id or "").strip()
if not pid:
raise ValueError("missing singbox profile id")
payload: Dict[str, Any] = {}
if int(base_revision or 0) > 0:
payload["base_revision"] = int(base_revision)
cid = str(client_id or "").strip()
if cid:
payload["client_id"] = cid
path = str(config_path or "").strip()
if path:
payload["config_path"] = path
if restart is not None:
payload["restart"] = bool(restart)
if bool(skip_runtime):
payload["skip_runtime"] = True
if check_binary is not None:
payload["check_binary"] = bool(check_binary)
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
f"/api/v1/transport/singbox/profiles/{pid}/apply",
json_body=payload,
)
)
or {},
)
return self._parse_singbox_profile_apply(data, fallback_id=pid, fallback_client=cid)
def transport_singbox_profile_rollback(
self,
profile_id: str,
*,
base_revision: int = 0,
client_id: str = "",
config_path: str = "",
history_id: str = "",
restart: Optional[bool] = None,
skip_runtime: bool = False,
) -> SingBoxProfileRollbackResult:
pid = str(profile_id or "").strip()
if not pid:
raise ValueError("missing singbox profile id")
payload: Dict[str, Any] = {}
if int(base_revision or 0) > 0:
payload["base_revision"] = int(base_revision)
cid = str(client_id or "").strip()
if cid:
payload["client_id"] = cid
path = str(config_path or "").strip()
if path:
payload["config_path"] = path
hid = str(history_id or "").strip()
if hid:
payload["history_id"] = hid
if restart is not None:
payload["restart"] = bool(restart)
if bool(skip_runtime):
payload["skip_runtime"] = True
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
f"/api/v1/transport/singbox/profiles/{pid}/rollback",
json_body=payload,
)
)
or {},
)
return self._parse_singbox_profile_rollback(data, fallback_id=pid, fallback_client=cid)
def transport_singbox_profile_history(
self,
profile_id: str,
*,
limit: int = 20,
) -> SingBoxProfileHistoryResult:
pid = str(profile_id or "").strip()
if not pid:
raise ValueError("missing singbox profile id")
lim = int(limit or 0)
if lim <= 0:
lim = 20
data = cast(
Dict[str, Any],
self._json(
self._request(
"GET",
f"/api/v1/transport/singbox/profiles/{pid}/history",
params={"limit": str(lim)},
)
)
or {},
)
return self._parse_singbox_profile_history(data, fallback_id=pid)
# DNS / SmartDNS
def _parse_singbox_profile_issue(self, row: Any) -> Optional[SingBoxProfileIssue]:
if not isinstance(row, dict):
return None
return SingBoxProfileIssue(
field=str(row.get("field") or "").strip(),
severity=str(row.get("severity") or "").strip().lower(),
code=str(row.get("code") or "").strip(),
message=str(row.get("message") or "").strip(),
)
def _parse_singbox_profile_diff(self, raw: Any) -> SingBoxProfileRenderDiff:
data = raw if isinstance(raw, dict) else {}
return SingBoxProfileRenderDiff(
added=self._to_int(data.get("added")),
changed=self._to_int(data.get("changed")),
removed=self._to_int(data.get("removed")),
)
def _parse_singbox_profile(self, raw: Any) -> Optional[SingBoxProfile]:
if not isinstance(raw, dict):
return None
pid = str(raw.get("id") or "").strip()
if not pid:
return None
typed_raw = raw.get("typed") or {}
if not isinstance(typed_raw, dict):
typed_raw = {}
raw_cfg = raw.get("raw_config") or {}
if not isinstance(raw_cfg, dict):
raw_cfg = {}
meta_raw = raw.get("meta") or {}
if not isinstance(meta_raw, dict):
meta_raw = {}
masked_raw = raw.get("secrets_masked") or {}
if not isinstance(masked_raw, dict):
masked_raw = {}
masked: Dict[str, str] = {}
for k, v in masked_raw.items():
key = str(k or "").strip()
if not key:
continue
masked[key] = str(v or "")
return SingBoxProfile(
id=pid,
name=str(raw.get("name") or "").strip(),
mode=str(raw.get("mode") or "").strip().lower(),
protocol=str(raw.get("protocol") or "").strip().lower(),
enabled=bool(raw.get("enabled", False)),
schema_version=self._to_int(raw.get("schema_version"), default=1),
profile_revision=self._to_int(raw.get("profile_revision")),
render_revision=self._to_int(raw.get("render_revision")),
last_validated_at=str(raw.get("last_validated_at") or "").strip(),
last_applied_at=str(raw.get("last_applied_at") or "").strip(),
last_error=str(raw.get("last_error") or "").strip(),
typed=cast(Dict[str, Any], typed_raw),
raw_config=cast(Dict[str, Any], raw_cfg),
meta=cast(Dict[str, Any], meta_raw),
has_secrets=bool(raw.get("has_secrets", False)),
secrets_masked=masked,
created_at=str(raw.get("created_at") or "").strip(),
updated_at=str(raw.get("updated_at") or "").strip(),
)
def _parse_singbox_profiles_state(self, data: Dict[str, Any]) -> SingBoxProfilesState:
raw_items = data.get("items") or []
if not isinstance(raw_items, list):
raw_items = []
items: List[SingBoxProfile] = []
for row in raw_items:
p = self._parse_singbox_profile(row)
if p is not None:
items.append(p)
item = self._parse_singbox_profile(data.get("item"))
return SingBoxProfilesState(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or "").strip(),
code=str(data.get("code") or "").strip(),
count=self._to_int(data.get("count"), default=len(items)),
active_profile_id=str(data.get("active_profile_id") or "").strip(),
items=items,
item=item,
)
def _parse_singbox_profile_validate(
self,
data: Dict[str, Any],
*,
fallback_id: str = "",
) -> SingBoxProfileValidateResult:
raw_errors = data.get("errors") or []
if not isinstance(raw_errors, list):
raw_errors = []
raw_warnings = data.get("warnings") or []
if not isinstance(raw_warnings, list):
raw_warnings = []
errors: List[SingBoxProfileIssue] = []
for row in raw_errors:
issue = self._parse_singbox_profile_issue(row)
if issue is not None:
errors.append(issue)
warnings: List[SingBoxProfileIssue] = []
for row in raw_warnings:
issue = self._parse_singbox_profile_issue(row)
if issue is not None:
warnings.append(issue)
return SingBoxProfileValidateResult(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or "").strip(),
code=str(data.get("code") or "").strip(),
profile_id=str(data.get("profile_id") or fallback_id).strip(),
profile_revision=self._to_int(data.get("profile_revision")),
valid=bool(data.get("valid", False)),
errors=errors,
warnings=warnings,
render_digest=str(data.get("render_digest") or "").strip(),
diff=self._parse_singbox_profile_diff(data.get("diff")),
)
def _parse_singbox_profile_apply(
self,
data: Dict[str, Any],
*,
fallback_id: str = "",
fallback_client: str = "",
) -> SingBoxProfileApplyResult:
raw_errors = data.get("errors") or []
if not isinstance(raw_errors, list):
raw_errors = []
raw_warnings = data.get("warnings") or []
if not isinstance(raw_warnings, list):
raw_warnings = []
errors: List[SingBoxProfileIssue] = []
for row in raw_errors:
issue = self._parse_singbox_profile_issue(row)
if issue is not None:
errors.append(issue)
warnings: List[SingBoxProfileIssue] = []
for row in raw_warnings:
issue = self._parse_singbox_profile_issue(row)
if issue is not None:
warnings.append(issue)
return SingBoxProfileApplyResult(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or "").strip(),
code=str(data.get("code") or "").strip(),
profile_id=str(data.get("profile_id") or fallback_id).strip(),
client_id=str(data.get("client_id") or fallback_client).strip(),
config_path=str(data.get("config_path") or "").strip(),
profile_revision=self._to_int(data.get("profile_revision")),
render_revision=self._to_int(data.get("render_revision")),
last_applied_at=str(data.get("last_applied_at") or "").strip(),
render_path=str(data.get("render_path") or "").strip(),
render_digest=str(data.get("render_digest") or "").strip(),
rollback_available=bool(data.get("rollback_available", False)),
valid=bool(data.get("valid", False)),
errors=errors,
warnings=warnings,
diff=self._parse_singbox_profile_diff(data.get("diff")),
)
def _parse_singbox_profile_render(
self,
data: Dict[str, Any],
*,
fallback_id: str = "",
) -> SingBoxProfileRenderResult:
raw_errors = data.get("errors") or []
if not isinstance(raw_errors, list):
raw_errors = []
raw_warnings = data.get("warnings") or []
if not isinstance(raw_warnings, list):
raw_warnings = []
raw_config = data.get("config") or {}
if not isinstance(raw_config, dict):
raw_config = {}
errors: List[SingBoxProfileIssue] = []
for row in raw_errors:
issue = self._parse_singbox_profile_issue(row)
if issue is not None:
errors.append(issue)
warnings: List[SingBoxProfileIssue] = []
for row in raw_warnings:
issue = self._parse_singbox_profile_issue(row)
if issue is not None:
warnings.append(issue)
return SingBoxProfileRenderResult(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or "").strip(),
code=str(data.get("code") or "").strip(),
profile_id=str(data.get("profile_id") or fallback_id).strip(),
profile_revision=self._to_int(data.get("profile_revision")),
render_revision=self._to_int(data.get("render_revision")),
render_path=str(data.get("render_path") or "").strip(),
render_digest=str(data.get("render_digest") or "").strip(),
changed=bool(data.get("changed", False)),
valid=bool(data.get("valid", False)),
errors=errors,
warnings=warnings,
diff=self._parse_singbox_profile_diff(data.get("diff")),
config=cast(Dict[str, Any], raw_config),
)
def _parse_singbox_profile_rollback(
self,
data: Dict[str, Any],
*,
fallback_id: str = "",
fallback_client: str = "",
) -> SingBoxProfileRollbackResult:
return SingBoxProfileRollbackResult(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or "").strip(),
code=str(data.get("code") or "").strip(),
profile_id=str(data.get("profile_id") or fallback_id).strip(),
client_id=str(data.get("client_id") or fallback_client).strip(),
config_path=str(data.get("config_path") or "").strip(),
history_id=str(data.get("history_id") or "").strip(),
profile_revision=self._to_int(data.get("profile_revision")),
last_applied_at=str(data.get("last_applied_at") or "").strip(),
)
def _parse_singbox_profile_history_entry(self, raw: Any) -> Optional[SingBoxProfileHistoryEntry]:
if not isinstance(raw, dict):
return None
hid = str(raw.get("id") or "").strip()
if not hid:
return None
return SingBoxProfileHistoryEntry(
id=hid,
at=str(raw.get("at") or "").strip(),
profile_id=str(raw.get("profile_id") or "").strip(),
action=str(raw.get("action") or "").strip().lower(),
status=str(raw.get("status") or "").strip().lower(),
code=str(raw.get("code") or "").strip(),
message=str(raw.get("message") or "").strip(),
profile_revision=self._to_int(raw.get("profile_revision")),
render_revision=self._to_int(raw.get("render_revision")),
render_digest=str(raw.get("render_digest") or "").strip(),
render_path=str(raw.get("render_path") or "").strip(),
client_id=str(raw.get("client_id") or "").strip(),
)
def _parse_singbox_profile_history(
self,
data: Dict[str, Any],
*,
fallback_id: str = "",
) -> SingBoxProfileHistoryResult:
raw_items = data.get("items") or []
if not isinstance(raw_items, list):
raw_items = []
items: List[SingBoxProfileHistoryEntry] = []
for row in raw_items:
entry = self._parse_singbox_profile_history_entry(row)
if entry is not None:
items.append(entry)
return SingBoxProfileHistoryResult(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or "").strip(),
code=str(data.get("code") or "").strip(),
profile_id=str(data.get("profile_id") or fallback_id).strip(),
count=self._to_int(data.get("count"), default=len(items)),
items=items,
)

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
import re
_ANSI_RE = re.compile(r"\x1B\[[0-9;]*[A-Za-z]")
def strip_ansi(s: str) -> str:
"""Remove ANSI escape sequences."""
if not s:
return ""
return _ANSI_RE.sub("", s)

View File

@@ -0,0 +1,282 @@
from __future__ import annotations
from typing import Any, Dict, List, cast
from .models import *
from .utils import strip_ansi
class VpnApiMixin:
# VPN
def vpn_autoloop_status(self) -> VpnAutoloopStatus:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/vpn/autoloop-status", timeout=2.0)) or {})
raw = strip_ansi(str(data.get("raw_text") or "").strip())
word = str(data.get("status_word") or "unknown").strip()
return VpnAutoloopStatus(raw_text=raw, status_word=word)
def vpn_status(self) -> VpnStatus:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/vpn/status", timeout=2.0)) or {})
return VpnStatus(
desired_location=str(data.get("desired_location") or "").strip(),
status_word=str(data.get("status_word") or "unknown").strip(),
raw_text=strip_ansi(str(data.get("raw_text") or "").strip()),
unit_state=str(data.get("unit_state") or "unknown").strip(),
)
def vpn_autoconnect(self, enable: bool) -> CmdResult:
action = "start" if enable else "stop"
data = cast(
Dict[str, Any],
self._json(self._request("POST", "/api/v1/vpn/autoconnect", json_body={"action": action})) or {},
)
return self._parse_cmd_result(data)
def vpn_locations_state(self) -> VpnLocationsState:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/vpn/locations", timeout=3.0)) or {},
)
locs = data.get("locations") or []
res: List[VpnLocation] = []
if isinstance(locs, list):
for item in locs:
if isinstance(item, dict):
label = str(item.get("label") or "")
iso = str(item.get("iso") or "")
target = str(item.get("target") or "").strip()
if label and iso:
if not target:
target = iso
res.append(VpnLocation(label=label, iso=iso, target=target))
return VpnLocationsState(
locations=res,
updated_at=str(data.get("updated_at") or "").strip(),
stale=bool(data.get("stale", False)),
refresh_in_progress=bool(data.get("refresh_in_progress", False)),
last_error=strip_ansi(str(data.get("last_error") or "").strip()),
next_retry_at=str(data.get("next_retry_at") or "").strip(),
)
def vpn_locations(self) -> List[VpnLocation]:
return self.vpn_locations_state().locations
def vpn_locations_refresh_trigger(self) -> None:
self._request(
"GET",
"/api/v1/vpn/locations",
params={"refresh": "1"},
timeout=2.0,
)
def vpn_set_location(self, target: str, iso: str = "", label: str = "") -> None:
val = str(target).strip()
if not val:
raise ValueError("target is required")
self._request(
"POST",
"/api/v1/vpn/location",
json_body={
"target": val,
"iso": str(iso).strip(),
"label": str(label).strip(),
},
)
def egress_identity_get(self, scope: str, *, refresh: bool = False) -> EgressIdentity:
scope_v = str(scope or "").strip()
if not scope_v:
raise ValueError("scope is required")
params: Dict[str, Any] = {"scope": scope_v}
if refresh:
params["refresh"] = "1"
data = cast(
Dict[str, Any],
self._json(
self._request(
"GET",
"/api/v1/egress/identity",
params=params,
timeout=2.0,
)
)
or {},
)
item_raw = data.get("item") or {}
if not isinstance(item_raw, dict):
item_raw = {}
return self._parse_egress_identity(item_raw, scope_fallback=scope_v)
def egress_identity_refresh(
self,
*,
scopes: Optional[List[str]] = None,
force: bool = False,
) -> EgressIdentityRefreshResult:
payload: Dict[str, Any] = {}
scope_items: List[str] = []
for raw in list(scopes or []):
v = str(raw or "").strip()
if v:
scope_items.append(v)
if scope_items:
payload["scopes"] = scope_items
if force:
payload["force"] = True
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/egress/identity/refresh",
json_body=payload,
timeout=2.0,
)
)
or {},
)
raw_items = data.get("items") or []
if not isinstance(raw_items, list):
raw_items = []
items: List[EgressIdentityRefreshItem] = []
for row in raw_items:
if not isinstance(row, dict):
continue
items.append(
EgressIdentityRefreshItem(
scope=str(row.get("scope") or "").strip(),
status=str(row.get("status") or "").strip().lower(),
queued=bool(row.get("queued", False)),
reason=strip_ansi(str(row.get("reason") or "").strip()),
)
)
return EgressIdentityRefreshResult(
ok=bool(data.get("ok", False)),
message=strip_ansi(str(data.get("message") or "").strip()),
count=self._to_int(data.get("count")),
queued=self._to_int(data.get("queued")),
skipped=self._to_int(data.get("skipped")),
items=items,
)
# ---- AdGuard VPN interactive login-session ----
def vpn_login_session_start(self) -> LoginSessionStart:
data = cast(
Dict[str, Any],
self._json(self._request("POST", "/api/v1/vpn/login/session/start", timeout=10.0)) or {},
)
pid_val = data.get("pid", None)
pid: Optional[int]
try:
pid = int(pid_val) if pid_val is not None else None
except (TypeError, ValueError):
pid = None
return LoginSessionStart(
ok=bool(data.get("ok", False)),
phase=str(data.get("phase") or ""),
level=str(data.get("level") or ""),
pid=pid,
email=strip_ansi(str(data.get("email") or "").strip()),
error=strip_ansi(str(data.get("error") or "").strip()),
)
def vpn_login_session_state(self, since: int = 0) -> LoginSessionState:
since_i = int(since) if since is not None else 0
data = cast(
Dict[str, Any],
self._json(
self._request(
"GET",
"/api/v1/vpn/login/session/state",
params={"since": str(max(0, since_i))},
timeout=5.0,
)
)
or {},
)
lines = data.get("lines") or []
if not isinstance(lines, list):
lines = []
cursor_val = data.get("cursor", 0)
try:
cursor = int(cursor_val)
except (TypeError, ValueError):
cursor = 0
return LoginSessionState(
ok=bool(data.get("ok", False)),
phase=str(data.get("phase") or ""),
level=str(data.get("level") or ""),
alive=bool(data.get("alive", False)),
url=strip_ansi(str(data.get("url") or "").strip()),
email=strip_ansi(str(data.get("email") or "").strip()),
cursor=cursor,
lines=[strip_ansi(str(x)) for x in lines],
can_open=bool(data.get("can_open", False)),
can_check=bool(data.get("can_check", False)),
can_cancel=bool(data.get("can_cancel", False)),
)
def vpn_login_session_action(self, action: Literal["open", "check", "cancel"]) -> LoginSessionAction:
act = str(action).strip().lower()
if act not in ("open", "check", "cancel"):
raise ValueError(f"Invalid login-session action: {action}")
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/vpn/login/session/action",
json_body={"action": act},
timeout=10.0,
)
)
or {},
)
return LoginSessionAction(
ok=bool(data.get("ok", False)),
phase=str(data.get("phase") or ""),
level=str(data.get("level") or ""),
error=strip_ansi(str(data.get("error") or "").strip()),
)
def vpn_login_session_stop(self) -> CmdResult:
# Stop returns {"ok": true}; wrap into CmdResult for controller consistency.
data = cast(
Dict[str, Any],
self._json(self._request("POST", "/api/v1/vpn/login/session/stop", timeout=10.0)) or {},
)
ok = bool(data.get("ok", False))
return CmdResult(ok=ok, message="login session stopped" if ok else "failed to stop login session")
def vpn_logout(self) -> CmdResult:
data = cast(Dict[str, Any], self._json(self._request("POST", "/api/v1/vpn/logout", timeout=20.0)) or {})
return self._parse_cmd_result(data)
def _parse_egress_identity(
self,
raw: Dict[str, Any],
*,
scope_fallback: str = "",
) -> EgressIdentity:
data = raw if isinstance(raw, dict) else {}
return EgressIdentity(
scope=str(data.get("scope") or scope_fallback).strip(),
source=str(data.get("source") or "").strip().lower(),
source_id=str(data.get("source_id") or "").strip(),
ip=str(data.get("ip") or "").strip(),
country_code=str(data.get("country_code") or "").strip().upper(),
country_name=str(data.get("country_name") or "").strip(),
updated_at=str(data.get("updated_at") or "").strip(),
stale=bool(data.get("stale", False)),
refresh_in_progress=bool(data.get("refresh_in_progress", False)),
last_error=strip_ansi(str(data.get("last_error") or "").strip()),
next_retry_at=str(data.get("next_retry_at") or "").strip(),
)