platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
4
selective-vpn-gui/api/__init__.py
Normal file
4
selective-vpn-gui/api/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .client import ApiClient
|
||||
from .errors import ApiError
|
||||
from .models import *
|
||||
from .utils import strip_ansi
|
||||
262
selective-vpn-gui/api/client.py
Normal file
262
selective-vpn-gui/api/client.py
Normal 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)
|
||||
269
selective-vpn-gui/api/dns.py
Normal file
269
selective-vpn-gui/api/dns.py
Normal 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 ""),
|
||||
)
|
||||
54
selective-vpn-gui/api/domains.py
Normal file
54
selective-vpn-gui/api/domains.py
Normal 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})
|
||||
20
selective-vpn-gui/api/errors.py
Normal file
20
selective-vpn-gui/api/errors.py
Normal 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}"
|
||||
788
selective-vpn-gui/api/models.py
Normal file
788
selective-vpn-gui/api/models.py
Normal 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"]
|
||||
93
selective-vpn-gui/api/routes.py
Normal file
93
selective-vpn-gui/api/routes.py
Normal 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)
|
||||
50
selective-vpn-gui/api/status.py
Normal file
50
selective-vpn-gui/api/status.py
Normal 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,
|
||||
)
|
||||
35
selective-vpn-gui/api/trace.py
Normal file
35
selective-vpn-gui/api/trace.py
Normal 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
|
||||
397
selective-vpn-gui/api/traffic.py
Normal file
397
selective-vpn-gui/api/traffic.py
Normal 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 ""),
|
||||
)
|
||||
19
selective-vpn-gui/api/transport.py
Normal file
19
selective-vpn-gui/api/transport.py
Normal 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
|
||||
463
selective-vpn-gui/api/transport_clients.py
Normal file
463
selective-vpn-gui/api/transport_clients.py
Normal 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(),
|
||||
)
|
||||
422
selective-vpn-gui/api/transport_policy.py
Normal file
422
selective-vpn-gui/api/transport_policy.py
Normal 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,
|
||||
)
|
||||
623
selective-vpn-gui/api/transport_singbox.py
Normal file
623
selective-vpn-gui/api/transport_singbox.py
Normal 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,
|
||||
)
|
||||
|
||||
12
selective-vpn-gui/api/utils.py
Normal file
12
selective-vpn-gui/api/utils.py
Normal 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)
|
||||
282
selective-vpn-gui/api/vpn.py
Normal file
282
selective-vpn-gui/api/vpn.py
Normal 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(),
|
||||
)
|
||||
Reference in New Issue
Block a user