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(),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
10
selective-vpn-gui/controllers/__init__.py
Normal file
10
selective-vpn-gui/controllers/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .views import *
|
||||
from .core_controller import ControllerCoreMixin
|
||||
from .status_controller import StatusControllerMixin
|
||||
from .vpn_controller import VpnControllerMixin
|
||||
from .routes_controller import RoutesControllerMixin
|
||||
from .traffic_controller import TrafficControllerMixin
|
||||
from .transport_controller import TransportControllerMixin
|
||||
from .dns_controller import DNSControllerMixin
|
||||
from .domains_controller import DomainsControllerMixin
|
||||
from .trace_controller import TraceControllerMixin
|
||||
157
selective-vpn-gui/controllers/core_controller.py
Normal file
157
selective-vpn-gui/controllers/core_controller.py
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from api_client import CmdResult, Event, LoginState, VpnStatus
|
||||
|
||||
# Вырезаем спам автопроверки из логов (CLI любит писать "Next check in ...").
|
||||
_NEXT_CHECK_RE = re.compile(
|
||||
r"(?:\b\d+s\.)?\s*Next check in\s+\d+s\.?,?", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
class ControllerCoreMixin:
|
||||
# -------- logging --------
|
||||
|
||||
def log_gui(self, msg: str) -> None:
|
||||
self.client.trace_append("gui", msg)
|
||||
|
||||
def log_smartdns(self, msg: str) -> None:
|
||||
self.client.trace_append("smartdns", msg)
|
||||
|
||||
# -------- events stream --------
|
||||
|
||||
def iter_events(self, since: int = 0, stop=None):
|
||||
return self.client.events_stream(since=since, stop=stop)
|
||||
|
||||
def classify_event(self, ev: Event) -> List[str]:
|
||||
"""Return list of areas to refresh for given event kind."""
|
||||
k = (ev.kind or "").strip().lower()
|
||||
if not k:
|
||||
return []
|
||||
if k in ("status_changed", "status_error"):
|
||||
return ["status", "routes", "vpn"]
|
||||
if k in ("login_state_changed", "login_state_error"):
|
||||
return ["login", "vpn"]
|
||||
if k == "autoloop_status_changed":
|
||||
return ["vpn"]
|
||||
if k == "vpn_locations_changed":
|
||||
return ["vpn"]
|
||||
if k == "unit_state_changed":
|
||||
return ["status", "vpn", "routes", "dns"]
|
||||
if k in ("trace_changed", "trace_append"):
|
||||
return ["trace"]
|
||||
if k == "routes_nft_progress":
|
||||
# Перерисовать блок "routes" (кнопки + прогресс).
|
||||
return ["routes"]
|
||||
if k == "traffic_mode_changed":
|
||||
return ["routes", "status"]
|
||||
if k == "traffic_profiles_changed":
|
||||
# Used by Traffic mode dialog (Apps/runtime) for persistent app profiles.
|
||||
return ["routes"]
|
||||
if k in (
|
||||
"transport_client_state_changed",
|
||||
"transport_client_health_changed",
|
||||
"transport_client_provisioned",
|
||||
"transport_policy_validated",
|
||||
"transport_policy_applied",
|
||||
"transport_conflict_detected",
|
||||
):
|
||||
return ["transport", "status"]
|
||||
if k == "egress_identity_changed":
|
||||
return ["vpn", "transport"]
|
||||
return []
|
||||
|
||||
# -------- helpers --------
|
||||
|
||||
def _is_logged_in_state(self, st: LoginState) -> bool:
|
||||
# Backend "state" может быть любым, делаем устойчивую проверку.
|
||||
s = (st.state or "").strip().lower()
|
||||
if st.email:
|
||||
return True
|
||||
if s in ("ok", "logged", "logged_in", "success", "authorized", "ready"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _level_to_color(self, level: str) -> str:
|
||||
lv = (level or "").strip().lower()
|
||||
if lv in ("green", "ok", "true", "success"):
|
||||
return "green"
|
||||
if lv in ("red", "error", "false", "failed"):
|
||||
return "red"
|
||||
return "orange"
|
||||
|
||||
def _format_policy_route(
|
||||
self,
|
||||
policy_ok: Optional[bool],
|
||||
route_ok: Optional[bool],
|
||||
) -> str:
|
||||
if policy_ok is None and route_ok is None:
|
||||
return "unknown (not checked)"
|
||||
val = policy_ok if policy_ok is not None else route_ok
|
||||
if val is True:
|
||||
return "OK (default route present in VPN table)"
|
||||
return "MISSING default route in VPN table"
|
||||
|
||||
def _resolve_routes_unit(self, iface: str) -> str:
|
||||
forced = (self.routes_unit or "").strip()
|
||||
if forced:
|
||||
return forced
|
||||
ifc = (iface or "").strip()
|
||||
if ifc and ifc != "-":
|
||||
return f"selective-vpn2@{ifc}.service"
|
||||
return ""
|
||||
|
||||
# -------- formatting helpers --------
|
||||
|
||||
def _pretty_cmd(self, res: CmdResult) -> str:
|
||||
lines: List[str] = []
|
||||
lines.append("OK" if res.ok else "ERROR")
|
||||
if res.message:
|
||||
lines.append(res.message.strip())
|
||||
if res.exit_code is not None:
|
||||
lines.append(f"exit_code: {res.exit_code}")
|
||||
if res.stdout.strip():
|
||||
lines.append("")
|
||||
lines.append("stdout:")
|
||||
lines.append(res.stdout.rstrip())
|
||||
if res.stderr.strip() and res.stderr.strip() != res.stdout.strip():
|
||||
lines.append("")
|
||||
lines.append("stderr:")
|
||||
lines.append(res.stderr.rstrip())
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
def _pretty_cmd_then_status(self, res: CmdResult, st: VpnStatus) -> str:
|
||||
return (
|
||||
self._pretty_cmd(res).rstrip()
|
||||
+ "\n\n"
|
||||
+ self._pretty_vpn_status(st).rstrip()
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
def _clean_login_lines(self, lines: Iterable[str]) -> List[str]:
|
||||
out: List[str] = []
|
||||
for raw in lines or []:
|
||||
if raw is None:
|
||||
continue
|
||||
|
||||
s = str(raw).replace("\r", "\n")
|
||||
for part in s.splitlines():
|
||||
t = part.strip()
|
||||
if not t:
|
||||
continue
|
||||
|
||||
# Вырезаем спам "Next check in ...".
|
||||
t2 = _NEXT_CHECK_RE.sub("", t).strip()
|
||||
if not t2:
|
||||
continue
|
||||
|
||||
# На всякий повторно.
|
||||
t2 = _NEXT_CHECK_RE.sub("", t2).strip()
|
||||
if not t2:
|
||||
continue
|
||||
|
||||
out.append(t2)
|
||||
return out
|
||||
72
selective-vpn-gui/controllers/dns_controller.py
Normal file
72
selective-vpn-gui/controllers/dns_controller.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, cast
|
||||
|
||||
from api_client import (
|
||||
DNSBenchmarkResponse,
|
||||
DNSBenchmarkUpstream,
|
||||
DNSStatus,
|
||||
DNSUpstreamPoolState,
|
||||
DnsUpstreams,
|
||||
SmartdnsRuntimeState,
|
||||
)
|
||||
|
||||
from .views import ActionView, ServiceAction
|
||||
|
||||
|
||||
class DNSControllerMixin:
|
||||
def dns_upstreams_view(self) -> DnsUpstreams:
|
||||
return self.client.dns_upstreams_get()
|
||||
|
||||
def dns_upstreams_save(self, cfg: DnsUpstreams) -> None:
|
||||
self.client.dns_upstreams_set(cfg)
|
||||
|
||||
def dns_upstream_pool_view(self) -> DNSUpstreamPoolState:
|
||||
return self.client.dns_upstream_pool_get()
|
||||
|
||||
def dns_upstream_pool_save(self, items: List[DNSBenchmarkUpstream]) -> DNSUpstreamPoolState:
|
||||
return self.client.dns_upstream_pool_set(items)
|
||||
|
||||
def dns_benchmark(
|
||||
self,
|
||||
upstreams: List[DNSBenchmarkUpstream],
|
||||
domains: List[str],
|
||||
timeout_ms: int = 1800,
|
||||
attempts: int = 1,
|
||||
concurrency: int = 6,
|
||||
profile: str = "load",
|
||||
) -> DNSBenchmarkResponse:
|
||||
return self.client.dns_benchmark(
|
||||
upstreams=upstreams,
|
||||
domains=domains,
|
||||
timeout_ms=timeout_ms,
|
||||
attempts=attempts,
|
||||
concurrency=concurrency,
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
def dns_status_view(self) -> DNSStatus:
|
||||
return self.client.dns_status_get()
|
||||
|
||||
def dns_mode_set(self, via: bool, smartdns_addr: str) -> DNSStatus:
|
||||
return self.client.dns_mode_set(via, smartdns_addr)
|
||||
|
||||
def smartdns_service_action(self, action: str) -> DNSStatus:
|
||||
act = action.strip().lower()
|
||||
if act not in ("start", "stop", "restart"):
|
||||
raise ValueError(f"Invalid SmartDNS action: {action}")
|
||||
return self.client.dns_smartdns_service_set(cast(ServiceAction, act))
|
||||
|
||||
def smartdns_prewarm(self, limit: int = 0, aggressive_subs: bool = False) -> ActionView:
|
||||
res = self.client.smartdns_prewarm(limit=limit, aggressive_subs=aggressive_subs)
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def smartdns_runtime_view(self) -> SmartdnsRuntimeState:
|
||||
return self.client.smartdns_runtime_get()
|
||||
|
||||
def smartdns_runtime_set(self, enabled: bool, restart: bool = True) -> SmartdnsRuntimeState:
|
||||
return self.client.smartdns_runtime_set(enabled=enabled, restart=restart)
|
||||
|
||||
# -------- Domains --------
|
||||
|
||||
77
selective-vpn-gui/controllers/domains_controller.py
Normal file
77
selective-vpn-gui/controllers/domains_controller.py
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, cast
|
||||
|
||||
from api_client import DomainsFile, DomainsTable
|
||||
|
||||
|
||||
class DomainsControllerMixin:
|
||||
def domains_table_view(self) -> DomainsTable:
|
||||
return self.client.domains_table()
|
||||
|
||||
def domains_file_load(self, name: str) -> DomainsFile:
|
||||
nm = name.strip().lower()
|
||||
if nm not in (
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
):
|
||||
raise ValueError(f"Invalid domains file name: {name}")
|
||||
return self.client.domains_file_get(
|
||||
cast(
|
||||
Literal[
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
],
|
||||
nm,
|
||||
)
|
||||
)
|
||||
|
||||
def domains_file_save(self, name: str, content: str) -> None:
|
||||
nm = name.strip().lower()
|
||||
if nm not in (
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
):
|
||||
raise ValueError(f"Invalid domains file name: {name}")
|
||||
self.client.domains_file_set(
|
||||
cast(
|
||||
Literal[
|
||||
"bases",
|
||||
"meta",
|
||||
"subs",
|
||||
"static",
|
||||
"smartdns",
|
||||
"last-ips-map",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
],
|
||||
nm,
|
||||
),
|
||||
content,
|
||||
)
|
||||
|
||||
# -------- Trace --------
|
||||
|
||||
161
selective-vpn-gui/controllers/routes_controller.py
Normal file
161
selective-vpn-gui/controllers/routes_controller.py
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import cast
|
||||
|
||||
from api_client import CmdResult, Event
|
||||
|
||||
from .views import ActionView, RoutesNftProgressView, RoutesResolveSummaryView, ServiceAction
|
||||
|
||||
|
||||
class RoutesControllerMixin:
|
||||
def routes_service_action(self, action: str) -> ActionView:
|
||||
act = action.strip().lower()
|
||||
if act not in ("start", "stop", "restart"):
|
||||
raise ValueError(f"Invalid routes action: {action}")
|
||||
res = self.client.routes_service(cast(ServiceAction, act))
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def routes_clear(self) -> ActionView:
|
||||
res = self.client.routes_clear()
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def routes_cache_restore(self) -> ActionView:
|
||||
res = self.client.routes_cache_restore()
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def routes_precheck_debug(self, run_now: bool = True) -> ActionView:
|
||||
res = self.client.routes_precheck_debug(run_now=run_now)
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def routes_fix_policy_route(self) -> ActionView:
|
||||
res = self.client.routes_fix_policy_route()
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def routes_timer_enabled(self) -> bool:
|
||||
st = self.client.routes_timer_get()
|
||||
return bool(st.enabled)
|
||||
|
||||
def routes_timer_set(self, enabled: bool) -> ActionView:
|
||||
res = self.client.routes_timer_set(bool(enabled))
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def routes_resolve_summary_view(self) -> RoutesResolveSummaryView:
|
||||
dump = self.client.trace_get("full")
|
||||
lines = list(getattr(dump, "lines", []) or [])
|
||||
line = ""
|
||||
for raw in reversed(lines):
|
||||
s = str(raw or "")
|
||||
if "resolve summary:" in s:
|
||||
line = s
|
||||
break
|
||||
if not line:
|
||||
return RoutesResolveSummaryView(
|
||||
available=False,
|
||||
text="Resolve summary: no data yet",
|
||||
recheck_text="Timeout recheck: —",
|
||||
color="gray",
|
||||
recheck_color="gray",
|
||||
)
|
||||
|
||||
tail = line.split("resolve summary:", 1)[1]
|
||||
pairs: dict[str, int] = {}
|
||||
for m in re.finditer(r"([a-zA-Z0-9_]+)=(-?\d+)", tail):
|
||||
k = str(m.group(1) or "").strip().lower()
|
||||
try:
|
||||
pairs[k] = int(m.group(2))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
unique_ips = int(pairs.get("unique_ips", 0))
|
||||
direct_ips = int(pairs.get("direct_ips", 0))
|
||||
wildcard_ips = int(pairs.get("wildcard_ips", 0))
|
||||
unresolved = int(pairs.get("unresolved", 0))
|
||||
unresolved_live = int(pairs.get("unresolved_live", 0))
|
||||
unresolved_suppressed = int(pairs.get("unresolved_suppressed", 0))
|
||||
q_hits = int(pairs.get("quarantine_hits", 0))
|
||||
dns_attempts = int(pairs.get("dns_attempts", 0))
|
||||
dns_timeout = int(pairs.get("dns_timeout", 0))
|
||||
dns_cooldown_skips = int(pairs.get("dns_cooldown_skips", 0))
|
||||
live_batch_target = int(pairs.get("live_batch_target", 0))
|
||||
live_batch_deferred = int(pairs.get("live_batch_deferred", 0))
|
||||
live_batch_p1 = int(pairs.get("live_batch_p1", 0))
|
||||
live_batch_p2 = int(pairs.get("live_batch_p2", 0))
|
||||
live_batch_p3 = int(pairs.get("live_batch_p3", 0))
|
||||
live_batch_nxheavy_pct = int(pairs.get("live_batch_nxheavy_pct", 0))
|
||||
live_batch_nxheavy_skip = int(pairs.get("live_batch_nxheavy_skip", 0))
|
||||
|
||||
r_checked = int(pairs.get("timeout_recheck_checked", 0))
|
||||
r_recovered = int(pairs.get("timeout_recheck_recovered", 0))
|
||||
r_recovered_ips = int(pairs.get("timeout_recheck_recovered_ips", 0))
|
||||
r_still_timeout = int(pairs.get("timeout_recheck_still_timeout", 0))
|
||||
r_now_nx = int(pairs.get("timeout_recheck_now_nxdomain", 0))
|
||||
r_now_tmp = int(pairs.get("timeout_recheck_now_temporary", 0))
|
||||
|
||||
text = (
|
||||
f"Resolve: ips={unique_ips} (direct={direct_ips}, wildcard={wildcard_ips}, "
|
||||
f"+recheck_ips={r_recovered_ips}) | unresolved={unresolved} "
|
||||
f"(live={unresolved_live}, suppressed={unresolved_suppressed}) | "
|
||||
f"quarantine_hits={q_hits} | dns_timeout={dns_timeout} "
|
||||
f"| cooldown_skips={dns_cooldown_skips} | attempts={dns_attempts} "
|
||||
f"| live_batch={live_batch_target} deferred={live_batch_deferred} "
|
||||
f"(p1={live_batch_p1}, p2={live_batch_p2}, p3={live_batch_p3}, nx_pct={live_batch_nxheavy_pct}, nx_skip={live_batch_nxheavy_skip})"
|
||||
)
|
||||
recheck_text = (
|
||||
f"Timeout recheck: checked={r_checked} recovered={r_recovered} "
|
||||
f"still_timeout={r_still_timeout} now_nxdomain={r_now_nx} now_temporary={r_now_tmp}"
|
||||
)
|
||||
|
||||
color = "green" if unresolved < 4000 else ("#b58900" if unresolved < 10000 else "red")
|
||||
if dns_timeout > 500 and color == "green":
|
||||
color = "#b58900"
|
||||
if live_batch_p3 > 0 and (live_batch_p1+live_batch_p2) > 0:
|
||||
ratio = float(live_batch_p3) / float(live_batch_p1 + live_batch_p2 + live_batch_p3)
|
||||
if ratio > 0.8:
|
||||
color = "#b58900" if color == "green" else color
|
||||
if ratio > 0.95:
|
||||
color = "red"
|
||||
recheck_color = "green" if r_still_timeout <= 20 else ("#b58900" if r_still_timeout <= 100 else "red")
|
||||
return RoutesResolveSummaryView(
|
||||
available=True,
|
||||
text=text,
|
||||
recheck_text=recheck_text,
|
||||
color=color,
|
||||
recheck_color=recheck_color,
|
||||
)
|
||||
|
||||
|
||||
def routes_nft_progress_from_event(self, ev: Event) -> RoutesNftProgressView:
|
||||
"""
|
||||
Превращает Event(kind='routes_nft_progress') в удобную модель
|
||||
для прогресс-бара/лейбла.
|
||||
"""
|
||||
payload = (
|
||||
getattr(ev, "data", None)
|
||||
or getattr(ev, "payload", None)
|
||||
or getattr(ev, "extra", None)
|
||||
or {}
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
try:
|
||||
percent = int(payload.get("percent", 0))
|
||||
except Exception:
|
||||
percent = 0
|
||||
|
||||
msg = str(payload.get("message", "")) if payload is not None else ""
|
||||
if not msg:
|
||||
msg = "Updating nft set…"
|
||||
|
||||
active = 0 <= percent < 100
|
||||
|
||||
return RoutesNftProgressView(
|
||||
percent=percent,
|
||||
message=msg,
|
||||
active=active,
|
||||
)
|
||||
|
||||
# -------- DNS / SmartDNS --------
|
||||
74
selective-vpn-gui/controllers/status_controller.py
Normal file
74
selective-vpn-gui/controllers/status_controller.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from api_client import LoginState, Status, UnitState, VpnStatus
|
||||
|
||||
from .views import LoginView, StatusOverviewView
|
||||
|
||||
|
||||
class StatusControllerMixin:
|
||||
def get_login_view(self) -> LoginView:
|
||||
st: LoginState = self.client.get_login_state()
|
||||
|
||||
# Prefer backend UI-ready "text" if provided, else build it.
|
||||
if st.text:
|
||||
txt = st.text
|
||||
else:
|
||||
if st.email:
|
||||
txt = f"AdGuard VPN: logged in as {st.email}"
|
||||
else:
|
||||
txt = "AdGuard VPN: (no login data)"
|
||||
|
||||
logged_in = self._is_logged_in_state(st)
|
||||
|
||||
# Цвет: либо из backend, либо простой нормализованный вариант
|
||||
if st.color:
|
||||
color = st.color
|
||||
else:
|
||||
if logged_in:
|
||||
color = "green"
|
||||
else:
|
||||
s = (st.state or "").strip().lower()
|
||||
color = "orange" if s in ("unknown", "checking") else "red"
|
||||
|
||||
return LoginView(
|
||||
text=txt,
|
||||
color=color,
|
||||
logged_in=logged_in,
|
||||
email=st.email or "",
|
||||
)
|
||||
|
||||
def get_status_overview(self) -> StatusOverviewView:
|
||||
st: Status = self.client.get_status()
|
||||
|
||||
routes_unit = self._resolve_routes_unit(st.iface)
|
||||
routes_s: UnitState = (
|
||||
self.client.systemd_state(routes_unit)
|
||||
if routes_unit
|
||||
else UnitState(state="unknown")
|
||||
)
|
||||
smartdns_s: UnitState = self.client.systemd_state(self.smartdns_unit)
|
||||
vpn_st: VpnStatus = self.client.vpn_status()
|
||||
|
||||
counts = f"domains={st.domain_count}, ips={st.ip_count}"
|
||||
iface = f"iface={st.iface} table={st.table} mark={st.mark}"
|
||||
|
||||
policy_route = self._format_policy_route(st.policy_route_ok, st.route_ok)
|
||||
|
||||
# SmartDNS: если state пустой/unknown — считаем это ошибкой
|
||||
smart_state = smartdns_s.state or "unknown"
|
||||
if smart_state.lower() in ("", "unknown", "failed"):
|
||||
smart_state = "ERROR (unknown state)"
|
||||
|
||||
return StatusOverviewView(
|
||||
timestamp=st.timestamp or "—",
|
||||
counts=counts,
|
||||
iface_table_mark=iface,
|
||||
policy_route=policy_route,
|
||||
routes_service=f"{routes_unit or 'selective-vpn2@<auto>.service'}: {routes_s.state}",
|
||||
smartdns_service=f"{self.smartdns_unit}: {smart_state}",
|
||||
# это состояние самого VPN-юнита, НЕ autoloop:
|
||||
# т.е. работает ли AdGuardVPN-daemon / туннель
|
||||
vpn_service=f"VPN: {vpn_st.unit_state}",
|
||||
)
|
||||
|
||||
12
selective-vpn-gui/controllers/trace_controller.py
Normal file
12
selective-vpn-gui/controllers/trace_controller.py
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from .views import TraceMode
|
||||
from api_client import TraceDump
|
||||
|
||||
|
||||
class TraceControllerMixin:
|
||||
# -------- Trace --------
|
||||
|
||||
def trace_view(self, mode: TraceMode = "full") -> TraceDump:
|
||||
return self.client.trace_get(mode)
|
||||
238
selective-vpn-gui/controllers/traffic_controller.py
Normal file
238
selective-vpn-gui/controllers/traffic_controller.py
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from api_client import (
|
||||
CmdResult,
|
||||
TrafficAppMarkItem,
|
||||
TrafficAppMarksResult,
|
||||
TrafficAppMarksStatus,
|
||||
TrafficAppProfile,
|
||||
TrafficAppProfileSaveResult,
|
||||
TrafficAudit,
|
||||
TrafficCandidates,
|
||||
TrafficInterfaces,
|
||||
TrafficModeStatus,
|
||||
)
|
||||
|
||||
from .views import TrafficModeView
|
||||
|
||||
|
||||
class TrafficControllerMixin:
|
||||
def traffic_mode_view(self) -> TrafficModeView:
|
||||
st: TrafficModeStatus = self.client.traffic_mode_get()
|
||||
return TrafficModeView(
|
||||
desired_mode=(st.desired_mode or st.mode or "selective"),
|
||||
applied_mode=(st.applied_mode or "direct"),
|
||||
preferred_iface=st.preferred_iface or "",
|
||||
advanced_active=bool(st.advanced_active),
|
||||
auto_local_bypass=bool(st.auto_local_bypass),
|
||||
auto_local_active=bool(st.auto_local_active),
|
||||
ingress_reply_bypass=bool(st.ingress_reply_bypass),
|
||||
ingress_reply_active=bool(st.ingress_reply_active),
|
||||
bypass_candidates=int(st.bypass_candidates),
|
||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
|
||||
force_direct_subnets=list(st.force_direct_subnets or []),
|
||||
force_direct_uids=list(st.force_direct_uids or []),
|
||||
force_direct_cgroups=list(st.force_direct_cgroups or []),
|
||||
overrides_applied=int(st.overrides_applied),
|
||||
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
|
||||
cgroup_warning=st.cgroup_warning or "",
|
||||
active_iface=st.active_iface or "",
|
||||
iface_reason=st.iface_reason or "",
|
||||
ingress_rule_present=bool(st.ingress_rule_present),
|
||||
ingress_nft_active=bool(st.ingress_nft_active),
|
||||
probe_ok=bool(st.probe_ok),
|
||||
probe_message=st.probe_message or "",
|
||||
healthy=bool(st.healthy),
|
||||
message=st.message or "",
|
||||
)
|
||||
|
||||
def traffic_mode_set(
|
||||
self,
|
||||
mode: str,
|
||||
preferred_iface: Optional[str] = None,
|
||||
auto_local_bypass: Optional[bool] = None,
|
||||
ingress_reply_bypass: Optional[bool] = None,
|
||||
force_vpn_subnets: Optional[List[str]] = None,
|
||||
force_vpn_uids: Optional[List[str]] = None,
|
||||
force_vpn_cgroups: Optional[List[str]] = None,
|
||||
force_direct_subnets: Optional[List[str]] = None,
|
||||
force_direct_uids: Optional[List[str]] = None,
|
||||
force_direct_cgroups: Optional[List[str]] = None,
|
||||
) -> TrafficModeView:
|
||||
st: TrafficModeStatus = self.client.traffic_mode_set(
|
||||
mode,
|
||||
preferred_iface,
|
||||
auto_local_bypass,
|
||||
ingress_reply_bypass,
|
||||
force_vpn_subnets,
|
||||
force_vpn_uids,
|
||||
force_vpn_cgroups,
|
||||
force_direct_subnets,
|
||||
force_direct_uids,
|
||||
force_direct_cgroups,
|
||||
)
|
||||
return TrafficModeView(
|
||||
desired_mode=(st.desired_mode or st.mode or mode),
|
||||
applied_mode=(st.applied_mode or "direct"),
|
||||
preferred_iface=st.preferred_iface or "",
|
||||
advanced_active=bool(st.advanced_active),
|
||||
auto_local_bypass=bool(st.auto_local_bypass),
|
||||
auto_local_active=bool(st.auto_local_active),
|
||||
ingress_reply_bypass=bool(st.ingress_reply_bypass),
|
||||
ingress_reply_active=bool(st.ingress_reply_active),
|
||||
bypass_candidates=int(st.bypass_candidates),
|
||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
|
||||
force_direct_subnets=list(st.force_direct_subnets or []),
|
||||
force_direct_uids=list(st.force_direct_uids or []),
|
||||
force_direct_cgroups=list(st.force_direct_cgroups or []),
|
||||
overrides_applied=int(st.overrides_applied),
|
||||
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
|
||||
cgroup_warning=st.cgroup_warning or "",
|
||||
active_iface=st.active_iface or "",
|
||||
iface_reason=st.iface_reason or "",
|
||||
ingress_rule_present=bool(st.ingress_rule_present),
|
||||
ingress_nft_active=bool(st.ingress_nft_active),
|
||||
probe_ok=bool(st.probe_ok),
|
||||
probe_message=st.probe_message or "",
|
||||
healthy=bool(st.healthy),
|
||||
message=st.message or "",
|
||||
)
|
||||
|
||||
def traffic_mode_test(self) -> TrafficModeView:
|
||||
st: TrafficModeStatus = self.client.traffic_mode_test()
|
||||
return TrafficModeView(
|
||||
desired_mode=(st.desired_mode or st.mode or "selective"),
|
||||
applied_mode=(st.applied_mode or "direct"),
|
||||
preferred_iface=st.preferred_iface or "",
|
||||
advanced_active=bool(st.advanced_active),
|
||||
auto_local_bypass=bool(st.auto_local_bypass),
|
||||
auto_local_active=bool(st.auto_local_active),
|
||||
ingress_reply_bypass=bool(st.ingress_reply_bypass),
|
||||
ingress_reply_active=bool(st.ingress_reply_active),
|
||||
bypass_candidates=int(st.bypass_candidates),
|
||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
|
||||
force_direct_subnets=list(st.force_direct_subnets or []),
|
||||
force_direct_uids=list(st.force_direct_uids or []),
|
||||
force_direct_cgroups=list(st.force_direct_cgroups or []),
|
||||
overrides_applied=int(st.overrides_applied),
|
||||
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
|
||||
cgroup_warning=st.cgroup_warning or "",
|
||||
active_iface=st.active_iface or "",
|
||||
iface_reason=st.iface_reason or "",
|
||||
ingress_rule_present=bool(st.ingress_rule_present),
|
||||
ingress_nft_active=bool(st.ingress_nft_active),
|
||||
probe_ok=bool(st.probe_ok),
|
||||
probe_message=st.probe_message or "",
|
||||
healthy=bool(st.healthy),
|
||||
message=st.message or "",
|
||||
)
|
||||
|
||||
def traffic_advanced_reset(self) -> TrafficModeView:
|
||||
st: TrafficModeStatus = self.client.traffic_advanced_reset()
|
||||
return TrafficModeView(
|
||||
desired_mode=(st.desired_mode or st.mode or "selective"),
|
||||
applied_mode=(st.applied_mode or "direct"),
|
||||
preferred_iface=st.preferred_iface or "",
|
||||
advanced_active=bool(st.advanced_active),
|
||||
auto_local_bypass=bool(st.auto_local_bypass),
|
||||
auto_local_active=bool(st.auto_local_active),
|
||||
ingress_reply_bypass=bool(st.ingress_reply_bypass),
|
||||
ingress_reply_active=bool(st.ingress_reply_active),
|
||||
bypass_candidates=int(st.bypass_candidates),
|
||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
|
||||
force_direct_subnets=list(st.force_direct_subnets or []),
|
||||
force_direct_uids=list(st.force_direct_uids or []),
|
||||
force_direct_cgroups=list(st.force_direct_cgroups or []),
|
||||
overrides_applied=int(st.overrides_applied),
|
||||
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
|
||||
cgroup_warning=st.cgroup_warning or "",
|
||||
active_iface=st.active_iface or "",
|
||||
iface_reason=st.iface_reason or "",
|
||||
ingress_rule_present=bool(st.ingress_rule_present),
|
||||
ingress_nft_active=bool(st.ingress_nft_active),
|
||||
probe_ok=bool(st.probe_ok),
|
||||
probe_message=st.probe_message or "",
|
||||
healthy=bool(st.healthy),
|
||||
message=st.message or "",
|
||||
)
|
||||
|
||||
def traffic_interfaces(self) -> List[str]:
|
||||
st: TrafficInterfaces = self.client.traffic_interfaces_get()
|
||||
vals = [x for x in st.interfaces if x]
|
||||
if st.preferred_iface and st.preferred_iface not in vals:
|
||||
vals.insert(0, st.preferred_iface)
|
||||
return vals
|
||||
|
||||
def traffic_candidates(self) -> TrafficCandidates:
|
||||
return self.client.traffic_candidates_get()
|
||||
|
||||
def traffic_appmarks_status(self) -> TrafficAppMarksStatus:
|
||||
return self.client.traffic_appmarks_status()
|
||||
|
||||
def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]:
|
||||
return self.client.traffic_appmarks_items()
|
||||
|
||||
def traffic_appmarks_apply(
|
||||
self,
|
||||
*,
|
||||
op: str,
|
||||
target: str,
|
||||
cgroup: str = "",
|
||||
unit: str = "",
|
||||
command: str = "",
|
||||
app_key: str = "",
|
||||
timeout_sec: int = 0,
|
||||
) -> TrafficAppMarksResult:
|
||||
return self.client.traffic_appmarks_apply(
|
||||
op=op,
|
||||
target=target,
|
||||
cgroup=cgroup,
|
||||
unit=unit,
|
||||
command=command,
|
||||
app_key=app_key,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
|
||||
def traffic_app_profiles_list(self) -> List[TrafficAppProfile]:
|
||||
return self.client.traffic_app_profiles_list()
|
||||
|
||||
def traffic_app_profile_upsert(
|
||||
self,
|
||||
*,
|
||||
id: str = "",
|
||||
name: str = "",
|
||||
app_key: str = "",
|
||||
command: str,
|
||||
target: str,
|
||||
ttl_sec: int = 0,
|
||||
vpn_profile: str = "",
|
||||
) -> TrafficAppProfileSaveResult:
|
||||
return self.client.traffic_app_profile_upsert(
|
||||
id=id,
|
||||
name=name,
|
||||
app_key=app_key,
|
||||
command=command,
|
||||
target=target,
|
||||
ttl_sec=ttl_sec,
|
||||
vpn_profile=vpn_profile,
|
||||
)
|
||||
|
||||
def traffic_app_profile_delete(self, id: str) -> CmdResult:
|
||||
return self.client.traffic_app_profile_delete(id)
|
||||
|
||||
def traffic_audit(self) -> TrafficAudit:
|
||||
return self.client.traffic_audit_get()
|
||||
|
||||
# -------- Transport flow (E4.2 foundation) --------
|
||||
|
||||
807
selective-vpn-gui/controllers/transport_controller.py
Normal file
807
selective-vpn-gui/controllers/transport_controller.py
Normal file
@@ -0,0 +1,807 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
from api_client import (
|
||||
ApiError,
|
||||
CmdResult,
|
||||
SingBoxProfile,
|
||||
SingBoxProfileApplyResult,
|
||||
SingBoxProfileHistoryResult,
|
||||
SingBoxProfileIssue,
|
||||
SingBoxProfileRenderResult,
|
||||
SingBoxProfileRollbackResult,
|
||||
SingBoxProfilesState,
|
||||
SingBoxProfileValidateResult,
|
||||
TransportCapabilities,
|
||||
TransportClient,
|
||||
TransportClientActionResult,
|
||||
TransportClientHealthSnapshot,
|
||||
TransportInterfacesSnapshot,
|
||||
TransportConflict,
|
||||
TransportConflicts,
|
||||
TransportHealthRefreshResult,
|
||||
TransportNetnsToggleResult,
|
||||
TransportOwnerLocksClearResult,
|
||||
TransportOwnerLocksSnapshot,
|
||||
TransportOwnershipSnapshot,
|
||||
TransportPolicy,
|
||||
TransportPolicyApplyResult,
|
||||
TransportPolicyIntent,
|
||||
TransportPolicyValidateResult,
|
||||
)
|
||||
|
||||
from .views import ActionView, TransportClientAction, TransportFlowPhase, TransportPolicyFlowView
|
||||
|
||||
|
||||
class TransportControllerMixin:
|
||||
def transport_clients(
|
||||
self,
|
||||
enabled_only: bool = False,
|
||||
kind: str = "",
|
||||
include_virtual: bool = False,
|
||||
) -> List[TransportClient]:
|
||||
return self.client.transport_clients_get(
|
||||
enabled_only=enabled_only,
|
||||
kind=kind,
|
||||
include_virtual=include_virtual,
|
||||
)
|
||||
|
||||
def transport_interfaces(self) -> TransportInterfacesSnapshot:
|
||||
return self.client.transport_interfaces_get()
|
||||
|
||||
def transport_health_refresh(
|
||||
self,
|
||||
*,
|
||||
client_ids: Optional[List[str]] = None,
|
||||
force: bool = False,
|
||||
) -> TransportHealthRefreshResult:
|
||||
return self.client.transport_health_refresh(client_ids=client_ids, force=force)
|
||||
|
||||
def transport_client_health(self, client_id: str) -> TransportClientHealthSnapshot:
|
||||
return self.client.transport_client_health_get(client_id)
|
||||
|
||||
def transport_client_create_action(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
kind: str,
|
||||
name: str = "",
|
||||
enabled: bool = True,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> ActionView:
|
||||
cid = str(client_id or "").strip()
|
||||
if not cid:
|
||||
raise ValueError("missing transport client id")
|
||||
res: CmdResult = self.client.transport_client_create(
|
||||
client_id=cid,
|
||||
kind=str(kind or "").strip().lower(),
|
||||
name=name,
|
||||
enabled=enabled,
|
||||
config=config,
|
||||
)
|
||||
msg = res.message or "client create completed"
|
||||
return ActionView(ok=bool(res.ok), pretty_text=f"create {cid}: {msg}")
|
||||
|
||||
def transport_client_action(self, client_id: str, action: TransportClientAction) -> ActionView:
|
||||
res: TransportClientActionResult = self.client.transport_client_action(client_id, action)
|
||||
status_bits = []
|
||||
before = (res.status_before or "").strip()
|
||||
after = (res.status_after or "").strip()
|
||||
if before or after:
|
||||
status_bits.append(f"status {before or '-'} -> {after or '-'}")
|
||||
if res.code:
|
||||
status_bits.append(f"code={res.code}")
|
||||
if res.last_error:
|
||||
status_bits.append(f"last_error={res.last_error}")
|
||||
extra = f" ({'; '.join(status_bits)})" if status_bits else ""
|
||||
msg = res.message or f"{res.action} completed"
|
||||
return ActionView(ok=bool(res.ok), pretty_text=f"{res.action} {res.client_id}: {msg}{extra}")
|
||||
|
||||
def transport_client_patch_action(
|
||||
self,
|
||||
client_id: str,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> ActionView:
|
||||
cid = str(client_id or "").strip()
|
||||
if not cid:
|
||||
raise ValueError("missing transport client id")
|
||||
res: CmdResult = self.client.transport_client_patch(
|
||||
cid,
|
||||
name=name,
|
||||
enabled=enabled,
|
||||
config=config,
|
||||
)
|
||||
msg = res.message or "client patch completed"
|
||||
return ActionView(ok=bool(res.ok), pretty_text=f"patch {cid}: {msg}")
|
||||
|
||||
def transport_client_delete_action(
|
||||
self,
|
||||
client_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
cleanup: bool = True,
|
||||
) -> ActionView:
|
||||
cid = str(client_id or "").strip()
|
||||
if not cid:
|
||||
raise ValueError("missing transport client id")
|
||||
res: CmdResult = self.client.transport_client_delete(cid, force=force, cleanup=cleanup)
|
||||
msg = res.message or "delete completed"
|
||||
return ActionView(ok=bool(res.ok), pretty_text=f"delete {cid}: {msg}")
|
||||
|
||||
def transport_netns_toggle(
|
||||
self,
|
||||
*,
|
||||
enabled: Optional[bool] = None,
|
||||
client_ids: Optional[List[str]] = None,
|
||||
provision: bool = True,
|
||||
restart_running: bool = True,
|
||||
) -> TransportNetnsToggleResult:
|
||||
ids = [
|
||||
str(x).strip()
|
||||
for x in (client_ids or [])
|
||||
if str(x).strip()
|
||||
] if client_ids is not None else None
|
||||
return self.client.transport_netns_toggle(
|
||||
enabled=enabled,
|
||||
client_ids=ids,
|
||||
provision=provision,
|
||||
restart_running=restart_running,
|
||||
)
|
||||
|
||||
def transport_policy_rollback_action(self, base_revision: int = 0) -> ActionView:
|
||||
base = int(base_revision or 0)
|
||||
if base <= 0:
|
||||
base = int(self.client.transport_policy_get().revision or 0)
|
||||
res: TransportPolicyApplyResult = self.client.transport_policy_rollback(base_revision=base)
|
||||
if res.ok:
|
||||
msg = res.message or "policy rollback applied"
|
||||
bits = [f"revision={int(res.policy_revision or 0)}"]
|
||||
if res.apply_id:
|
||||
bits.append(f"apply_id={res.apply_id}")
|
||||
return ActionView(ok=True, pretty_text=f"{msg} ({', '.join(bits)})")
|
||||
msg = res.message or "policy rollback failed"
|
||||
if res.code:
|
||||
msg = f"{msg} (code={res.code})"
|
||||
return ActionView(ok=False, pretty_text=msg)
|
||||
|
||||
def transport_policy(self) -> TransportPolicy:
|
||||
return self.client.transport_policy_get()
|
||||
|
||||
def transport_ownership(self) -> TransportOwnershipSnapshot:
|
||||
return self.client.transport_ownership_get()
|
||||
|
||||
def transport_owner_locks(self) -> TransportOwnerLocksSnapshot:
|
||||
return self.client.transport_owner_locks_get()
|
||||
|
||||
def transport_owner_locks_clear(
|
||||
self,
|
||||
*,
|
||||
base_revision: int = 0,
|
||||
client_id: str = "",
|
||||
destination_ip: str = "",
|
||||
destination_ips: Optional[List[str]] = None,
|
||||
confirm_token: str = "",
|
||||
) -> TransportOwnerLocksClearResult:
|
||||
return self.client.transport_owner_locks_clear(
|
||||
base_revision=int(base_revision or 0),
|
||||
client_id=str(client_id or "").strip(),
|
||||
destination_ip=str(destination_ip or "").strip(),
|
||||
destination_ips=[
|
||||
str(x).strip()
|
||||
for x in list(destination_ips or [])
|
||||
if str(x).strip()
|
||||
],
|
||||
confirm_token=str(confirm_token or "").strip(),
|
||||
)
|
||||
|
||||
def transport_owner_locks_clear_action(
|
||||
self,
|
||||
*,
|
||||
base_revision: int = 0,
|
||||
client_id: str = "",
|
||||
destination_ip: str = "",
|
||||
destination_ips: Optional[List[str]] = None,
|
||||
confirm_token: str = "",
|
||||
) -> ActionView:
|
||||
res = self.transport_owner_locks_clear(
|
||||
base_revision=base_revision,
|
||||
client_id=client_id,
|
||||
destination_ip=destination_ip,
|
||||
destination_ips=destination_ips,
|
||||
confirm_token=confirm_token,
|
||||
)
|
||||
bits: List[str] = []
|
||||
if res.code:
|
||||
bits.append(f"code={res.code}")
|
||||
bits.append(f"match={int(res.match_count)}")
|
||||
bits.append(f"cleared={int(res.cleared_count)}")
|
||||
bits.append(f"remaining={int(res.remaining_count)}")
|
||||
msg = (res.message or "owner-lock clear").strip()
|
||||
return ActionView(ok=bool(res.ok), pretty_text=f"{msg} ({', '.join(bits)})")
|
||||
|
||||
def transport_conflicts(self) -> TransportConflicts:
|
||||
return self.client.transport_conflicts_get()
|
||||
|
||||
def transport_capabilities(self) -> TransportCapabilities:
|
||||
return self.client.transport_capabilities_get()
|
||||
|
||||
def transport_flow_draft(
|
||||
self,
|
||||
intents: Optional[List[TransportPolicyIntent]] = None,
|
||||
*,
|
||||
base_revision: int = 0,
|
||||
) -> TransportPolicyFlowView:
|
||||
pol = self.client.transport_policy_get()
|
||||
rev = int(base_revision) if int(base_revision or 0) > 0 else int(pol.revision)
|
||||
return TransportPolicyFlowView(
|
||||
phase="draft",
|
||||
intents=list(intents) if intents is not None else list(pol.intents),
|
||||
base_revision=rev,
|
||||
current_revision=int(pol.revision),
|
||||
applied_revision=0,
|
||||
confirm_token="",
|
||||
valid=False,
|
||||
block_count=0,
|
||||
warn_count=0,
|
||||
diff_added=0,
|
||||
diff_changed=0,
|
||||
diff_removed=0,
|
||||
conflicts=[],
|
||||
apply_id="",
|
||||
rollback_available=False,
|
||||
message="draft ready",
|
||||
code="",
|
||||
)
|
||||
|
||||
def transport_flow_update_draft(
|
||||
self,
|
||||
flow: TransportPolicyFlowView,
|
||||
intents: List[TransportPolicyIntent],
|
||||
*,
|
||||
base_revision: int = 0,
|
||||
) -> TransportPolicyFlowView:
|
||||
rev = int(base_revision) if int(base_revision or 0) > 0 else int(flow.current_revision or flow.base_revision)
|
||||
return replace(
|
||||
flow,
|
||||
phase="draft",
|
||||
intents=list(intents),
|
||||
base_revision=rev,
|
||||
applied_revision=0,
|
||||
confirm_token="",
|
||||
valid=False,
|
||||
block_count=0,
|
||||
warn_count=0,
|
||||
diff_added=0,
|
||||
diff_changed=0,
|
||||
diff_removed=0,
|
||||
conflicts=[],
|
||||
apply_id="",
|
||||
rollback_available=False,
|
||||
message="draft updated",
|
||||
code="",
|
||||
)
|
||||
|
||||
def transport_flow_validate(
|
||||
self,
|
||||
flow: TransportPolicyFlowView,
|
||||
*,
|
||||
allow_warnings: bool = True,
|
||||
) -> TransportPolicyFlowView:
|
||||
res: TransportPolicyValidateResult = self.client.transport_policy_validate(
|
||||
base_revision=int(flow.base_revision or 0),
|
||||
intents=list(flow.intents),
|
||||
allow_warnings=allow_warnings,
|
||||
force_override=False,
|
||||
)
|
||||
phase: TransportFlowPhase = "validated"
|
||||
if not res.valid or int(res.summary.block_count) > 0:
|
||||
phase = "risky"
|
||||
return replace(
|
||||
flow,
|
||||
phase=phase,
|
||||
base_revision=int(res.base_revision or flow.base_revision),
|
||||
current_revision=int(res.base_revision or flow.current_revision),
|
||||
confirm_token=res.confirm_token,
|
||||
valid=bool(res.valid),
|
||||
block_count=int(res.summary.block_count),
|
||||
warn_count=int(res.summary.warn_count),
|
||||
diff_added=int(res.diff.added),
|
||||
diff_changed=int(res.diff.changed),
|
||||
diff_removed=int(res.diff.removed),
|
||||
conflicts=list(res.conflicts or []),
|
||||
apply_id="",
|
||||
rollback_available=False,
|
||||
message=res.message or ("validated" if phase == "validated" else "blocking conflicts found"),
|
||||
code=res.code or "",
|
||||
)
|
||||
|
||||
def transport_flow_confirm(self, flow: TransportPolicyFlowView) -> TransportPolicyFlowView:
|
||||
if flow.phase != "risky":
|
||||
raise ValueError("confirm step is allowed only after risky validate")
|
||||
if not flow.confirm_token:
|
||||
raise ValueError("missing confirm token; run validate again")
|
||||
return replace(
|
||||
flow,
|
||||
phase="confirm",
|
||||
message="force apply requires explicit confirmation",
|
||||
code="FORCE_CONFIRM_REQUIRED",
|
||||
)
|
||||
|
||||
def transport_flow_apply(
|
||||
self,
|
||||
flow: TransportPolicyFlowView,
|
||||
*,
|
||||
force_override: bool = False,
|
||||
) -> TransportPolicyFlowView:
|
||||
if flow.phase == "draft":
|
||||
return replace(
|
||||
flow,
|
||||
message="policy must be validated before apply",
|
||||
code="VALIDATE_REQUIRED",
|
||||
)
|
||||
if flow.phase == "risky" and not force_override:
|
||||
return replace(
|
||||
flow,
|
||||
message="policy has blocking conflicts; open confirm step",
|
||||
code="POLICY_CONFLICT_BLOCK",
|
||||
)
|
||||
if force_override and flow.phase != "confirm":
|
||||
return replace(
|
||||
flow,
|
||||
phase="risky",
|
||||
message="force apply requires confirm state",
|
||||
code="FORCE_CONFIRM_REQUIRED",
|
||||
)
|
||||
if force_override and not flow.confirm_token:
|
||||
return replace(
|
||||
flow,
|
||||
phase="risky",
|
||||
message="confirm token is missing or expired; run validate again",
|
||||
code="FORCE_OVERRIDE_CONFIRM_REQUIRED",
|
||||
)
|
||||
|
||||
res: TransportPolicyApplyResult = self.client.transport_policy_apply(
|
||||
base_revision=int(flow.base_revision),
|
||||
intents=list(flow.intents),
|
||||
force_override=bool(force_override),
|
||||
confirm_token=flow.confirm_token if force_override else "",
|
||||
)
|
||||
return self._transport_flow_from_apply_result(flow, res)
|
||||
|
||||
def transport_flow_rollback(self, flow: TransportPolicyFlowView) -> TransportPolicyFlowView:
|
||||
base = int(flow.current_revision or flow.base_revision)
|
||||
res: TransportPolicyApplyResult = self.client.transport_policy_rollback(base_revision=base)
|
||||
return self._transport_flow_from_apply_result(flow, res)
|
||||
|
||||
def _transport_flow_from_apply_result(
|
||||
self,
|
||||
flow: TransportPolicyFlowView,
|
||||
res: TransportPolicyApplyResult,
|
||||
) -> TransportPolicyFlowView:
|
||||
if res.ok:
|
||||
pol = self.client.transport_policy_get()
|
||||
applied_rev = int(res.policy_revision or pol.revision)
|
||||
return TransportPolicyFlowView(
|
||||
phase="applied",
|
||||
intents=list(pol.intents),
|
||||
base_revision=applied_rev,
|
||||
current_revision=applied_rev,
|
||||
applied_revision=applied_rev,
|
||||
confirm_token="",
|
||||
valid=True,
|
||||
block_count=0,
|
||||
warn_count=0,
|
||||
diff_added=0,
|
||||
diff_changed=0,
|
||||
diff_removed=0,
|
||||
conflicts=[],
|
||||
apply_id=res.apply_id or "",
|
||||
rollback_available=bool(res.rollback_available),
|
||||
message=res.message or "policy applied",
|
||||
code=res.code or "",
|
||||
)
|
||||
|
||||
if res.code == "POLICY_REVISION_MISMATCH":
|
||||
current_rev = int(res.current_revision or 0)
|
||||
if current_rev <= 0:
|
||||
current_rev = int(self.client.transport_policy_get().revision)
|
||||
return replace(
|
||||
flow,
|
||||
phase="draft",
|
||||
base_revision=current_rev,
|
||||
current_revision=current_rev,
|
||||
confirm_token="",
|
||||
valid=False,
|
||||
message="policy revision changed; validate again",
|
||||
code=res.code,
|
||||
)
|
||||
|
||||
if res.code in ("POLICY_CONFLICT_BLOCK", "FORCE_OVERRIDE_CONFIRM_REQUIRED"):
|
||||
conflicts = list(res.conflicts or flow.conflicts)
|
||||
block_count = len([x for x in conflicts if (x.severity or "").strip().lower() == "block"])
|
||||
return replace(
|
||||
flow,
|
||||
phase="risky",
|
||||
valid=False,
|
||||
block_count=block_count,
|
||||
conflicts=conflicts,
|
||||
message=res.message or "blocking conflicts",
|
||||
code=res.code,
|
||||
)
|
||||
|
||||
return replace(
|
||||
flow,
|
||||
phase="error",
|
||||
valid=False,
|
||||
message=res.message or "transport apply failed",
|
||||
code=res.code or "TRANSPORT_APPLY_ERROR",
|
||||
)
|
||||
|
||||
def singbox_profile_id_for_client(self, client: Optional[TransportClient]) -> str:
|
||||
if client is None:
|
||||
return ""
|
||||
cfg = getattr(client, "config", {}) or {}
|
||||
if isinstance(cfg, dict):
|
||||
for key in ("profile_id", "singbox_profile_id", "profile"):
|
||||
v = str(cfg.get(key) or "").strip()
|
||||
if v:
|
||||
return v
|
||||
return str(getattr(client, "id", "") or "").strip()
|
||||
|
||||
def singbox_profile_ensure_linked(
|
||||
self,
|
||||
client: TransportClient,
|
||||
*,
|
||||
preferred_profile_id: str = "",
|
||||
) -> ActionView:
|
||||
pid, state = self._ensure_singbox_profile_for_client(
|
||||
client,
|
||||
preferred_profile_id=str(preferred_profile_id or "").strip(),
|
||||
)
|
||||
cid = str(getattr(client, "id", "") or "").strip()
|
||||
if state == "created":
|
||||
return ActionView(ok=True, pretty_text=f"profile {pid} created and linked to {cid}")
|
||||
if state == "linked":
|
||||
return ActionView(ok=True, pretty_text=f"profile {pid} linked to {cid}")
|
||||
return ActionView(ok=True, pretty_text=f"profile {pid} already linked to {cid}")
|
||||
|
||||
def singbox_profile_validate_action(
|
||||
self,
|
||||
profile_id: str,
|
||||
*,
|
||||
check_binary: Optional[bool] = None,
|
||||
client: Optional[TransportClient] = None,
|
||||
) -> ActionView:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
res: SingBoxProfileValidateResult = self.client.transport_singbox_profile_validate(
|
||||
pid,
|
||||
check_binary=check_binary,
|
||||
)
|
||||
ok = bool(res.ok and res.valid)
|
||||
if ok:
|
||||
msg = res.message or "profile is valid"
|
||||
return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("validate", pid, msg, res))
|
||||
msg = res.message or "profile validation failed"
|
||||
return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("validate", pid, msg, res))
|
||||
|
||||
def singbox_profile_render_preview_action(
|
||||
self,
|
||||
profile_id: str,
|
||||
*,
|
||||
check_binary: Optional[bool] = None,
|
||||
persist: bool = False,
|
||||
client: Optional[TransportClient] = None,
|
||||
) -> ActionView:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
res: SingBoxProfileRenderResult = self.client.transport_singbox_profile_render(
|
||||
pid,
|
||||
check_binary=check_binary,
|
||||
persist=bool(persist),
|
||||
)
|
||||
ok = bool(res.ok and res.valid)
|
||||
if ok:
|
||||
msg = res.message or ("rendered" if persist else "render preview ready")
|
||||
return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("render", pid, msg, res))
|
||||
msg = res.message or "render failed"
|
||||
return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("render", pid, msg, res))
|
||||
|
||||
def singbox_profile_apply_action(
|
||||
self,
|
||||
profile_id: str,
|
||||
*,
|
||||
client_id: str = "",
|
||||
restart: Optional[bool] = True,
|
||||
skip_runtime: bool = False,
|
||||
check_binary: Optional[bool] = None,
|
||||
client: Optional[TransportClient] = None,
|
||||
) -> ActionView:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
cid = str(client_id or "").strip()
|
||||
if not cid and client is not None:
|
||||
cid = str(getattr(client, "id", "") or "").strip()
|
||||
res: SingBoxProfileApplyResult = self.client.transport_singbox_profile_apply(
|
||||
pid,
|
||||
client_id=cid,
|
||||
restart=restart,
|
||||
skip_runtime=skip_runtime,
|
||||
check_binary=check_binary,
|
||||
)
|
||||
if res.ok:
|
||||
msg = res.message or "profile applied"
|
||||
return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("apply", pid, msg, res))
|
||||
msg = res.message or "profile apply failed"
|
||||
return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("apply", pid, msg, res))
|
||||
|
||||
def singbox_profile_rollback_action(
|
||||
self,
|
||||
profile_id: str,
|
||||
*,
|
||||
client_id: str = "",
|
||||
restart: Optional[bool] = True,
|
||||
skip_runtime: bool = False,
|
||||
history_id: str = "",
|
||||
client: Optional[TransportClient] = None,
|
||||
) -> ActionView:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
cid = str(client_id or "").strip()
|
||||
if not cid and client is not None:
|
||||
cid = str(getattr(client, "id", "") or "").strip()
|
||||
res: SingBoxProfileRollbackResult = self.client.transport_singbox_profile_rollback(
|
||||
pid,
|
||||
client_id=cid,
|
||||
history_id=history_id,
|
||||
restart=restart,
|
||||
skip_runtime=skip_runtime,
|
||||
)
|
||||
if res.ok:
|
||||
msg = res.message or "profile rollback applied"
|
||||
return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("rollback", pid, msg, res))
|
||||
msg = res.message or "profile rollback failed"
|
||||
return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("rollback", pid, msg, res))
|
||||
|
||||
def singbox_profile_history_lines(
|
||||
self,
|
||||
profile_id: str,
|
||||
*,
|
||||
limit: int = 20,
|
||||
client: Optional[TransportClient] = None,
|
||||
) -> List[str]:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
res: SingBoxProfileHistoryResult = self.client.transport_singbox_profile_history(pid, limit=limit)
|
||||
lines: List[str] = []
|
||||
for it in list(res.items or []):
|
||||
lines.append(self._format_singbox_history_line(it))
|
||||
return lines
|
||||
|
||||
def singbox_profile_get_for_client(
|
||||
self,
|
||||
client: TransportClient,
|
||||
*,
|
||||
profile_id: str = "",
|
||||
) -> SingBoxProfile:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
return self.client.transport_singbox_profile_get(pid)
|
||||
|
||||
def singbox_profile_save_raw_for_client(
|
||||
self,
|
||||
client: TransportClient,
|
||||
*,
|
||||
profile_id: str = "",
|
||||
name: str = "",
|
||||
enabled: bool = True,
|
||||
protocol: str = "vless",
|
||||
raw_config: Optional[Dict[str, Any]] = None,
|
||||
) -> ActionView:
|
||||
pid = self._resolve_singbox_profile_id(profile_id, client)
|
||||
current = self.client.transport_singbox_profile_get(pid)
|
||||
snap = self.client.transport_singbox_profile_patch(
|
||||
pid,
|
||||
base_revision=int(current.profile_revision or 0),
|
||||
name=(str(name or "").strip() or current.name or pid),
|
||||
enabled=bool(enabled),
|
||||
protocol=(str(protocol or "").strip().lower() or "vless"),
|
||||
mode="raw",
|
||||
raw_config=cast(Dict[str, Any], raw_config or {}),
|
||||
)
|
||||
item = snap.item
|
||||
if item is None:
|
||||
return ActionView(ok=False, pretty_text=f"save profile {pid}: backend returned empty item")
|
||||
return ActionView(
|
||||
ok=True,
|
||||
pretty_text=(
|
||||
f"save profile {pid}: revision={int(item.profile_revision or 0)} "
|
||||
f"render_revision={int(item.render_revision or 0)}"
|
||||
),
|
||||
)
|
||||
|
||||
def _format_singbox_profile_action(
|
||||
self,
|
||||
action: str,
|
||||
profile_id: str,
|
||||
message: str,
|
||||
res: SingBoxProfileValidateResult | SingBoxProfileRenderResult | SingBoxProfileApplyResult | SingBoxProfileRollbackResult,
|
||||
) -> str:
|
||||
bits: List[str] = []
|
||||
if getattr(res, "code", ""):
|
||||
bits.append(f"code={str(getattr(res, 'code', '')).strip()}")
|
||||
|
||||
rev = int(getattr(res, "profile_revision", 0) or 0)
|
||||
if rev > 0:
|
||||
bits.append(f"rev={rev}")
|
||||
|
||||
diff = getattr(res, "diff", None)
|
||||
if diff is not None:
|
||||
added = int(getattr(diff, "added", 0) or 0)
|
||||
changed = int(getattr(diff, "changed", 0) or 0)
|
||||
removed = int(getattr(diff, "removed", 0) or 0)
|
||||
bits.append(f"diff=+{added}/~{changed}/-{removed}")
|
||||
|
||||
render_digest = str(getattr(res, "render_digest", "") or "").strip()
|
||||
if render_digest:
|
||||
bits.append(f"digest={render_digest[:12]}")
|
||||
|
||||
client_id = str(getattr(res, "client_id", "") or "").strip()
|
||||
if client_id:
|
||||
bits.append(f"client={client_id}")
|
||||
config_path = str(getattr(res, "config_path", "") or "").strip()
|
||||
if config_path:
|
||||
bits.append(f"config={config_path}")
|
||||
history_id = str(getattr(res, "history_id", "") or "").strip()
|
||||
if history_id:
|
||||
bits.append(f"history={history_id}")
|
||||
render_path = str(getattr(res, "render_path", "") or "").strip()
|
||||
if render_path:
|
||||
bits.append(f"render={render_path}")
|
||||
render_revision = int(getattr(res, "render_revision", 0) or 0)
|
||||
if render_revision > 0:
|
||||
bits.append(f"render_rev={render_revision}")
|
||||
|
||||
rollback_available = bool(getattr(res, "rollback_available", False))
|
||||
if rollback_available:
|
||||
bits.append("rollback=available")
|
||||
|
||||
errors = cast(List[SingBoxProfileIssue], list(getattr(res, "errors", []) or []))
|
||||
warnings = cast(List[SingBoxProfileIssue], list(getattr(res, "warnings", []) or []))
|
||||
if warnings:
|
||||
bits.append(f"warnings={len(warnings)}")
|
||||
if errors:
|
||||
bits.append(f"errors={len(errors)}")
|
||||
first = self._format_singbox_issue_brief(errors[0])
|
||||
if first:
|
||||
bits.append(f"first_error={first}")
|
||||
|
||||
tail = f" ({'; '.join(bits)})" if bits else ""
|
||||
return f"{action} profile {profile_id}: {message}{tail}"
|
||||
|
||||
def _format_singbox_history_line(self, it) -> str:
|
||||
at = str(getattr(it, "at", "") or "").strip() or "-"
|
||||
action = str(getattr(it, "action", "") or "").strip() or "event"
|
||||
status = str(getattr(it, "status", "") or "").strip() or "unknown"
|
||||
msg = str(getattr(it, "message", "") or "").strip()
|
||||
code = str(getattr(it, "code", "") or "").strip()
|
||||
digest = str(getattr(it, "render_digest", "") or "").strip()
|
||||
client_id = str(getattr(it, "client_id", "") or "").strip()
|
||||
bits: List[str] = []
|
||||
if code:
|
||||
bits.append(f"code={code}")
|
||||
if client_id:
|
||||
bits.append(f"client={client_id}")
|
||||
if digest:
|
||||
bits.append(f"digest={digest[:12]}")
|
||||
tail = f" ({'; '.join(bits)})" if bits else ""
|
||||
body = msg or "-"
|
||||
return f"{at} | {action} | {status} | {body}{tail}"
|
||||
|
||||
def _resolve_singbox_profile_id(self, profile_id: str, client: Optional[TransportClient]) -> str:
|
||||
pid = str(profile_id or "").strip()
|
||||
if client is not None:
|
||||
ensured_pid, _ = self._ensure_singbox_profile_for_client(client, preferred_profile_id=pid)
|
||||
pid = ensured_pid
|
||||
if not pid:
|
||||
raise ValueError("missing singbox profile id")
|
||||
return pid
|
||||
|
||||
def _ensure_singbox_profile_for_client(
|
||||
self,
|
||||
client: TransportClient,
|
||||
*,
|
||||
preferred_profile_id: str = "",
|
||||
) -> tuple[str, str]:
|
||||
cid = str(getattr(client, "id", "") or "").strip()
|
||||
if not cid:
|
||||
raise ValueError("missing transport client id")
|
||||
|
||||
pid = str(preferred_profile_id or "").strip()
|
||||
if not pid:
|
||||
pid = self.singbox_profile_id_for_client(client)
|
||||
if not pid:
|
||||
raise ValueError("cannot resolve singbox profile id for selected client")
|
||||
|
||||
try:
|
||||
cur = self.client.transport_singbox_profile_get(pid)
|
||||
except ApiError as e:
|
||||
if int(getattr(e, "status_code", 0) or 0) != 404:
|
||||
raise
|
||||
raw_cfg = self._load_singbox_raw_config_from_client(client)
|
||||
protocol = self._infer_singbox_protocol(client, raw_cfg)
|
||||
snap: SingBoxProfilesState = self.client.transport_singbox_profile_create(
|
||||
profile_id=pid,
|
||||
name=str(getattr(client, "name", "") or "").strip() or pid,
|
||||
mode="raw",
|
||||
protocol=protocol,
|
||||
raw_config=raw_cfg,
|
||||
meta={"client_id": cid},
|
||||
enabled=True,
|
||||
)
|
||||
created = snap.item
|
||||
if created is None:
|
||||
raise RuntimeError("profile create returned empty item")
|
||||
return str(created.id or pid).strip(), "created"
|
||||
|
||||
meta = dict(cur.meta or {})
|
||||
if str(meta.get("client_id") or "").strip() == cid:
|
||||
return pid, "ok"
|
||||
meta["client_id"] = cid
|
||||
snap = self.client.transport_singbox_profile_patch(
|
||||
pid,
|
||||
base_revision=int(cur.profile_revision or 0),
|
||||
meta=meta,
|
||||
)
|
||||
if snap.item is not None:
|
||||
pid = str(snap.item.id or pid).strip()
|
||||
return pid, "linked"
|
||||
|
||||
def _load_singbox_raw_config_from_client(self, client: TransportClient) -> dict:
|
||||
cfg = getattr(client, "config", {}) or {}
|
||||
if not isinstance(cfg, dict):
|
||||
return {}
|
||||
path = str(cfg.get("config_path") or "").strip()
|
||||
if not path:
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
parsed = json.load(f)
|
||||
if isinstance(parsed, dict):
|
||||
return cast(dict, parsed)
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def _infer_singbox_protocol(self, client: TransportClient, raw_cfg: dict) -> str:
|
||||
cfg = getattr(client, "config", {}) or {}
|
||||
if isinstance(cfg, dict):
|
||||
p = str(cfg.get("protocol") or "").strip().lower()
|
||||
if p:
|
||||
return p
|
||||
if isinstance(raw_cfg, dict):
|
||||
outbounds = raw_cfg.get("outbounds") or []
|
||||
if isinstance(outbounds, list):
|
||||
for row in outbounds:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
t = str(row.get("type") or "").strip().lower()
|
||||
if not t:
|
||||
continue
|
||||
if t in ("direct", "block", "dns"):
|
||||
continue
|
||||
return t
|
||||
return "vless"
|
||||
|
||||
def _format_singbox_issue_brief(self, issue: SingBoxProfileIssue) -> str:
|
||||
code = str(getattr(issue, "code", "") or "").strip()
|
||||
field = str(getattr(issue, "field", "") or "").strip()
|
||||
message = str(getattr(issue, "message", "") or "").strip()
|
||||
parts = [x for x in (code, field, message) if x]
|
||||
if not parts:
|
||||
return ""
|
||||
out = ": ".join(parts[:2]) if len(parts) > 1 else parts[0]
|
||||
if len(parts) > 2:
|
||||
out = f"{out}: {parts[2]}"
|
||||
return out if len(out) <= 140 else out[:137] + "..."
|
||||
136
selective-vpn-gui/controllers/views.py
Normal file
136
selective-vpn-gui/controllers/views.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Literal
|
||||
|
||||
from api_client import TransportConflict, TransportPolicyIntent
|
||||
|
||||
TraceMode = Literal["full", "gui", "smartdns"]
|
||||
ServiceAction = Literal["start", "stop", "restart"]
|
||||
LoginAction = Literal["open", "check", "cancel"]
|
||||
TransportClientAction = Literal["provision", "start", "stop", "restart"]
|
||||
TransportFlowPhase = Literal["draft", "validated", "risky", "confirm", "applied", "error"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginView:
|
||||
text: str
|
||||
color: str
|
||||
logged_in: bool
|
||||
email: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatusOverviewView:
|
||||
timestamp: str
|
||||
counts: str
|
||||
iface_table_mark: str
|
||||
policy_route: str
|
||||
routes_service: str
|
||||
smartdns_service: str
|
||||
vpn_service: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VpnStatusView:
|
||||
desired_location: str
|
||||
pretty_text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActionView:
|
||||
ok: bool
|
||||
pretty_text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginFlowView:
|
||||
phase: str
|
||||
level: str
|
||||
dot_color: str
|
||||
status_text: str
|
||||
url: str
|
||||
email: str
|
||||
alive: bool
|
||||
cursor: int
|
||||
lines: List[str]
|
||||
can_open: bool
|
||||
can_check: bool
|
||||
can_cancel: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VpnAutoconnectView:
|
||||
"""Для блока Autoconnect на вкладке AdGuardVPN."""
|
||||
enabled: bool # True = включён autoloop
|
||||
unit_text: str # строка вида "unit: active"
|
||||
color: str # "green" / "red" / "orange"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutesNftProgressView:
|
||||
"""Прогресс обновления nft-наборов (agvpn4)."""
|
||||
percent: int
|
||||
message: str
|
||||
active: bool # True — пока идёт апдейт, False — когда закончили / ничего не идёт
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrafficModeView:
|
||||
desired_mode: str
|
||||
applied_mode: str
|
||||
preferred_iface: str
|
||||
advanced_active: bool
|
||||
auto_local_bypass: bool
|
||||
auto_local_active: bool
|
||||
ingress_reply_bypass: bool
|
||||
ingress_reply_active: bool
|
||||
bypass_candidates: int
|
||||
force_vpn_subnets: List[str]
|
||||
force_vpn_uids: List[str]
|
||||
force_vpn_cgroups: List[str]
|
||||
force_direct_subnets: List[str]
|
||||
force_direct_uids: List[str]
|
||||
force_direct_cgroups: List[str]
|
||||
overrides_applied: int
|
||||
cgroup_resolved_uids: int
|
||||
cgroup_warning: str
|
||||
active_iface: str
|
||||
iface_reason: str
|
||||
ingress_rule_present: bool
|
||||
ingress_nft_active: bool
|
||||
probe_ok: bool
|
||||
probe_message: str
|
||||
healthy: bool
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutesResolveSummaryView:
|
||||
available: bool
|
||||
text: str
|
||||
recheck_text: str
|
||||
color: str
|
||||
recheck_color: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransportPolicyFlowView:
|
||||
phase: TransportFlowPhase
|
||||
intents: List[TransportPolicyIntent]
|
||||
base_revision: int
|
||||
current_revision: int
|
||||
applied_revision: int
|
||||
confirm_token: str
|
||||
valid: bool
|
||||
block_count: int
|
||||
warn_count: int
|
||||
diff_added: int
|
||||
diff_changed: int
|
||||
diff_removed: int
|
||||
conflicts: List[TransportConflict]
|
||||
apply_id: str
|
||||
rollback_available: bool
|
||||
message: str
|
||||
code: str
|
||||
305
selective-vpn-gui/controllers/vpn_controller.py
Normal file
305
selective-vpn-gui/controllers/vpn_controller.py
Normal file
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, cast
|
||||
|
||||
from api_client import (
|
||||
CmdResult,
|
||||
EgressIdentity,
|
||||
EgressIdentityRefreshResult,
|
||||
LoginSessionAction,
|
||||
LoginSessionStart,
|
||||
LoginSessionState,
|
||||
LoginState,
|
||||
VpnLocation,
|
||||
VpnLocationsState,
|
||||
VpnStatus,
|
||||
)
|
||||
|
||||
from .views import ActionView, LoginAction, LoginFlowView, VpnAutoconnectView, VpnStatusView
|
||||
|
||||
|
||||
class VpnControllerMixin:
|
||||
def vpn_locations_view(self) -> List[VpnLocation]:
|
||||
return self.client.vpn_locations()
|
||||
|
||||
def vpn_locations_state_view(self) -> VpnLocationsState:
|
||||
return self.client.vpn_locations_state()
|
||||
|
||||
def vpn_locations_refresh_trigger(self) -> None:
|
||||
self.client.vpn_locations_refresh_trigger()
|
||||
|
||||
def vpn_status_view(self) -> VpnStatusView:
|
||||
st = self.client.vpn_status()
|
||||
pretty = self._pretty_vpn_status(st)
|
||||
return VpnStatusView(
|
||||
desired_location=st.desired_location,
|
||||
pretty_text=pretty,
|
||||
)
|
||||
|
||||
def vpn_status_model(self) -> VpnStatus:
|
||||
return self.client.vpn_status()
|
||||
|
||||
# --- autoconnect / autoloop ---
|
||||
|
||||
def _autoconnect_from_auto(self, auto) -> bool:
|
||||
"""
|
||||
Вытаскиваем True/False из ответа /vpn/autoloop/status.
|
||||
|
||||
Приоритет:
|
||||
1) явное поле auto.enabled (bool)
|
||||
2) эвристика по status_word / raw_text
|
||||
"""
|
||||
enabled_field = getattr(auto, "enabled", None)
|
||||
if isinstance(enabled_field, bool):
|
||||
return enabled_field
|
||||
|
||||
word = (getattr(auto, "status_word", "") or "").strip().lower()
|
||||
raw = (getattr(auto, "raw_text", "") or "").lower()
|
||||
|
||||
# приоритет — явные статусы
|
||||
if word in (
|
||||
"active",
|
||||
"running",
|
||||
"enabled",
|
||||
"on",
|
||||
"up",
|
||||
"started",
|
||||
"ok",
|
||||
"true",
|
||||
"yes",
|
||||
):
|
||||
return True
|
||||
if word in ("inactive", "stopped", "disabled", "off", "down", "false", "no"):
|
||||
return False
|
||||
|
||||
# фоллбек — по raw_text
|
||||
if "inactive" in raw or "disabled" in raw or "failed" in raw:
|
||||
return False
|
||||
if "active" in raw or "running" in raw or "enabled" in raw:
|
||||
return True
|
||||
return False
|
||||
|
||||
def vpn_autoconnect_view(self) -> VpnAutoconnectView:
|
||||
try:
|
||||
auto = self.client.vpn_autoloop_status()
|
||||
except Exception as e:
|
||||
return VpnAutoconnectView(
|
||||
enabled=False,
|
||||
unit_text=f"unit: ERROR ({e})",
|
||||
color="red",
|
||||
)
|
||||
|
||||
enabled = self._autoconnect_from_auto(auto)
|
||||
|
||||
unit_state = (
|
||||
getattr(auto, "unit_state", "") # если backend так отдаёт
|
||||
or (auto.status_word or "")
|
||||
or "unknown"
|
||||
)
|
||||
|
||||
text = f"unit: {unit_state}"
|
||||
|
||||
low = f"{unit_state} {(auto.raw_text or '')}".lower()
|
||||
if any(x in low for x in ("failed", "error", "unknown", "inactive", "dead")):
|
||||
color = "red"
|
||||
elif "active" in low or "running" in low or "enabled" in low:
|
||||
color = "green"
|
||||
else:
|
||||
color = "orange"
|
||||
|
||||
return VpnAutoconnectView(enabled=enabled, unit_text=text, color=color)
|
||||
|
||||
def vpn_autoconnect_enabled(self) -> bool:
|
||||
"""Старый интерфейс — оставляем для кнопки toggle."""
|
||||
return self.vpn_autoconnect_view().enabled
|
||||
|
||||
def vpn_set_autoconnect(self, enable: bool) -> VpnStatusView:
|
||||
res = self.client.vpn_autoconnect(enable)
|
||||
st = self.client.vpn_status()
|
||||
pretty = self._pretty_cmd_then_status(res, st)
|
||||
return VpnStatusView(
|
||||
desired_location=st.desired_location,
|
||||
pretty_text=pretty,
|
||||
)
|
||||
|
||||
def vpn_set_location(self, target: str, iso: str = "", label: str = "") -> VpnStatusView:
|
||||
self.client.vpn_set_location(target=target, iso=iso, label=label)
|
||||
st = self.client.vpn_status()
|
||||
pretty = self._pretty_vpn_status(st)
|
||||
return VpnStatusView(
|
||||
desired_location=st.desired_location,
|
||||
pretty_text=pretty,
|
||||
)
|
||||
|
||||
def egress_identity(self, scope: str, *, refresh: bool = False) -> EgressIdentity:
|
||||
return self.client.egress_identity_get(scope, refresh=refresh)
|
||||
|
||||
def egress_identity_refresh(
|
||||
self,
|
||||
*,
|
||||
scopes: Optional[List[str]] = None,
|
||||
force: bool = False,
|
||||
) -> EgressIdentityRefreshResult:
|
||||
return self.client.egress_identity_refresh(scopes=scopes, force=force)
|
||||
|
||||
def _pretty_vpn_status(self, st: VpnStatus) -> str:
|
||||
lines = [
|
||||
f"unit_state: {st.unit_state}",
|
||||
f"desired_location: {st.desired_location or '—'}",
|
||||
f"status: {st.status_word}",
|
||||
]
|
||||
if st.raw_text:
|
||||
lines.append("")
|
||||
lines.append(st.raw_text.strip())
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
# -------- Login Flow (interactive) --------
|
||||
|
||||
def login_flow_start(self) -> LoginFlowView:
|
||||
s: LoginSessionStart = self.client.vpn_login_session_start()
|
||||
|
||||
dot = self._level_to_color(s.level)
|
||||
|
||||
if not s.ok:
|
||||
txt = s.error or "Failed to start login session"
|
||||
return LoginFlowView(
|
||||
phase=s.phase or "failed",
|
||||
level=s.level or "red",
|
||||
dot_color="red",
|
||||
status_text=txt,
|
||||
url="",
|
||||
email="",
|
||||
alive=False,
|
||||
cursor=0,
|
||||
lines=[txt],
|
||||
can_open=False,
|
||||
can_check=False,
|
||||
can_cancel=False,
|
||||
)
|
||||
|
||||
if (s.phase or "").lower() == "already_logged":
|
||||
txt = (
|
||||
f"Already logged in as {s.email}"
|
||||
if s.email
|
||||
else "Already logged in"
|
||||
)
|
||||
return LoginFlowView(
|
||||
phase="already_logged",
|
||||
level="green",
|
||||
dot_color="green",
|
||||
status_text=txt,
|
||||
url="",
|
||||
email=s.email or "",
|
||||
alive=False,
|
||||
cursor=0,
|
||||
lines=[txt],
|
||||
can_open=False,
|
||||
can_check=False,
|
||||
can_cancel=False,
|
||||
)
|
||||
|
||||
txt = f"Login started (pid={s.pid})" if s.pid else "Login started"
|
||||
return LoginFlowView(
|
||||
phase=s.phase or "starting",
|
||||
level=s.level or "yellow",
|
||||
dot_color=dot,
|
||||
status_text=txt,
|
||||
url="",
|
||||
email="",
|
||||
alive=True,
|
||||
cursor=0,
|
||||
lines=[],
|
||||
can_open=True,
|
||||
can_check=True,
|
||||
can_cancel=True,
|
||||
)
|
||||
|
||||
def login_flow_poll(self, since: int) -> LoginFlowView:
|
||||
st: LoginSessionState = self.client.vpn_login_session_state(since=since)
|
||||
|
||||
dot = self._level_to_color(st.level)
|
||||
|
||||
phase = (st.phase or "").lower()
|
||||
if phase == "waiting_browser":
|
||||
status_txt = "Waiting for browser authorization…"
|
||||
elif phase == "checking":
|
||||
status_txt = "Checking…"
|
||||
elif phase == "success":
|
||||
status_txt = "✅ Logged in"
|
||||
elif phase == "failed":
|
||||
status_txt = "❌ Login failed"
|
||||
elif phase == "cancelled":
|
||||
status_txt = "Cancelled"
|
||||
elif phase == "already_logged":
|
||||
status_txt = (
|
||||
f"Already logged in as {st.email}"
|
||||
if st.email
|
||||
else "Already logged in"
|
||||
)
|
||||
else:
|
||||
status_txt = st.phase or "…"
|
||||
|
||||
clean_lines = self._clean_login_lines(st.lines)
|
||||
|
||||
return LoginFlowView(
|
||||
phase=st.phase,
|
||||
level=st.level,
|
||||
dot_color=dot,
|
||||
status_text=status_txt,
|
||||
url=st.url,
|
||||
email=st.email,
|
||||
alive=st.alive,
|
||||
cursor=st.cursor,
|
||||
can_open=st.can_open,
|
||||
can_check=st.can_cancel,
|
||||
can_cancel=st.can_cancel,
|
||||
lines=clean_lines,
|
||||
)
|
||||
|
||||
def login_flow_action(self, action: str) -> ActionView:
|
||||
act = action.strip().lower()
|
||||
if act not in ("open", "check", "cancel"):
|
||||
raise ValueError(f"Invalid login action: {action}")
|
||||
|
||||
res: LoginSessionAction = self.client.vpn_login_session_action(
|
||||
cast(LoginAction, act)
|
||||
)
|
||||
|
||||
if not res.ok:
|
||||
txt = res.error or "Login action failed"
|
||||
return ActionView(ok=False, pretty_text=txt + "\n")
|
||||
|
||||
txt = f"OK: {act} → phase={res.phase} level={res.level}"
|
||||
return ActionView(ok=True, pretty_text=txt + "\n")
|
||||
|
||||
def login_flow_stop(self) -> ActionView:
|
||||
res = self.client.vpn_login_session_stop()
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
def vpn_logout(self) -> ActionView:
|
||||
res = self.client.vpn_logout()
|
||||
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
|
||||
|
||||
# Баннер "AdGuard VPN: logged in as ...", по клику показываем инфу как в CLI
|
||||
def login_banner_cli_text(self) -> str:
|
||||
try:
|
||||
st: LoginState = self.client.get_login_state()
|
||||
except Exception as e:
|
||||
return f"Failed to query login state: {e}"
|
||||
|
||||
# backend может не иметь поля error, поэтому через getattr
|
||||
err = getattr(st, "error", None) or getattr(st, "message", None)
|
||||
if err:
|
||||
return str(err)
|
||||
|
||||
if st.email:
|
||||
return f"You are already logged in.\nCurrent user is {st.email}"
|
||||
|
||||
if st.state:
|
||||
return f"Login state: {st.state}"
|
||||
|
||||
return "No login information available."
|
||||
|
||||
# -------- Routes --------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ from typing import Callable, List
|
||||
from PySide6.QtCore import Qt, QSettings
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
@@ -131,6 +132,14 @@ class DNSBenchmarkDialog(QDialog):
|
||||
opts.addWidget(QLabel("Parallel DNS checks:"))
|
||||
opts.addWidget(self.spin_concurrency)
|
||||
|
||||
self.chk_load_profile = QCheckBox("Load profile (realistic)")
|
||||
self.chk_load_profile.setChecked(True)
|
||||
self.chk_load_profile.setToolTip(
|
||||
"EN: Load profile adds synthetic NX probes and burst rounds to simulate resolver pressure.\n"
|
||||
"RU: Load-профиль добавляет synthetic NX-пробы и burst-раунды, чтобы симулировать нагрузку резолвера."
|
||||
)
|
||||
opts.addWidget(self.chk_load_profile)
|
||||
|
||||
self.btn_run = QPushButton("Run benchmark")
|
||||
self.btn_run.clicked.connect(self.on_run_benchmark)
|
||||
opts.addWidget(self.btn_run)
|
||||
@@ -339,6 +348,7 @@ class DNSBenchmarkDialog(QDialog):
|
||||
timeout_ms=int(self.spin_timeout.value()),
|
||||
attempts=int(self.spin_attempts.value()),
|
||||
concurrency=int(self.spin_concurrency.value()),
|
||||
profile="load" if self.chk_load_profile.isChecked() else "quick",
|
||||
)
|
||||
self._render_results(resp)
|
||||
if self.refresh_cb:
|
||||
@@ -376,7 +386,7 @@ class DNSBenchmarkDialog(QDialog):
|
||||
|
||||
self.lbl_summary.setText(
|
||||
f"Checked: {len(resp.results)} DNS | domains={len(resp.domains_used)} "
|
||||
f"| timeout={resp.timeout_ms}ms"
|
||||
f"| timeout={resp.timeout_ms}ms | profile={str(getattr(resp, 'profile', '') or 'load')}"
|
||||
)
|
||||
self.lbl_summary.setStyleSheet("color: gray;")
|
||||
|
||||
|
||||
26
selective-vpn-gui/main_window/__init__.py
Normal file
26
selective-vpn-gui/main_window/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from .constants import (
|
||||
LOCATION_TARGET_ROLE,
|
||||
LoginPage,
|
||||
SINGBOX_EDITOR_PROTOCOL_IDS,
|
||||
SINGBOX_EDITOR_PROTOCOL_OPTIONS,
|
||||
SINGBOX_PROTOCOL_SEED_SPEC,
|
||||
SINGBOX_STATUS_ROLE,
|
||||
)
|
||||
from .runtime_actions_mixin import MainWindowRuntimeActionsMixin
|
||||
from .singbox_mixin import SingBoxMainWindowMixin
|
||||
from .ui_shell_mixin import MainWindowUIShellMixin
|
||||
from .workers import EventThread, LocationsThread
|
||||
|
||||
__all__ = [
|
||||
"EventThread",
|
||||
"LOCATION_TARGET_ROLE",
|
||||
"MainWindowRuntimeActionsMixin",
|
||||
"MainWindowUIShellMixin",
|
||||
"LocationsThread",
|
||||
"LoginPage",
|
||||
"SingBoxMainWindowMixin",
|
||||
"SINGBOX_EDITOR_PROTOCOL_IDS",
|
||||
"SINGBOX_EDITOR_PROTOCOL_OPTIONS",
|
||||
"SINGBOX_PROTOCOL_SEED_SPEC",
|
||||
"SINGBOX_STATUS_ROLE",
|
||||
]
|
||||
81
selective-vpn-gui/main_window/constants.py
Normal file
81
selective-vpn-gui/main_window/constants.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
_NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s")
|
||||
LoginPage = Literal["main", "login"]
|
||||
LOCATION_TARGET_ROLE = Qt.UserRole + 1
|
||||
SINGBOX_STATUS_ROLE = Qt.UserRole + 2
|
||||
SINGBOX_EDITOR_PROTOCOL_OPTIONS = [
|
||||
("VLESS", "vless"),
|
||||
("Trojan", "trojan"),
|
||||
("Shadowsocks", "shadowsocks"),
|
||||
("Hysteria2", "hysteria2"),
|
||||
("TUIC", "tuic"),
|
||||
("WireGuard", "wireguard"),
|
||||
]
|
||||
SINGBOX_EDITOR_PROTOCOL_IDS = tuple([pid for _label, pid in SINGBOX_EDITOR_PROTOCOL_OPTIONS])
|
||||
SINGBOX_PROTOCOL_SEED_SPEC: dict[str, dict[str, Any]] = {
|
||||
"vless": {
|
||||
"port": 443,
|
||||
"security": "none",
|
||||
"proxy_defaults": {
|
||||
"uuid": "",
|
||||
},
|
||||
},
|
||||
"trojan": {
|
||||
"port": 443,
|
||||
"security": "tls",
|
||||
"proxy_defaults": {
|
||||
"password": "",
|
||||
},
|
||||
},
|
||||
"shadowsocks": {
|
||||
"port": 443,
|
||||
"security": "none",
|
||||
"proxy_defaults": {
|
||||
"method": "aes-128-gcm",
|
||||
"password": "",
|
||||
},
|
||||
},
|
||||
"hysteria2": {
|
||||
"port": 443,
|
||||
"security": "tls",
|
||||
"proxy_defaults": {
|
||||
"password": "",
|
||||
},
|
||||
"tls_security": "tls",
|
||||
},
|
||||
"tuic": {
|
||||
"port": 443,
|
||||
"security": "tls",
|
||||
"proxy_defaults": {
|
||||
"uuid": "",
|
||||
"password": "",
|
||||
},
|
||||
"tls_security": "tls",
|
||||
},
|
||||
"wireguard": {
|
||||
"port": 51820,
|
||||
"security": "none",
|
||||
"proxy_defaults": {
|
||||
"private_key": "",
|
||||
"peer_public_key": "",
|
||||
"local_address": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"LOCATION_TARGET_ROLE",
|
||||
"LoginPage",
|
||||
"SINGBOX_EDITOR_PROTOCOL_IDS",
|
||||
"SINGBOX_EDITOR_PROTOCOL_OPTIONS",
|
||||
"SINGBOX_PROTOCOL_SEED_SPEC",
|
||||
"SINGBOX_STATUS_ROLE",
|
||||
"_NEXT_CHECK_RE",
|
||||
]
|
||||
18
selective-vpn-gui/main_window/runtime_actions_mixin.py
Normal file
18
selective-vpn-gui/main_window/runtime_actions_mixin.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from main_window.runtime_auth_mixin import RuntimeAuthMixin
|
||||
from main_window.runtime_ops_mixin import RuntimeOpsMixin
|
||||
from main_window.runtime_refresh_mixin import RuntimeRefreshMixin
|
||||
from main_window.runtime_state_mixin import RuntimeStateMixin
|
||||
|
||||
|
||||
class MainWindowRuntimeActionsMixin(
|
||||
RuntimeOpsMixin,
|
||||
RuntimeAuthMixin,
|
||||
RuntimeRefreshMixin,
|
||||
RuntimeStateMixin,
|
||||
):
|
||||
"""Facade mixin for backward-compatible MainWindow inheritance."""
|
||||
|
||||
|
||||
__all__ = ["MainWindowRuntimeActionsMixin"]
|
||||
209
selective-vpn-gui/main_window/runtime_auth_mixin.py
Normal file
209
selective-vpn-gui/main_window/runtime_auth_mixin.py
Normal file
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
|
||||
class RuntimeAuthMixin:
|
||||
def on_auth_button(self) -> None:
|
||||
def work():
|
||||
view = self.ctrl.get_login_view()
|
||||
if view.logged_in:
|
||||
self.on_logout()
|
||||
else:
|
||||
# при логине всегда переходим на вкладку AdGuardVPN и
|
||||
# показываем страницу логина
|
||||
self.tabs.setCurrentWidget(self.tab_vpn)
|
||||
self._show_vpn_page("login")
|
||||
self.on_start_login()
|
||||
self._safe(work, title="Auth error")
|
||||
|
||||
def on_login_banner_clicked(self) -> None:
|
||||
def work():
|
||||
txt = self.ctrl.login_banner_cli_text()
|
||||
QMessageBox.information(self, "AdGuard VPN", txt)
|
||||
self._safe(work, title="Login banner error")
|
||||
|
||||
# ---------------- LOGIN FLOW ACTIONS ----------------
|
||||
|
||||
def on_start_login(self) -> None:
|
||||
def work():
|
||||
self.ctrl.log_gui("Top Login clicked")
|
||||
self._show_vpn_page("login")
|
||||
self._login_flow_reset_ui()
|
||||
|
||||
start = self.ctrl.login_flow_start()
|
||||
|
||||
self._login_cursor = int(start.cursor)
|
||||
self.lbl_login_flow_status.setText(
|
||||
f"Status: {start.status_text or '—'}"
|
||||
)
|
||||
self.lbl_login_flow_email.setText(
|
||||
f"User: {start.email}" if start.email else ""
|
||||
)
|
||||
self.edit_login_url.setText(start.url or "")
|
||||
|
||||
self._login_flow_set_buttons(
|
||||
can_open=start.can_open,
|
||||
can_check=start.can_check,
|
||||
can_cancel=start.can_cancel,
|
||||
)
|
||||
|
||||
if start.lines:
|
||||
cleaned = self._clean_ui_lines(start.lines)
|
||||
if cleaned:
|
||||
self._append_text(self.txt_login_flow, cleaned + "\n")
|
||||
|
||||
if not start.alive:
|
||||
self._login_flow_autopoll_stop()
|
||||
self._login_flow_set_buttons(
|
||||
can_open=False, can_check=False, can_cancel=False
|
||||
)
|
||||
self.btn_login_stop.setEnabled(False)
|
||||
QTimer.singleShot(250, self.refresh_login_banner)
|
||||
return
|
||||
|
||||
self._login_flow_autopoll_start()
|
||||
|
||||
self._safe(work, title="Login start error")
|
||||
|
||||
def _login_flow_reset_ui(self) -> None:
|
||||
self._login_cursor = 0
|
||||
self._login_url_opened = False
|
||||
self.edit_login_url.setText("")
|
||||
self.lbl_login_flow_status.setText("Status: —")
|
||||
self.lbl_login_flow_email.setText("")
|
||||
self._set_text(self.txt_login_flow, "")
|
||||
|
||||
def _login_flow_set_buttons(
|
||||
self,
|
||||
*,
|
||||
can_open: bool,
|
||||
can_check: bool,
|
||||
can_cancel: bool,
|
||||
) -> None:
|
||||
self.btn_login_open.setEnabled(bool(can_open))
|
||||
self.btn_login_copy.setEnabled(bool(self.edit_login_url.text().strip()))
|
||||
self.btn_login_check.setEnabled(bool(can_check))
|
||||
self.btn_login_close.setEnabled(bool(can_cancel))
|
||||
self.btn_login_stop.setEnabled(True)
|
||||
|
||||
def _login_flow_autopoll_start(self) -> None:
|
||||
self._login_flow_active = True
|
||||
if not self.login_poll_timer.isActive():
|
||||
self.login_poll_timer.start()
|
||||
|
||||
def _login_flow_autopoll_stop(self) -> None:
|
||||
self._login_flow_active = False
|
||||
if self.login_poll_timer.isActive():
|
||||
self.login_poll_timer.stop()
|
||||
|
||||
def _login_poll_tick(self) -> None:
|
||||
if not self._login_flow_active:
|
||||
return
|
||||
|
||||
def work():
|
||||
view = self.ctrl.login_flow_poll(self._login_cursor)
|
||||
self._login_cursor = int(view.cursor)
|
||||
|
||||
self.lbl_login_flow_status.setText(
|
||||
f"Status: {view.status_text or '—'}"
|
||||
)
|
||||
self.lbl_login_flow_email.setText(
|
||||
f"User: {view.email}" if view.email else ""
|
||||
)
|
||||
|
||||
if view.url:
|
||||
self.edit_login_url.setText(view.url)
|
||||
|
||||
self._login_flow_set_buttons(
|
||||
can_open=view.can_open,
|
||||
can_check=view.can_check,
|
||||
can_cancel=view.can_cancel,
|
||||
)
|
||||
|
||||
cleaned = self._clean_ui_lines(view.lines)
|
||||
if cleaned:
|
||||
self._append_text(self.txt_login_flow, cleaned + "\n")
|
||||
|
||||
if (not self._login_url_opened) and view.url:
|
||||
self._login_url_opened = True
|
||||
try:
|
||||
subprocess.Popen(["xdg-open", view.url])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
phase = (view.phase or "").strip().lower()
|
||||
if (not view.alive) or phase in (
|
||||
"success",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"already_logged",
|
||||
):
|
||||
self._login_flow_autopoll_stop()
|
||||
self._login_flow_set_buttons(
|
||||
can_open=False, can_check=False, can_cancel=False
|
||||
)
|
||||
self.btn_login_stop.setEnabled(False)
|
||||
QTimer.singleShot(250, self.refresh_login_banner)
|
||||
|
||||
self._safe(work, title="Login flow error")
|
||||
|
||||
def on_login_copy(self) -> None:
|
||||
def work():
|
||||
u = self.edit_login_url.text().strip()
|
||||
if u:
|
||||
QApplication.clipboard().setText(u)
|
||||
self.ctrl.log_gui("Login flow: copy-url")
|
||||
self._safe(work, title="Login copy error")
|
||||
|
||||
def on_login_open(self) -> None:
|
||||
def work():
|
||||
u = self.edit_login_url.text().strip()
|
||||
if u:
|
||||
try:
|
||||
subprocess.Popen(["xdg-open", u])
|
||||
except Exception:
|
||||
pass
|
||||
self.ctrl.log_gui("Login flow: open")
|
||||
self._safe(work, title="Login open error")
|
||||
|
||||
def on_login_check(self) -> None:
|
||||
def work():
|
||||
# если ещё ничего не запущено — считаем это стартом логина
|
||||
if (
|
||||
not self._login_flow_active
|
||||
and self._login_cursor == 0
|
||||
and not self.edit_login_url.text().strip()
|
||||
and not self.txt_login_flow.toPlainText().strip()
|
||||
):
|
||||
self.on_start_login()
|
||||
return
|
||||
|
||||
self.ctrl.login_flow_action("check")
|
||||
self.ctrl.log_gui("Login flow: check")
|
||||
self._safe(work, title="Login check error")
|
||||
|
||||
def on_login_cancel(self) -> None:
|
||||
def work():
|
||||
self.ctrl.login_flow_action("cancel")
|
||||
self.ctrl.log_gui("Login flow: cancel")
|
||||
self._safe(work, title="Login cancel error")
|
||||
|
||||
def on_login_stop(self) -> None:
|
||||
def work():
|
||||
self.ctrl.login_flow_stop()
|
||||
self.ctrl.log_gui("Login flow: stop")
|
||||
self._login_flow_autopoll_stop()
|
||||
QTimer.singleShot(250, self.refresh_login_banner)
|
||||
self._safe(work, title="Login stop error")
|
||||
|
||||
def on_logout(self) -> None:
|
||||
def work():
|
||||
self.ctrl.log_gui("Top Logout clicked")
|
||||
res = self.ctrl.vpn_logout()
|
||||
self._set_text(self.txt_vpn, res.pretty_text or str(res))
|
||||
QTimer.singleShot(250, self.refresh_login_banner)
|
||||
self._safe(work, title="Logout error")
|
||||
268
selective-vpn-gui/main_window/runtime_ops_mixin.py
Normal file
268
selective-vpn-gui/main_window/runtime_ops_mixin.py
Normal file
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from dns_benchmark_dialog import DNSBenchmarkDialog
|
||||
from main_window.constants import LOCATION_TARGET_ROLE
|
||||
from traffic_mode_dialog import TrafficModeDialog
|
||||
|
||||
|
||||
class RuntimeOpsMixin:
|
||||
def on_toggle_autoconnect(self) -> None:
|
||||
def work():
|
||||
current = self.ctrl.vpn_autoconnect_enabled()
|
||||
enable = not current
|
||||
self.ctrl.vpn_set_autoconnect(enable)
|
||||
self.ctrl.log_gui(f"VPN autoconnect set to {enable}")
|
||||
self.refresh_vpn_tab()
|
||||
self._safe(work, title="Autoconnect error")
|
||||
|
||||
def on_location_activated(self, _index: int) -> None:
|
||||
self._safe(self._apply_selected_location, title="Location error")
|
||||
|
||||
def on_set_location(self) -> None:
|
||||
self._safe(self._apply_selected_location, title="Location error")
|
||||
|
||||
def _apply_selected_location(self) -> None:
|
||||
idx = self.cmb_locations.currentIndex()
|
||||
if idx < 0:
|
||||
return
|
||||
|
||||
iso = str(self.cmb_locations.currentData() or "").strip().upper()
|
||||
target = str(self.cmb_locations.currentData(LOCATION_TARGET_ROLE) or "").strip()
|
||||
label = str(self.cmb_locations.currentText() or "").strip()
|
||||
if not target:
|
||||
target = iso
|
||||
if not iso or not target:
|
||||
return
|
||||
|
||||
desired = (self._vpn_desired_location or "").strip().lower()
|
||||
if desired and desired in (iso.lower(), target.lower()):
|
||||
return
|
||||
|
||||
self.lbl_locations_meta.setText(f"Applying location {target}...")
|
||||
self.lbl_locations_meta.setStyleSheet("color: orange;")
|
||||
|
||||
self._start_vpn_location_switching(target)
|
||||
self.refresh_login_banner()
|
||||
QApplication.processEvents()
|
||||
|
||||
try:
|
||||
self.ctrl.vpn_set_location(target=target, iso=iso, label=label)
|
||||
except Exception:
|
||||
self._stop_vpn_location_switching()
|
||||
self.refresh_login_banner()
|
||||
raise
|
||||
|
||||
self.ctrl.log_gui(f"VPN location set to {target} ({iso})")
|
||||
self._vpn_desired_location = target
|
||||
self.refresh_vpn_tab()
|
||||
self._trigger_vpn_egress_refresh(reason=f"location switch to {target}")
|
||||
|
||||
# ---- Routes actions ------------------------------------------------
|
||||
|
||||
def on_routes_action(
|
||||
self, action: Literal["start", "stop", "restart"]
|
||||
) -> None:
|
||||
def work():
|
||||
res = self.ctrl.routes_service_action(action)
|
||||
self._set_text(self.txt_routes, res.pretty_text or str(res))
|
||||
self.refresh_status_tab()
|
||||
self._safe(work, title="Routes error")
|
||||
|
||||
def _append_routes_log(self, msg: str) -> None:
|
||||
line = (msg or "").strip()
|
||||
if not line:
|
||||
return
|
||||
self._append_text(self.txt_routes, line + "\n")
|
||||
self.ctrl.log_gui(line)
|
||||
|
||||
def on_open_traffic_settings(self) -> None:
|
||||
def work():
|
||||
def refresh_all_traffic() -> None:
|
||||
self.refresh_routes_tab()
|
||||
self.refresh_status_tab()
|
||||
|
||||
dlg = TrafficModeDialog(
|
||||
self.ctrl,
|
||||
log_cb=self._append_routes_log,
|
||||
refresh_cb=refresh_all_traffic,
|
||||
parent=self,
|
||||
)
|
||||
dlg.exec()
|
||||
refresh_all_traffic()
|
||||
self._safe(work, title="Traffic mode dialog error")
|
||||
|
||||
def on_test_traffic_mode(self) -> None:
|
||||
def work():
|
||||
view = self.ctrl.traffic_mode_test()
|
||||
msg = (
|
||||
f"Traffic mode test: desired={view.desired_mode}, applied={view.applied_mode}, "
|
||||
f"iface={view.active_iface or '-'}, probe_ok={view.probe_ok}, "
|
||||
f"healthy={view.healthy}, auto_local_bypass={view.auto_local_bypass}, "
|
||||
f"bypass_routes={view.bypass_candidates}, overrides={view.overrides_applied}, "
|
||||
f"cgroup_uids={view.cgroup_resolved_uids}, cgroup_warning={view.cgroup_warning or '-'}, "
|
||||
f"message={view.message}, probe={view.probe_message}"
|
||||
)
|
||||
self._append_routes_log(msg)
|
||||
self.refresh_routes_tab()
|
||||
self.refresh_status_tab()
|
||||
self._safe(work, title="Traffic mode test error")
|
||||
|
||||
def on_routes_precheck_debug(self) -> None:
|
||||
def work():
|
||||
res = self.ctrl.routes_precheck_debug(run_now=True)
|
||||
txt = (res.pretty_text or "").strip()
|
||||
if res.ok:
|
||||
QMessageBox.information(self, "Resolve precheck debug", txt or "OK")
|
||||
else:
|
||||
QMessageBox.critical(self, "Resolve precheck debug", txt or "ERROR")
|
||||
self.refresh_routes_tab()
|
||||
self.refresh_status_tab()
|
||||
self.refresh_trace_tab()
|
||||
self._safe(work, title="Resolve precheck debug error")
|
||||
|
||||
def on_toggle_timer(self) -> None:
|
||||
def work():
|
||||
enabled = self.chk_timer.isChecked()
|
||||
res = self.ctrl.routes_timer_set(enabled)
|
||||
self.ctrl.log_gui(f"Routes timer set to {enabled}")
|
||||
self._set_text(self.txt_routes, res.pretty_text or str(res))
|
||||
self.refresh_routes_tab()
|
||||
self._safe(work, title="Timer error")
|
||||
|
||||
def on_fix_policy_route(self) -> None:
|
||||
def work():
|
||||
res = self.ctrl.routes_fix_policy_route()
|
||||
self._set_text(self.txt_routes, res.pretty_text or str(res))
|
||||
self.refresh_status_tab()
|
||||
self._safe(work, title="Policy route error")
|
||||
|
||||
# ---- DNS actions ---------------------------------------------------
|
||||
|
||||
def _schedule_dns_autosave(self, _text: str = "") -> None:
|
||||
if self._dns_ui_refresh:
|
||||
return
|
||||
self.dns_save_timer.start()
|
||||
|
||||
def _apply_dns_autosave(self) -> None:
|
||||
def work():
|
||||
if self._dns_ui_refresh:
|
||||
return
|
||||
self.ctrl.dns_mode_set(
|
||||
self.chk_dns_via_smartdns.isChecked(),
|
||||
self.ent_smartdns_addr.text().strip(),
|
||||
)
|
||||
self.ctrl.log_gui("DNS settings autosaved")
|
||||
self._safe(work, title="DNS save error")
|
||||
|
||||
def on_open_dns_benchmark(self) -> None:
|
||||
def work():
|
||||
dlg = DNSBenchmarkDialog(
|
||||
self.ctrl,
|
||||
settings=self._ui_settings,
|
||||
refresh_cb=self.refresh_dns_tab,
|
||||
parent=self,
|
||||
)
|
||||
dlg.exec()
|
||||
self.refresh_dns_tab()
|
||||
self._safe(work, title="DNS benchmark error")
|
||||
|
||||
def on_dns_mode_toggle(self) -> None:
|
||||
def work():
|
||||
via = self.chk_dns_via_smartdns.isChecked()
|
||||
self.ctrl.dns_mode_set(via, self.ent_smartdns_addr.text().strip())
|
||||
mode = "hybrid_wildcard" if via else "direct"
|
||||
self.ctrl.log_gui(f"DNS mode changed: mode={mode}")
|
||||
self.refresh_dns_tab()
|
||||
self._safe(work, title="DNS mode error")
|
||||
|
||||
def on_smartdns_unit_toggle(self) -> None:
|
||||
def work():
|
||||
enable = self.chk_dns_unit_relay.isChecked()
|
||||
action = "start" if enable else "stop"
|
||||
self.ctrl.smartdns_service_action(action)
|
||||
self.ctrl.log_smartdns(f"SmartDNS unit action from GUI: {action}")
|
||||
self.refresh_dns_tab()
|
||||
self.refresh_status_tab()
|
||||
self._safe(work, title="SmartDNS error")
|
||||
|
||||
def on_smartdns_runtime_toggle(self) -> None:
|
||||
def work():
|
||||
if self._dns_ui_refresh:
|
||||
return
|
||||
enable = self.chk_dns_runtime_nftset.isChecked()
|
||||
st = self.ctrl.smartdns_runtime_set(enabled=enable, restart=True)
|
||||
self.ctrl.log_smartdns(
|
||||
f"SmartDNS runtime accelerator set from GUI: enabled={enable} changed={st.changed} restarted={st.restarted} source={st.wildcard_source}"
|
||||
)
|
||||
self.refresh_dns_tab()
|
||||
self.refresh_trace_tab()
|
||||
self._safe(work, title="SmartDNS runtime error")
|
||||
|
||||
def on_smartdns_prewarm(self) -> None:
|
||||
def work():
|
||||
aggressive = bool(self.chk_routes_prewarm_aggressive.isChecked())
|
||||
result = self.ctrl.smartdns_prewarm(aggressive_subs=aggressive)
|
||||
mode_txt = "aggressive_subs=on" if aggressive else "aggressive_subs=off"
|
||||
self.ctrl.log_smartdns(f"SmartDNS prewarm requested from GUI: {mode_txt}")
|
||||
txt = (result.pretty_text or "").strip()
|
||||
if result.ok:
|
||||
QMessageBox.information(self, "SmartDNS prewarm", txt or "OK")
|
||||
else:
|
||||
QMessageBox.critical(self, "SmartDNS prewarm", txt or "ERROR")
|
||||
self.refresh_trace_tab()
|
||||
self._safe(work, title="SmartDNS prewarm error")
|
||||
|
||||
def _update_prewarm_mode_label(self, _state: int = 0) -> None:
|
||||
aggressive = bool(self.chk_routes_prewarm_aggressive.isChecked())
|
||||
if aggressive:
|
||||
self.lbl_routes_prewarm_mode.setText("Prewarm mode: aggressive (subs enabled)")
|
||||
self.lbl_routes_prewarm_mode.setStyleSheet("color: orange;")
|
||||
else:
|
||||
self.lbl_routes_prewarm_mode.setText("Prewarm mode: wildcard-only")
|
||||
self.lbl_routes_prewarm_mode.setStyleSheet("color: gray;")
|
||||
|
||||
def _on_prewarm_aggressive_changed(self, _state: int = 0) -> None:
|
||||
self._update_prewarm_mode_label(_state)
|
||||
self._save_ui_preferences()
|
||||
|
||||
# ---- Domains actions -----------------------------------------------
|
||||
|
||||
def on_domains_load(self) -> None:
|
||||
def work():
|
||||
name = self._get_selected_domains_file()
|
||||
content, source, path = self._load_file_content(name)
|
||||
is_readonly = name in ("last-ips-map-direct", "last-ips-map-wildcard", "wildcard-observed-hosts")
|
||||
self.txt_domains.setReadOnly(is_readonly)
|
||||
self.btn_domains_save.setEnabled(not is_readonly)
|
||||
self._set_text(self.txt_domains, content)
|
||||
ro = "read-only" if is_readonly else "editable"
|
||||
self.lbl_domains_info.setText(f"{name} ({source}, {ro}) [{path}]")
|
||||
self._safe(work, title="Domains load error")
|
||||
|
||||
def on_domains_save(self) -> None:
|
||||
def work():
|
||||
name = self._get_selected_domains_file()
|
||||
content = self.txt_domains.toPlainText()
|
||||
self._save_file_content(name, content)
|
||||
self.ctrl.log_gui(f"Domains file saved: {name}")
|
||||
self._safe(work, title="Domains save error")
|
||||
|
||||
# ---- close event ---------------------------------------------------
|
||||
|
||||
def closeEvent(self, event) -> None: # pragma: no cover - GUI
|
||||
try:
|
||||
self._save_ui_preferences()
|
||||
self._login_flow_autopoll_stop()
|
||||
self.loc_typeahead_timer.stop()
|
||||
if self.locations_thread:
|
||||
self.locations_thread.quit()
|
||||
self.locations_thread.wait(1500)
|
||||
if self.events_thread:
|
||||
self.events_thread.stop()
|
||||
self.events_thread.wait(1500)
|
||||
finally:
|
||||
super().closeEvent(event)
|
||||
427
selective-vpn-gui/main_window/runtime_refresh_mixin.py
Normal file
427
selective-vpn-gui/main_window/runtime_refresh_mixin.py
Normal file
@@ -0,0 +1,427 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
from dashboard_controller import TraceMode
|
||||
from main_window.workers import EventThread, LocationsThread
|
||||
|
||||
|
||||
class RuntimeRefreshMixin:
|
||||
def _start_events_stream(self) -> None:
|
||||
if self.events_thread:
|
||||
return
|
||||
self.events_thread = EventThread(self.ctrl, self)
|
||||
self.events_thread.eventReceived.connect(self._handle_event)
|
||||
self.events_thread.error.connect(self._handle_event_error)
|
||||
self.events_thread.start()
|
||||
|
||||
@QtCore.Slot(object)
|
||||
def _handle_event(self, ev) -> None:
|
||||
try:
|
||||
kinds = self.ctrl.classify_event(ev)
|
||||
except Exception:
|
||||
kinds = []
|
||||
|
||||
# Отдельно ловим routes_nft_progress, чтобы обновить лейбл прогресса.
|
||||
try:
|
||||
k = (getattr(ev, "kind", "") or "").strip().lower()
|
||||
except Exception:
|
||||
k = ""
|
||||
|
||||
if k == "routes_nft_progress":
|
||||
try:
|
||||
prog_view = self.ctrl.routes_nft_progress_from_event(ev)
|
||||
self._update_routes_progress_label(prog_view)
|
||||
except Exception:
|
||||
# не роняем UI, просто игнор
|
||||
pass
|
||||
|
||||
# Простая стратегия: триггерить существующие refresh-функции.
|
||||
if "status" in kinds:
|
||||
self.refresh_status_tab()
|
||||
if "login" in kinds:
|
||||
self.refresh_login_banner()
|
||||
if "vpn" in kinds:
|
||||
self.refresh_vpn_tab()
|
||||
if "routes" in kinds:
|
||||
self.refresh_routes_tab()
|
||||
if "dns" in kinds:
|
||||
self.refresh_dns_tab()
|
||||
if "transport" in kinds:
|
||||
self.refresh_singbox_tab()
|
||||
self._refresh_selected_transport_health_live(silent=True)
|
||||
if "trace" in kinds:
|
||||
self.refresh_trace_tab()
|
||||
|
||||
|
||||
@QtCore.Slot(str)
|
||||
def _handle_event_error(self, msg: str) -> None:
|
||||
# Логируем в trace, UI не блокируем.
|
||||
try:
|
||||
self.ctrl.log_gui(f"[sse-error] {msg}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------- REFRESH ----------------
|
||||
|
||||
def refresh_everything(self) -> None:
|
||||
self.refresh_login_banner()
|
||||
self.refresh_status_tab()
|
||||
self.refresh_vpn_tab()
|
||||
self.refresh_singbox_tab()
|
||||
self.refresh_routes_tab()
|
||||
self.refresh_dns_tab()
|
||||
self.refresh_domains_tab()
|
||||
self.refresh_trace_tab()
|
||||
|
||||
def refresh_login_banner(self) -> None:
|
||||
def work():
|
||||
view = self.ctrl.get_login_view()
|
||||
|
||||
self._set_auth_button(view.logged_in)
|
||||
|
||||
if self._vpn_switching_active:
|
||||
if self._is_vpn_switching_expired():
|
||||
self._stop_vpn_location_switching()
|
||||
else:
|
||||
target = (self._vpn_switching_target or "").strip()
|
||||
msg = "AdGuard VPN: Switching location..."
|
||||
if target:
|
||||
msg = f"AdGuard VPN: Switching location to {target}..."
|
||||
self.btn_login_banner.setText(msg)
|
||||
self.btn_login_banner.setStyleSheet(
|
||||
"text-align: left; border: none; color: #d4a200;"
|
||||
)
|
||||
return
|
||||
|
||||
self.btn_login_banner.setText(view.text)
|
||||
# Принудительно: зелёный если залогинен, серый если нет
|
||||
color = "green" if view.logged_in else "gray"
|
||||
base_style = "text-align: left; border: none;"
|
||||
self.btn_login_banner.setStyleSheet(
|
||||
f"{base_style} color: {color};"
|
||||
)
|
||||
|
||||
self._safe(work, title="Login state error")
|
||||
|
||||
def refresh_status_tab(self) -> None:
|
||||
def work():
|
||||
view = self.ctrl.get_status_overview()
|
||||
self.st_timestamp.setText(view.timestamp)
|
||||
self.st_counts.setText(view.counts)
|
||||
self.st_iface.setText(view.iface_table_mark)
|
||||
|
||||
self._set_status_label_color(
|
||||
self.st_route, view.policy_route, kind="policy"
|
||||
)
|
||||
self._set_status_label_color(
|
||||
self.st_routes_service, view.routes_service, kind="service"
|
||||
)
|
||||
self._set_status_label_color(
|
||||
self.st_smartdns_service, view.smartdns_service, kind="service"
|
||||
)
|
||||
self._set_status_label_color(
|
||||
self.st_vpn_service, view.vpn_service, kind="service"
|
||||
)
|
||||
|
||||
self._safe(work, title="Status error")
|
||||
|
||||
def refresh_vpn_tab(self) -> None:
|
||||
def work():
|
||||
view = self.ctrl.vpn_status_view()
|
||||
prev_desired = (self._vpn_desired_location_last_seen or "").strip().lower()
|
||||
self._vpn_desired_location = (view.desired_location or "").strip()
|
||||
current_desired = (self._vpn_desired_location or "").strip().lower()
|
||||
self._vpn_desired_location_last_seen = self._vpn_desired_location
|
||||
txt = []
|
||||
if view.desired_location:
|
||||
txt.append(f"Desired location: {view.desired_location}")
|
||||
if view.pretty_text:
|
||||
txt.append(view.pretty_text.rstrip())
|
||||
self._set_text(self.txt_vpn, "\n".join(txt).strip() + "\n")
|
||||
|
||||
auto_view = self.ctrl.vpn_autoconnect_view()
|
||||
self.btn_autoconnect_toggle.setText(
|
||||
"Disable autoconnect" if auto_view.enabled else "Enable autoconnect"
|
||||
)
|
||||
self.lbl_autoconnect_state.setText(auto_view.unit_text)
|
||||
self.lbl_autoconnect_state.setStyleSheet(
|
||||
f"color: {auto_view.color};"
|
||||
)
|
||||
|
||||
vpn_egress = self._refresh_egress_identity_scope(
|
||||
"adguardvpn",
|
||||
trigger_refresh=True,
|
||||
silent=True,
|
||||
)
|
||||
self._render_vpn_egress_label(vpn_egress)
|
||||
self._maybe_trigger_vpn_egress_refresh_on_autoloop(auto_view.unit_text)
|
||||
if prev_desired and current_desired and prev_desired != current_desired:
|
||||
self._trigger_vpn_egress_refresh(
|
||||
reason=f"desired location changed: {prev_desired} -> {current_desired}"
|
||||
)
|
||||
|
||||
if self._vpn_switching_active:
|
||||
unit_low = (auto_view.unit_text or "").strip().lower()
|
||||
elapsed = self._vpn_switching_elapsed_sec()
|
||||
if any(
|
||||
x in unit_low
|
||||
for x in ("disconnected", "reconnecting", "unknown", "error", "inactive", "failed", "dead")
|
||||
):
|
||||
self._vpn_switching_seen_non_connected = True
|
||||
|
||||
desired_now = (self._vpn_desired_location or "").strip().lower()
|
||||
target_now = (self._vpn_switching_target or "").strip().lower()
|
||||
desired_matches = bool(target_now and desired_now and target_now == desired_now)
|
||||
|
||||
if self._is_vpn_switching_expired():
|
||||
self._stop_vpn_location_switching()
|
||||
elif (
|
||||
"connected" in unit_low
|
||||
and "disconnected" not in unit_low
|
||||
and elapsed >= float(self._vpn_switching_min_visible_sec)
|
||||
and (self._vpn_switching_seen_non_connected or desired_matches)
|
||||
):
|
||||
switched_to = (self._vpn_switching_target or "").strip()
|
||||
self._stop_vpn_location_switching()
|
||||
if switched_to:
|
||||
self._trigger_vpn_egress_refresh(
|
||||
reason=f"location switch completed: {switched_to}"
|
||||
)
|
||||
self.refresh_login_banner()
|
||||
|
||||
self._refresh_locations_async()
|
||||
|
||||
self._safe(work, title="VPN error")
|
||||
|
||||
def refresh_singbox_tab(self) -> None:
|
||||
def work():
|
||||
self.refresh_transport_engines(silent=True)
|
||||
self.refresh_transport_policy_locks(silent=True)
|
||||
self._apply_singbox_profile_controls()
|
||||
self._safe(work, title="SingBox error")
|
||||
|
||||
def _start_vpn_location_switching(self, target: str) -> None:
|
||||
self._vpn_switching_active = True
|
||||
self._vpn_switching_target = str(target or "").strip()
|
||||
self._vpn_switching_started_at = time.monotonic()
|
||||
self._vpn_switching_seen_non_connected = False
|
||||
|
||||
def _stop_vpn_location_switching(self) -> None:
|
||||
self._vpn_switching_active = False
|
||||
self._vpn_switching_target = ""
|
||||
self._vpn_switching_started_at = 0.0
|
||||
self._vpn_switching_seen_non_connected = False
|
||||
|
||||
def _is_vpn_switching_expired(self) -> bool:
|
||||
if not self._vpn_switching_active:
|
||||
return False
|
||||
started = float(self._vpn_switching_started_at or 0.0)
|
||||
if started <= 0:
|
||||
return False
|
||||
return (time.monotonic() - started) >= float(self._vpn_switching_timeout_sec)
|
||||
|
||||
def _vpn_switching_elapsed_sec(self) -> float:
|
||||
if not self._vpn_switching_active:
|
||||
return 0.0
|
||||
started = float(self._vpn_switching_started_at or 0.0)
|
||||
if started <= 0:
|
||||
return 0.0
|
||||
return max(0.0, time.monotonic() - started)
|
||||
|
||||
def _refresh_locations_async(self, force_refresh: bool = False) -> None:
|
||||
if self.locations_thread and self.locations_thread.isRunning():
|
||||
self._locations_refresh_pending = True
|
||||
if force_refresh:
|
||||
self._locations_force_refresh_pending = True
|
||||
return
|
||||
|
||||
run_force_refresh = bool(force_refresh or self._locations_force_refresh_pending)
|
||||
self._locations_refresh_pending = False
|
||||
self._locations_force_refresh_pending = False
|
||||
self.locations_thread = LocationsThread(
|
||||
self.ctrl,
|
||||
force_refresh=run_force_refresh,
|
||||
parent=self,
|
||||
)
|
||||
self.locations_thread.loaded.connect(self._on_locations_loaded)
|
||||
self.locations_thread.error.connect(self._on_locations_error)
|
||||
self.locations_thread.finished.connect(self._on_locations_finished)
|
||||
self.locations_thread.start()
|
||||
|
||||
@QtCore.Slot(object)
|
||||
def _on_locations_loaded(self, state) -> None:
|
||||
try:
|
||||
self._apply_locations_state(state)
|
||||
except Exception as e:
|
||||
self._on_locations_error(str(e))
|
||||
|
||||
@QtCore.Slot(str)
|
||||
def _on_locations_error(self, msg: str) -> None:
|
||||
msg = (msg or "").strip()
|
||||
if not msg:
|
||||
msg = "failed to load locations"
|
||||
self.lbl_locations_meta.setText(f"Locations: {msg}")
|
||||
self.lbl_locations_meta.setStyleSheet("color: red;")
|
||||
try:
|
||||
self.ctrl.log_gui(f"[vpn-locations] {msg}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@QtCore.Slot()
|
||||
def _on_locations_finished(self) -> None:
|
||||
self.locations_thread = None
|
||||
if self._locations_refresh_pending:
|
||||
force_refresh = self._locations_force_refresh_pending
|
||||
self._locations_refresh_pending = False
|
||||
self._locations_force_refresh_pending = False
|
||||
self._refresh_locations_async(force_refresh=force_refresh)
|
||||
|
||||
def _apply_locations_state(self, state) -> None:
|
||||
all_items: list[tuple[str, str, str, str, int]] = []
|
||||
for loc in getattr(state, "locations", []) or []:
|
||||
iso = str(getattr(loc, "iso", "") or "").strip().upper()
|
||||
label = str(getattr(loc, "label", "") or "").strip()
|
||||
target = str(getattr(loc, "target", "") or "").strip()
|
||||
if not iso or not label:
|
||||
continue
|
||||
if not target:
|
||||
target = iso
|
||||
name, ping = self._location_name_ping(label, iso, target)
|
||||
all_items.append((label, iso, target, name, ping))
|
||||
|
||||
self._all_locations = all_items
|
||||
self._apply_location_search_filter()
|
||||
self._render_locations_meta(state)
|
||||
|
||||
def _render_locations_meta(self, state) -> None:
|
||||
parts = []
|
||||
color = "gray"
|
||||
|
||||
updated_at = str(getattr(state, "updated_at", "") or "").strip()
|
||||
stale = bool(getattr(state, "stale", False))
|
||||
refreshing = bool(getattr(state, "refresh_in_progress", False))
|
||||
last_error = str(getattr(state, "last_error", "") or "").strip()
|
||||
next_retry = str(getattr(state, "next_retry_at", "") or "").strip()
|
||||
|
||||
if refreshing:
|
||||
parts.append("refreshing")
|
||||
color = "orange"
|
||||
if updated_at:
|
||||
parts.append(f"updated: {updated_at}")
|
||||
else:
|
||||
parts.append("updated: n/a")
|
||||
if stale:
|
||||
parts.append("stale cache")
|
||||
color = "orange"
|
||||
if last_error:
|
||||
cut = last_error if len(last_error) <= 120 else last_error[:117] + "..."
|
||||
parts.append(f"last error: {cut}")
|
||||
color = "red" if not refreshing else "orange"
|
||||
if next_retry:
|
||||
parts.append(f"next retry: {next_retry}")
|
||||
|
||||
self.lbl_locations_meta.setText(" | ".join(parts))
|
||||
self.lbl_locations_meta.setStyleSheet(f"color: {color};")
|
||||
|
||||
def refresh_routes_tab(self) -> None:
|
||||
def work():
|
||||
timer_enabled = self.ctrl.routes_timer_enabled()
|
||||
self.chk_timer.blockSignals(True)
|
||||
self.chk_timer.setChecked(bool(timer_enabled))
|
||||
self.chk_timer.blockSignals(False)
|
||||
|
||||
t = self.ctrl.traffic_mode_view()
|
||||
self._set_traffic_mode_state(
|
||||
t.desired_mode,
|
||||
t.applied_mode,
|
||||
t.preferred_iface,
|
||||
bool(t.advanced_active),
|
||||
bool(t.auto_local_bypass),
|
||||
bool(t.auto_local_active),
|
||||
bool(t.ingress_reply_bypass),
|
||||
bool(t.ingress_reply_active),
|
||||
int(t.bypass_candidates),
|
||||
int(t.overrides_applied),
|
||||
int(t.cgroup_resolved_uids),
|
||||
t.cgroup_warning,
|
||||
bool(t.healthy),
|
||||
bool(t.ingress_rule_present),
|
||||
bool(t.ingress_nft_active),
|
||||
bool(t.probe_ok),
|
||||
t.probe_message,
|
||||
t.active_iface,
|
||||
t.iface_reason,
|
||||
t.message,
|
||||
)
|
||||
rs = self.ctrl.routes_resolve_summary_view()
|
||||
self.lbl_routes_resolve_summary.setText(rs.text)
|
||||
self.lbl_routes_resolve_summary.setStyleSheet(f"color: {rs.color};")
|
||||
self.lbl_routes_recheck_summary.setText(rs.recheck_text)
|
||||
self.lbl_routes_recheck_summary.setStyleSheet(f"color: {rs.recheck_color};")
|
||||
self._safe(work, title="Routes error")
|
||||
|
||||
def refresh_dns_tab(self) -> None:
|
||||
def work():
|
||||
self._dns_ui_refresh = True
|
||||
try:
|
||||
pool = self.ctrl.dns_upstream_pool_view()
|
||||
self._set_dns_resolver_summary(getattr(pool, "items", []))
|
||||
|
||||
st = self.ctrl.dns_status_view()
|
||||
self.ent_smartdns_addr.setText(st.smartdns_addr or "")
|
||||
|
||||
mode = (getattr(st, "mode", "") or "").strip().lower()
|
||||
if mode in ("hybrid_wildcard", "hybrid"):
|
||||
hybrid_enabled = True
|
||||
mode = "hybrid_wildcard"
|
||||
else:
|
||||
hybrid_enabled = False
|
||||
mode = "direct"
|
||||
|
||||
self.chk_dns_via_smartdns.blockSignals(True)
|
||||
self.chk_dns_via_smartdns.setChecked(hybrid_enabled)
|
||||
self.chk_dns_via_smartdns.blockSignals(False)
|
||||
|
||||
unit_state = (st.unit_state or "unknown").strip().lower()
|
||||
unit_active = unit_state == "active"
|
||||
self.chk_dns_unit_relay.blockSignals(True)
|
||||
self.chk_dns_unit_relay.setChecked(unit_active)
|
||||
self.chk_dns_unit_relay.blockSignals(False)
|
||||
|
||||
self.chk_dns_runtime_nftset.blockSignals(True)
|
||||
self.chk_dns_runtime_nftset.setChecked(bool(getattr(st, "runtime_nftset", True)))
|
||||
self.chk_dns_runtime_nftset.blockSignals(False)
|
||||
self._set_dns_unit_relay_state(unit_active)
|
||||
self._set_dns_runtime_state(
|
||||
bool(getattr(st, "runtime_nftset", True)),
|
||||
str(getattr(st, "wildcard_source", "") or ""),
|
||||
str(getattr(st, "runtime_config_error", "") or ""),
|
||||
)
|
||||
self._set_dns_mode_state(mode)
|
||||
finally:
|
||||
self._dns_ui_refresh = False
|
||||
self._safe(work, title="DNS error")
|
||||
|
||||
def refresh_domains_tab(self) -> None:
|
||||
def work():
|
||||
# reload currently selected file
|
||||
self.on_domains_load()
|
||||
self._safe(work, title="Domains error")
|
||||
|
||||
def refresh_trace_tab(self) -> None:
|
||||
def work():
|
||||
if self.radio_trace_gui.isChecked():
|
||||
mode: TraceMode = "gui"
|
||||
elif self.radio_trace_smartdns.isChecked():
|
||||
mode = "smartdns"
|
||||
else:
|
||||
mode = "full"
|
||||
dump = self.ctrl.trace_view(mode)
|
||||
text = "\n".join(dump.lines).rstrip()
|
||||
if dump.lines:
|
||||
text += "\n"
|
||||
self._set_text(self.txt_trace, text, preserve_scroll=True)
|
||||
self._safe(work, title="Trace error")
|
||||
481
selective-vpn-gui/main_window/runtime_state_mixin.py
Normal file
481
selective-vpn-gui/main_window/runtime_state_mixin.py
Normal file
@@ -0,0 +1,481 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
from main_window.constants import LoginPage
|
||||
|
||||
|
||||
class RuntimeStateMixin:
|
||||
def _get_selected_domains_file(self) -> str:
|
||||
item = self.lst_files.currentItem()
|
||||
return item.text() if item is not None else "bases"
|
||||
|
||||
def _load_file_content(self, name: str) -> tuple[str, str, str]:
|
||||
api_map = {
|
||||
"bases": "bases",
|
||||
"meta-special": "meta",
|
||||
"subs": "subs",
|
||||
"static-ips": "static",
|
||||
"last-ips-map-direct": "last-ips-map-direct",
|
||||
"last-ips-map-wildcard": "last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts": "wildcard-observed-hosts",
|
||||
"smartdns.conf": "smartdns",
|
||||
}
|
||||
if name in api_map:
|
||||
f = self.ctrl.domains_file_load(api_map[name])
|
||||
content = f.content or ""
|
||||
source = getattr(f, "source", "") or "api"
|
||||
if name == "smartdns.conf":
|
||||
path = "/var/lib/selective-vpn/smartdns-wildcards.json -> /etc/selective-vpn/smartdns.conf"
|
||||
elif name == "last-ips-map-direct":
|
||||
path = "/var/lib/selective-vpn/last-ips-map-direct.txt (artifact: agvpn4)"
|
||||
elif name == "last-ips-map-wildcard":
|
||||
path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (artifact: agvpn_dyn4)"
|
||||
elif name == "wildcard-observed-hosts":
|
||||
path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (derived unique hosts)"
|
||||
else:
|
||||
path = f"/etc/selective-vpn/domains/{name}.txt"
|
||||
return content, source, path
|
||||
return "", "unknown", name
|
||||
|
||||
def _save_file_content(self, name: str, content: str) -> None:
|
||||
api_map = {
|
||||
"bases": "bases",
|
||||
"meta-special": "meta",
|
||||
"subs": "subs",
|
||||
"static-ips": "static",
|
||||
"smartdns.conf": "smartdns",
|
||||
}
|
||||
if name in api_map:
|
||||
self.ctrl.domains_file_save(api_map[name], content)
|
||||
return
|
||||
|
||||
def _show_vpn_page(self, which: LoginPage) -> None:
|
||||
self.vpn_stack.setCurrentIndex(1 if which == "login" else 0)
|
||||
|
||||
def _set_auth_button(self, logged: bool) -> None:
|
||||
self.btn_auth.setText("Logout" if logged else "Login")
|
||||
|
||||
def _set_status_label_color(self, lbl: QLabel, text: str, *, kind: str) -> None:
|
||||
"""Подкраска Policy route / services."""
|
||||
lbl.setText(text)
|
||||
low = (text or "").lower()
|
||||
color = "black"
|
||||
if kind == "policy":
|
||||
if "ok" in low and "missing" not in low and "error" not in low:
|
||||
color = "green"
|
||||
elif any(w in low for w in ("missing", "error", "failed")):
|
||||
color = "red"
|
||||
else:
|
||||
color = "orange"
|
||||
else: # service
|
||||
if any(w in low for w in ("failed", "error", "unknown", "inactive", "dead")):
|
||||
color = "red"
|
||||
elif "active" in low or "running" in low:
|
||||
color = "green"
|
||||
else:
|
||||
color = "orange"
|
||||
lbl.setStyleSheet(f"color: {color};")
|
||||
|
||||
def _set_dns_unit_relay_state(self, enabled: bool) -> None:
|
||||
txt = "SmartDNS unit relay: ON" if enabled else "SmartDNS unit relay: OFF"
|
||||
color = "green" if enabled else "red"
|
||||
self.chk_dns_unit_relay.setText(txt)
|
||||
self.chk_dns_unit_relay.setStyleSheet(f"color: {color};")
|
||||
|
||||
def _set_dns_runtime_state(self, enabled: bool, source: str, cfg_error: str = "") -> None:
|
||||
txt = "SmartDNS runtime accelerator (nftset -> agvpn_dyn4): ON" if enabled else "SmartDNS runtime accelerator (nftset -> agvpn_dyn4): OFF"
|
||||
color = "green" if enabled else "orange"
|
||||
self.chk_dns_runtime_nftset.setText(txt)
|
||||
self.chk_dns_runtime_nftset.setStyleSheet(f"color: {color};")
|
||||
|
||||
src = (source or "").strip().lower()
|
||||
if src == "both":
|
||||
src_txt = "Wildcard source: both (resolver + smartdns_runtime)"
|
||||
src_color = "green"
|
||||
elif src == "smartdns_runtime":
|
||||
src_txt = "Wildcard source: smartdns_runtime"
|
||||
src_color = "orange"
|
||||
else:
|
||||
src_txt = "Wildcard source: resolver"
|
||||
src_color = "gray"
|
||||
if cfg_error:
|
||||
src_txt = f"{src_txt} | runtime cfg: {cfg_error}"
|
||||
src_color = "orange"
|
||||
self.lbl_dns_wildcard_source.setText(src_txt)
|
||||
self.lbl_dns_wildcard_source.setStyleSheet(f"color: {src_color};")
|
||||
|
||||
def _set_dns_mode_state(self, mode: str) -> None:
|
||||
low = (mode or "").strip().lower()
|
||||
if low in ("hybrid_wildcard", "hybrid"):
|
||||
txt = "Resolver mode: hybrid wildcard (SmartDNS for wildcard domains)"
|
||||
color = "green"
|
||||
elif low == "direct":
|
||||
txt = "Resolver mode: direct upstreams"
|
||||
color = "red"
|
||||
else:
|
||||
txt = "Resolver mode: unknown"
|
||||
color = "orange"
|
||||
self.lbl_dns_mode_state.setText(txt)
|
||||
self.lbl_dns_mode_state.setStyleSheet(f"color: {color};")
|
||||
|
||||
def _set_dns_resolver_summary(self, pool_items) -> None:
|
||||
active = []
|
||||
total = 0
|
||||
for item in pool_items or []:
|
||||
addr = str(getattr(item, "addr", "") or "").strip()
|
||||
if not addr:
|
||||
continue
|
||||
total += 1
|
||||
if bool(getattr(item, "enabled", False)):
|
||||
active.append(addr)
|
||||
applied = len(active)
|
||||
if applied > 12:
|
||||
applied = 12
|
||||
if not active:
|
||||
text = f"Resolver upstreams: active=0/{total} (empty set)"
|
||||
else:
|
||||
preview = ", ".join(active[:4])
|
||||
if len(active) > 4:
|
||||
preview += f", +{len(active)-4} more"
|
||||
text = f"Resolver upstreams: active={len(active)}/{total}, applied={applied}/12 [{preview}]"
|
||||
self.lbl_dns_resolver_upstreams.setText(text)
|
||||
self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;")
|
||||
|
||||
avg_ms = self._ui_settings.value("dns_benchmark/last_avg_ms", None)
|
||||
ok = self._ui_settings.value("dns_benchmark/last_ok", None)
|
||||
fail = self._ui_settings.value("dns_benchmark/last_fail", None)
|
||||
timeout = self._ui_settings.value("dns_benchmark/last_timeout", None)
|
||||
if avg_ms is None or ok is None or fail is None:
|
||||
self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet")
|
||||
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
|
||||
return
|
||||
try:
|
||||
avg = int(avg_ms)
|
||||
ok_i = int(ok)
|
||||
fail_i = int(fail)
|
||||
timeout_i = int(timeout or 0)
|
||||
except Exception:
|
||||
self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet")
|
||||
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
|
||||
return
|
||||
color = "green" if avg < 200 else ("#b58900" if avg <= 400 else "red")
|
||||
if timeout_i > 0 and color != "red":
|
||||
color = "#b58900"
|
||||
self.lbl_dns_resolver_health.setText(
|
||||
f"Resolver health: avg={avg}ms ok={ok_i} fail={fail_i} timeout={timeout_i}"
|
||||
)
|
||||
self.lbl_dns_resolver_health.setStyleSheet(f"color: {color};")
|
||||
|
||||
def _set_traffic_mode_state(
|
||||
self,
|
||||
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,
|
||||
overrides_applied: int,
|
||||
cgroup_resolved_uids: int,
|
||||
cgroup_warning: str,
|
||||
healthy: bool,
|
||||
ingress_rule_present: bool,
|
||||
ingress_nft_active: bool,
|
||||
probe_ok: bool,
|
||||
probe_message: str,
|
||||
active_iface: str,
|
||||
iface_reason: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
desired = (desired_mode or "").strip().lower() or "selective"
|
||||
applied = (applied_mode or "").strip().lower() or "direct"
|
||||
|
||||
if healthy:
|
||||
color = "green"
|
||||
health_txt = "OK"
|
||||
else:
|
||||
color = "red"
|
||||
health_txt = "MISMATCH"
|
||||
|
||||
text = f"Traffic mode: {desired} (applied: {applied}) [{health_txt}]"
|
||||
diag_parts = []
|
||||
diag_parts.append(f"preferred={preferred_iface or 'auto'}")
|
||||
diag_parts.append(
|
||||
f"advanced={'on' if advanced_active else 'off'}"
|
||||
)
|
||||
diag_parts.append(
|
||||
f"auto_local={'on' if auto_local_bypass else 'off'}"
|
||||
f"({'active' if auto_local_active else 'saved'})"
|
||||
)
|
||||
diag_parts.append(
|
||||
f"ingress_reply={'on' if ingress_reply_bypass else 'off'}"
|
||||
f"({'active' if ingress_reply_active else 'saved'})"
|
||||
)
|
||||
if auto_local_active and bypass_candidates > 0:
|
||||
diag_parts.append(f"bypass_routes={bypass_candidates}")
|
||||
diag_parts.append(f"overrides={overrides_applied}")
|
||||
if cgroup_resolved_uids > 0:
|
||||
diag_parts.append(f"cgroup_uids={cgroup_resolved_uids}")
|
||||
if cgroup_warning:
|
||||
diag_parts.append(f"cgroup_warning={cgroup_warning}")
|
||||
if active_iface:
|
||||
diag_parts.append(f"iface={active_iface}")
|
||||
if iface_reason:
|
||||
diag_parts.append(f"source={iface_reason}")
|
||||
diag_parts.append(
|
||||
f"ingress_diag=rule:{'ok' if ingress_rule_present else 'off'}"
|
||||
f"/nft:{'ok' if ingress_nft_active else 'off'}"
|
||||
)
|
||||
diag_parts.append(f"probe={'ok' if probe_ok else 'fail'}")
|
||||
if probe_message:
|
||||
diag_parts.append(probe_message)
|
||||
if message:
|
||||
diag_parts.append(message)
|
||||
diag = " | ".join(diag_parts) if diag_parts else "—"
|
||||
|
||||
self.lbl_traffic_mode_state.setText(text)
|
||||
self.lbl_traffic_mode_state.setStyleSheet(f"color: {color};")
|
||||
self.lbl_traffic_mode_diag.setText(diag)
|
||||
self.lbl_traffic_mode_diag.setStyleSheet("color: gray;")
|
||||
|
||||
def _update_routes_progress_label(self, view) -> None:
|
||||
"""
|
||||
Обновляет прогресс nft по RoutesNftProgressView.
|
||||
view ожидаем с полями percent, message, active (duck-typing).
|
||||
"""
|
||||
if view is None:
|
||||
# сброс до idle
|
||||
self._routes_progress_last = 0
|
||||
self.routes_progress.setValue(0)
|
||||
self.lbl_routes_progress.setText("NFT: idle")
|
||||
self.lbl_routes_progress.setStyleSheet("color: gray;")
|
||||
return
|
||||
|
||||
# аккуратно ограничим 0..100
|
||||
try:
|
||||
percent = max(0, min(100, int(view.percent)))
|
||||
except Exception:
|
||||
percent = 0
|
||||
|
||||
# не даём прогрессу дёргаться назад, кроме явного сброса (percent==0)
|
||||
if percent == 0:
|
||||
self._routes_progress_last = 0
|
||||
else:
|
||||
percent = max(percent, self._routes_progress_last)
|
||||
self._routes_progress_last = percent
|
||||
|
||||
self.routes_progress.setValue(percent)
|
||||
|
||||
text = f"{percent}% – {view.message}"
|
||||
if not view.active and percent >= 100:
|
||||
color = "green"
|
||||
elif view.active:
|
||||
color = "orange"
|
||||
else:
|
||||
color = "gray"
|
||||
|
||||
self.lbl_routes_progress.setText(text)
|
||||
self.lbl_routes_progress.setStyleSheet(f"color: {color};")
|
||||
|
||||
def _load_ui_preferences(self) -> None:
|
||||
raw = self._ui_settings.value("routes/prewarm_aggressive", False)
|
||||
if isinstance(raw, str):
|
||||
val = raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
else:
|
||||
val = bool(raw)
|
||||
self.chk_routes_prewarm_aggressive.blockSignals(True)
|
||||
self.chk_routes_prewarm_aggressive.setChecked(val)
|
||||
self.chk_routes_prewarm_aggressive.blockSignals(False)
|
||||
self._update_prewarm_mode_label()
|
||||
|
||||
sort_mode = str(self._ui_settings.value("vpn/locations_sort", "ping") or "ping").strip().lower()
|
||||
idx = self.cmb_locations_sort.findData(sort_mode)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.cmb_locations_sort.blockSignals(True)
|
||||
self.cmb_locations_sort.setCurrentIndex(idx)
|
||||
self.cmb_locations_sort.blockSignals(False)
|
||||
|
||||
g_route = str(
|
||||
self._ui_settings.value("singbox/global_routing", "selective") or "selective"
|
||||
).strip().lower()
|
||||
idx = self.cmb_singbox_global_routing.findData(g_route)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.cmb_singbox_global_routing.blockSignals(True)
|
||||
self.cmb_singbox_global_routing.setCurrentIndex(idx)
|
||||
self.cmb_singbox_global_routing.blockSignals(False)
|
||||
|
||||
g_dns = str(
|
||||
self._ui_settings.value("singbox/global_dns", "system_resolver") or "system_resolver"
|
||||
).strip().lower()
|
||||
idx = self.cmb_singbox_global_dns.findData(g_dns)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.cmb_singbox_global_dns.blockSignals(True)
|
||||
self.cmb_singbox_global_dns.setCurrentIndex(idx)
|
||||
self.cmb_singbox_global_dns.blockSignals(False)
|
||||
|
||||
g_kill = str(
|
||||
self._ui_settings.value("singbox/global_killswitch", "on") or "on"
|
||||
).strip().lower()
|
||||
idx = self.cmb_singbox_global_killswitch.findData(g_kill)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.cmb_singbox_global_killswitch.blockSignals(True)
|
||||
self.cmb_singbox_global_killswitch.setCurrentIndex(idx)
|
||||
self.cmb_singbox_global_killswitch.blockSignals(False)
|
||||
|
||||
p_route = str(
|
||||
self._ui_settings.value("singbox/profile_routing", "global") or "global"
|
||||
).strip().lower()
|
||||
idx = self.cmb_singbox_profile_routing.findData(p_route)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.cmb_singbox_profile_routing.blockSignals(True)
|
||||
self.cmb_singbox_profile_routing.setCurrentIndex(idx)
|
||||
self.cmb_singbox_profile_routing.blockSignals(False)
|
||||
|
||||
p_dns = str(
|
||||
self._ui_settings.value("singbox/profile_dns", "global") or "global"
|
||||
).strip().lower()
|
||||
idx = self.cmb_singbox_profile_dns.findData(p_dns)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.cmb_singbox_profile_dns.blockSignals(True)
|
||||
self.cmb_singbox_profile_dns.setCurrentIndex(idx)
|
||||
self.cmb_singbox_profile_dns.blockSignals(False)
|
||||
|
||||
p_kill = str(
|
||||
self._ui_settings.value("singbox/profile_killswitch", "global") or "global"
|
||||
).strip().lower()
|
||||
idx = self.cmb_singbox_profile_killswitch.findData(p_kill)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.cmb_singbox_profile_killswitch.blockSignals(True)
|
||||
self.cmb_singbox_profile_killswitch.setCurrentIndex(idx)
|
||||
self.cmb_singbox_profile_killswitch.blockSignals(False)
|
||||
|
||||
raw = self._ui_settings.value("singbox/profile_use_global_routing", True)
|
||||
use_global_route = (
|
||||
raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
if isinstance(raw, str)
|
||||
else bool(raw)
|
||||
)
|
||||
raw = self._ui_settings.value("singbox/profile_use_global_dns", True)
|
||||
use_global_dns = (
|
||||
raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
if isinstance(raw, str)
|
||||
else bool(raw)
|
||||
)
|
||||
raw = self._ui_settings.value("singbox/profile_use_global_killswitch", True)
|
||||
use_global_kill = (
|
||||
raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
if isinstance(raw, str)
|
||||
else bool(raw)
|
||||
)
|
||||
self.chk_singbox_profile_use_global_routing.blockSignals(True)
|
||||
self.chk_singbox_profile_use_global_routing.setChecked(use_global_route)
|
||||
self.chk_singbox_profile_use_global_routing.blockSignals(False)
|
||||
self.chk_singbox_profile_use_global_dns.blockSignals(True)
|
||||
self.chk_singbox_profile_use_global_dns.setChecked(use_global_dns)
|
||||
self.chk_singbox_profile_use_global_dns.blockSignals(False)
|
||||
self.chk_singbox_profile_use_global_killswitch.blockSignals(True)
|
||||
self.chk_singbox_profile_use_global_killswitch.setChecked(use_global_kill)
|
||||
self.chk_singbox_profile_use_global_killswitch.blockSignals(False)
|
||||
|
||||
raw = self._ui_settings.value("singbox/ui_show_profile_settings", False)
|
||||
show_profile = (
|
||||
raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
if isinstance(raw, str)
|
||||
else bool(raw)
|
||||
)
|
||||
raw = self._ui_settings.value("singbox/ui_show_global_defaults", False)
|
||||
show_global = (
|
||||
raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
if isinstance(raw, str)
|
||||
else bool(raw)
|
||||
)
|
||||
raw = self._ui_settings.value("singbox/ui_show_activity_log", False)
|
||||
show_activity = (
|
||||
raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
if isinstance(raw, str)
|
||||
else bool(raw)
|
||||
)
|
||||
if show_profile and show_global:
|
||||
show_global = False
|
||||
|
||||
self.btn_singbox_toggle_profile_settings.blockSignals(True)
|
||||
self.btn_singbox_toggle_profile_settings.setChecked(show_profile)
|
||||
self.btn_singbox_toggle_profile_settings.blockSignals(False)
|
||||
self.btn_singbox_toggle_global_defaults.blockSignals(True)
|
||||
self.btn_singbox_toggle_global_defaults.setChecked(show_global)
|
||||
self.btn_singbox_toggle_global_defaults.blockSignals(False)
|
||||
self.btn_singbox_toggle_activity.blockSignals(True)
|
||||
self.btn_singbox_toggle_activity.setChecked(show_activity)
|
||||
self.btn_singbox_toggle_activity.blockSignals(False)
|
||||
|
||||
self._apply_singbox_profile_controls()
|
||||
self._apply_singbox_compact_visibility()
|
||||
|
||||
def _save_ui_preferences(self) -> None:
|
||||
self._ui_settings.setValue(
|
||||
"routes/prewarm_aggressive",
|
||||
bool(self.chk_routes_prewarm_aggressive.isChecked()),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"vpn/locations_sort",
|
||||
str(self.cmb_locations_sort.currentData() or "ping"),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"singbox/global_routing",
|
||||
str(self.cmb_singbox_global_routing.currentData() or "selective"),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"singbox/global_dns",
|
||||
str(self.cmb_singbox_global_dns.currentData() or "system_resolver"),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"singbox/global_killswitch",
|
||||
str(self.cmb_singbox_global_killswitch.currentData() or "on"),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"singbox/profile_use_global_routing",
|
||||
bool(self.chk_singbox_profile_use_global_routing.isChecked()),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"singbox/profile_use_global_dns",
|
||||
bool(self.chk_singbox_profile_use_global_dns.isChecked()),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"singbox/profile_use_global_killswitch",
|
||||
bool(self.chk_singbox_profile_use_global_killswitch.isChecked()),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"singbox/profile_routing",
|
||||
str(self.cmb_singbox_profile_routing.currentData() or "global"),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"singbox/profile_dns",
|
||||
str(self.cmb_singbox_profile_dns.currentData() or "global"),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"singbox/profile_killswitch",
|
||||
str(self.cmb_singbox_profile_killswitch.currentData() or "global"),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"singbox/ui_show_profile_settings",
|
||||
bool(self.btn_singbox_toggle_profile_settings.isChecked()),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"singbox/ui_show_global_defaults",
|
||||
bool(self.btn_singbox_toggle_global_defaults.isChecked()),
|
||||
)
|
||||
self._ui_settings.setValue(
|
||||
"singbox/ui_show_activity_log",
|
||||
bool(self.btn_singbox_toggle_activity.isChecked()),
|
||||
)
|
||||
self._ui_settings.sync()
|
||||
11
selective-vpn-gui/main_window/singbox/__init__.py
Normal file
11
selective-vpn-gui/main_window/singbox/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from .cards_mixin import SingBoxCardsMixin
|
||||
from .editor_mixin import SingBoxEditorMixin
|
||||
from .links_mixin import SingBoxLinksMixin
|
||||
from .runtime_mixin import SingBoxRuntimeMixin
|
||||
|
||||
__all__ = [
|
||||
"SingBoxCardsMixin",
|
||||
"SingBoxEditorMixin",
|
||||
"SingBoxLinksMixin",
|
||||
"SingBoxRuntimeMixin",
|
||||
]
|
||||
205
selective-vpn-gui/main_window/singbox/cards_mixin.py
Normal file
205
selective-vpn-gui/main_window/singbox/cards_mixin.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QFrame, QLabel, QListWidgetItem, QVBoxLayout
|
||||
|
||||
from main_window.constants import SINGBOX_STATUS_ROLE
|
||||
from transport_protocol_summary import transport_protocol_summary
|
||||
|
||||
|
||||
class SingBoxCardsMixin:
|
||||
def _singbox_client_protocol_summary(self, client) -> str:
|
||||
protocol_txt = transport_protocol_summary(client)
|
||||
if protocol_txt == "n/a":
|
||||
cid = str(getattr(client, "id", "") or "").strip()
|
||||
if (
|
||||
cid
|
||||
and cid == str(self._singbox_editor_profile_client_id or "").strip()
|
||||
and str(self._singbox_editor_protocol or "").strip()
|
||||
):
|
||||
protocol_txt = str(self._singbox_editor_protocol).strip().lower()
|
||||
return protocol_txt
|
||||
|
||||
def _make_singbox_profile_card_widget(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
protocol_txt: str,
|
||||
status: str,
|
||||
latency_txt: str,
|
||||
cid: str,
|
||||
) -> QFrame:
|
||||
frame = QFrame()
|
||||
frame.setObjectName("singboxProfileCard")
|
||||
lay = QVBoxLayout(frame)
|
||||
lay.setContentsMargins(10, 8, 10, 8)
|
||||
lay.setSpacing(2)
|
||||
|
||||
lbl_name = QLabel(name)
|
||||
lbl_name.setObjectName("cardName")
|
||||
lbl_name.setAlignment(Qt.AlignHCenter)
|
||||
lay.addWidget(lbl_name)
|
||||
|
||||
lbl_proto = QLabel(protocol_txt)
|
||||
lbl_proto.setObjectName("cardProto")
|
||||
lbl_proto.setAlignment(Qt.AlignHCenter)
|
||||
lay.addWidget(lbl_proto)
|
||||
|
||||
lbl_state = QLabel(f"{str(status or '').upper()} · {latency_txt}")
|
||||
lbl_state.setObjectName("cardState")
|
||||
lbl_state.setAlignment(Qt.AlignHCenter)
|
||||
lay.addWidget(lbl_state)
|
||||
|
||||
frame.setToolTip(f"{cid}\n{protocol_txt}\nstatus={status}")
|
||||
return frame
|
||||
|
||||
def _style_singbox_profile_card_widget(
|
||||
self,
|
||||
card: QFrame,
|
||||
*,
|
||||
active: bool,
|
||||
selected: bool,
|
||||
) -> None:
|
||||
if active and selected:
|
||||
bg = "#c7f1d5"
|
||||
border = "#208f47"
|
||||
name_color = "#11552e"
|
||||
meta_color = "#1f6f43"
|
||||
elif active:
|
||||
bg = "#eafaf0"
|
||||
border = "#2f9e44"
|
||||
name_color = "#14532d"
|
||||
meta_color = "#1f6f43"
|
||||
elif selected:
|
||||
bg = "#e8f1ff"
|
||||
border = "#2f80ed"
|
||||
name_color = "#1b2f50"
|
||||
meta_color = "#28568a"
|
||||
else:
|
||||
bg = "#f7f7f7"
|
||||
border = "#c9c9c9"
|
||||
name_color = "#202020"
|
||||
meta_color = "#666666"
|
||||
|
||||
card.setStyleSheet(
|
||||
f"""
|
||||
QFrame#singboxProfileCard {{
|
||||
border: 1px solid {border};
|
||||
border-radius: 6px;
|
||||
background: {bg};
|
||||
}}
|
||||
QLabel#cardName {{
|
||||
color: {name_color};
|
||||
font-weight: 600;
|
||||
}}
|
||||
QLabel#cardProto {{
|
||||
color: {meta_color};
|
||||
}}
|
||||
QLabel#cardState {{
|
||||
color: {meta_color};
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
def _refresh_singbox_profile_card_styles(self) -> None:
|
||||
current_id = self._selected_transport_engine_id()
|
||||
for i in range(self.lst_singbox_profile_cards.count()):
|
||||
item = self.lst_singbox_profile_cards.item(i)
|
||||
cid = str(item.data(Qt.UserRole) or "").strip()
|
||||
status = str(item.data(SINGBOX_STATUS_ROLE) or "").strip().lower()
|
||||
card = self.lst_singbox_profile_cards.itemWidget(item)
|
||||
if not isinstance(card, QFrame):
|
||||
continue
|
||||
self._style_singbox_profile_card_widget(
|
||||
card,
|
||||
active=(status == "up"),
|
||||
selected=bool(current_id and cid == current_id),
|
||||
)
|
||||
|
||||
def _render_singbox_profile_cards(self) -> None:
|
||||
current_id = self._selected_transport_engine_id()
|
||||
self.lst_singbox_profile_cards.blockSignals(True)
|
||||
self.lst_singbox_profile_cards.clear()
|
||||
selected_item = None
|
||||
|
||||
if not self._transport_api_supported:
|
||||
item = QListWidgetItem("Transport API unavailable")
|
||||
item.setFlags(item.flags() & ~Qt.ItemIsEnabled & ~Qt.ItemIsSelectable)
|
||||
self.lst_singbox_profile_cards.addItem(item)
|
||||
self.lst_singbox_profile_cards.blockSignals(False)
|
||||
return
|
||||
|
||||
if not self._transport_clients:
|
||||
item = QListWidgetItem("No SingBox profiles configured")
|
||||
item.setFlags(item.flags() & ~Qt.ItemIsEnabled & ~Qt.ItemIsSelectable)
|
||||
self.lst_singbox_profile_cards.addItem(item)
|
||||
self.lst_singbox_profile_cards.blockSignals(False)
|
||||
return
|
||||
|
||||
for c in self._transport_clients:
|
||||
cid = str(getattr(c, "id", "") or "").strip()
|
||||
if not cid:
|
||||
continue
|
||||
name = str(getattr(c, "name", "") or "").strip() or cid
|
||||
status, latency, _last_error, _last_check = self._transport_live_health_for_client(c)
|
||||
latency_txt = f"{latency}ms" if latency > 0 else "no ping"
|
||||
protocol_txt = self._singbox_client_protocol_summary(c)
|
||||
item = QListWidgetItem("")
|
||||
item.setData(Qt.UserRole, cid)
|
||||
item.setData(SINGBOX_STATUS_ROLE, status)
|
||||
item.setSizeHint(QSize(228, 78))
|
||||
self.lst_singbox_profile_cards.addItem(item)
|
||||
self.lst_singbox_profile_cards.setItemWidget(
|
||||
item,
|
||||
self._make_singbox_profile_card_widget(
|
||||
name=name,
|
||||
protocol_txt=protocol_txt,
|
||||
status=status,
|
||||
latency_txt=latency_txt,
|
||||
cid=cid,
|
||||
),
|
||||
)
|
||||
if current_id and cid == current_id:
|
||||
selected_item = item
|
||||
|
||||
if selected_item is not None:
|
||||
self.lst_singbox_profile_cards.setCurrentItem(selected_item)
|
||||
elif self.lst_singbox_profile_cards.count() > 0:
|
||||
self.lst_singbox_profile_cards.setCurrentRow(0)
|
||||
self.lst_singbox_profile_cards.blockSignals(False)
|
||||
self._refresh_singbox_profile_card_styles()
|
||||
|
||||
def _sync_singbox_profile_card_selection(self, cid: str) -> None:
|
||||
if self._syncing_singbox_selection:
|
||||
return
|
||||
self._syncing_singbox_selection = True
|
||||
try:
|
||||
self.lst_singbox_profile_cards.blockSignals(True)
|
||||
self.lst_singbox_profile_cards.clearSelection()
|
||||
target = str(cid or "").strip()
|
||||
if target:
|
||||
for i in range(self.lst_singbox_profile_cards.count()):
|
||||
item = self.lst_singbox_profile_cards.item(i)
|
||||
if str(item.data(Qt.UserRole) or "").strip() == target:
|
||||
self.lst_singbox_profile_cards.setCurrentItem(item)
|
||||
break
|
||||
self.lst_singbox_profile_cards.blockSignals(False)
|
||||
finally:
|
||||
self._syncing_singbox_selection = False
|
||||
self._refresh_singbox_profile_card_styles()
|
||||
|
||||
def _select_transport_engine_by_id(self, cid: str) -> bool:
|
||||
target = str(cid or "").strip()
|
||||
if not target:
|
||||
return False
|
||||
idx = self.cmb_transport_engine.findData(target)
|
||||
if idx < 0:
|
||||
return False
|
||||
if idx != self.cmb_transport_engine.currentIndex():
|
||||
self.cmb_transport_engine.setCurrentIndex(idx)
|
||||
else:
|
||||
self._sync_singbox_profile_card_selection(target)
|
||||
self._sync_selected_singbox_profile_link(silent=True)
|
||||
self._load_singbox_editor_for_selected(silent=True)
|
||||
self._update_transport_engine_view()
|
||||
return True
|
||||
633
selective-vpn-gui/main_window/singbox/editor_mixin.py
Normal file
633
selective-vpn-gui/main_window/singbox/editor_mixin.py
Normal file
@@ -0,0 +1,633 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from main_window.constants import SINGBOX_EDITOR_PROTOCOL_IDS, SINGBOX_PROTOCOL_SEED_SPEC
|
||||
|
||||
|
||||
class SingBoxEditorMixin:
|
||||
def _selected_singbox_profile_id(self) -> str:
|
||||
selected = self._selected_transport_client()
|
||||
if selected is not None:
|
||||
selected_cid = str(getattr(selected, "id", "") or "").strip()
|
||||
if (
|
||||
selected_cid
|
||||
and self._singbox_editor_profile_id
|
||||
and selected_cid == str(self._singbox_editor_profile_client_id or "").strip()
|
||||
):
|
||||
return str(self._singbox_editor_profile_id).strip()
|
||||
if selected_cid:
|
||||
# Desktop SingBox tab keeps one deterministic profile per engine card.
|
||||
return selected_cid
|
||||
return self._selected_transport_engine_id()
|
||||
|
||||
def _set_singbox_editor_enabled(self, enabled: bool) -> None:
|
||||
widgets = [
|
||||
self.ent_singbox_proto_name,
|
||||
self.chk_singbox_proto_enabled,
|
||||
self.cmb_singbox_proto_protocol,
|
||||
self.ent_singbox_vless_server,
|
||||
self.spn_singbox_vless_port,
|
||||
self.ent_singbox_vless_uuid,
|
||||
self.ent_singbox_proto_password,
|
||||
self.cmb_singbox_vless_flow,
|
||||
self.cmb_singbox_vless_packet_encoding,
|
||||
self.cmb_singbox_ss_method,
|
||||
self.ent_singbox_ss_plugin,
|
||||
self.spn_singbox_hy2_up_mbps,
|
||||
self.spn_singbox_hy2_down_mbps,
|
||||
self.ent_singbox_hy2_obfs,
|
||||
self.ent_singbox_hy2_obfs_password,
|
||||
self.cmb_singbox_tuic_congestion,
|
||||
self.cmb_singbox_tuic_udp_mode,
|
||||
self.chk_singbox_tuic_zero_rtt,
|
||||
self.ent_singbox_wg_private_key,
|
||||
self.ent_singbox_wg_peer_public_key,
|
||||
self.ent_singbox_wg_psk,
|
||||
self.ent_singbox_wg_local_address,
|
||||
self.ent_singbox_wg_reserved,
|
||||
self.spn_singbox_wg_mtu,
|
||||
self.btn_singbox_wg_paste_private,
|
||||
self.btn_singbox_wg_copy_private,
|
||||
self.btn_singbox_wg_paste_peer,
|
||||
self.btn_singbox_wg_copy_peer,
|
||||
self.btn_singbox_wg_paste_psk,
|
||||
self.btn_singbox_wg_copy_psk,
|
||||
self.cmb_singbox_vless_transport,
|
||||
self.ent_singbox_vless_path,
|
||||
self.ent_singbox_vless_grpc_service,
|
||||
self.cmb_singbox_vless_security,
|
||||
self.ent_singbox_vless_sni,
|
||||
self.ent_singbox_tls_alpn,
|
||||
self.cmb_singbox_vless_utls_fp,
|
||||
self.ent_singbox_vless_reality_pk,
|
||||
self.ent_singbox_vless_reality_sid,
|
||||
self.chk_singbox_vless_insecure,
|
||||
self.chk_singbox_vless_sniff,
|
||||
]
|
||||
for w in widgets:
|
||||
w.setEnabled(bool(enabled))
|
||||
|
||||
def _clear_singbox_editor(self) -> None:
|
||||
self._singbox_editor_loading = True
|
||||
try:
|
||||
self._singbox_editor_profile_id = ""
|
||||
self._singbox_editor_profile_client_id = ""
|
||||
self._singbox_editor_protocol = "vless"
|
||||
self._singbox_editor_source_raw = {}
|
||||
self.ent_singbox_proto_name.setText("")
|
||||
self.chk_singbox_proto_enabled.setChecked(True)
|
||||
self.cmb_singbox_proto_protocol.setCurrentIndex(0)
|
||||
self.ent_singbox_vless_server.setText("")
|
||||
self.spn_singbox_vless_port.setValue(443)
|
||||
self.ent_singbox_vless_uuid.setText("")
|
||||
self.ent_singbox_proto_password.setText("")
|
||||
self.cmb_singbox_vless_flow.setCurrentIndex(0)
|
||||
self.cmb_singbox_vless_flow.setEditText("")
|
||||
self.cmb_singbox_vless_packet_encoding.setCurrentIndex(0)
|
||||
self.cmb_singbox_ss_method.setCurrentIndex(0)
|
||||
self.ent_singbox_ss_plugin.setText("")
|
||||
self.spn_singbox_hy2_up_mbps.setValue(0)
|
||||
self.spn_singbox_hy2_down_mbps.setValue(0)
|
||||
self.ent_singbox_hy2_obfs.setText("")
|
||||
self.ent_singbox_hy2_obfs_password.setText("")
|
||||
self.cmb_singbox_tuic_congestion.setCurrentIndex(0)
|
||||
self.cmb_singbox_tuic_udp_mode.setCurrentIndex(0)
|
||||
self.chk_singbox_tuic_zero_rtt.setChecked(False)
|
||||
self.ent_singbox_wg_private_key.setText("")
|
||||
self.ent_singbox_wg_peer_public_key.setText("")
|
||||
self.ent_singbox_wg_psk.setText("")
|
||||
self.ent_singbox_wg_local_address.setText("")
|
||||
self.ent_singbox_wg_reserved.setText("")
|
||||
self.spn_singbox_wg_mtu.setValue(0)
|
||||
self.cmb_singbox_vless_transport.setCurrentIndex(0)
|
||||
self.ent_singbox_vless_path.setText("")
|
||||
self.ent_singbox_vless_grpc_service.setText("")
|
||||
self.cmb_singbox_vless_security.setCurrentIndex(0)
|
||||
self.ent_singbox_vless_sni.setText("")
|
||||
self.ent_singbox_tls_alpn.setText("")
|
||||
self.cmb_singbox_vless_utls_fp.setCurrentIndex(0)
|
||||
self.ent_singbox_vless_reality_pk.setText("")
|
||||
self.ent_singbox_vless_reality_sid.setText("")
|
||||
self.chk_singbox_vless_insecure.setChecked(False)
|
||||
self.chk_singbox_vless_sniff.setChecked(True)
|
||||
finally:
|
||||
self._singbox_editor_loading = False
|
||||
self.on_singbox_vless_editor_changed()
|
||||
|
||||
def _load_singbox_editor_for_selected(self, *, silent: bool = True) -> None:
|
||||
client = self._selected_transport_client()
|
||||
if client is None:
|
||||
self._clear_singbox_editor()
|
||||
self._set_singbox_editor_enabled(False)
|
||||
return
|
||||
try:
|
||||
cid = str(getattr(client, "id", "") or "").strip()
|
||||
profile = self.ctrl.singbox_profile_get_for_client(
|
||||
client,
|
||||
profile_id=self._selected_singbox_profile_id(),
|
||||
)
|
||||
self._apply_singbox_editor_profile(profile, fallback_name=str(getattr(client, "name", "") or "").strip())
|
||||
self._singbox_editor_profile_client_id = cid
|
||||
self._set_singbox_editor_enabled(True)
|
||||
except Exception as e:
|
||||
if not silent:
|
||||
raise
|
||||
self._append_transport_log(f"[profile] editor load failed: {e}")
|
||||
self._clear_singbox_editor()
|
||||
self._set_singbox_editor_enabled(False)
|
||||
|
||||
def _find_editor_proxy_outbound(self, outbounds: list[Any]) -> dict[str, Any]:
|
||||
proxy = {}
|
||||
for row in outbounds:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
t = str(row.get("type") or "").strip().lower()
|
||||
tag = str(row.get("tag") or "").strip().lower()
|
||||
if self._is_supported_editor_protocol(t):
|
||||
proxy = row
|
||||
break
|
||||
if tag == "proxy":
|
||||
proxy = row
|
||||
return dict(proxy) if isinstance(proxy, dict) else {}
|
||||
|
||||
def _find_editor_sniff_inbound(self, inbounds: list[Any]) -> dict[str, Any]:
|
||||
inbound = {}
|
||||
for row in inbounds:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
tag = str(row.get("tag") or "").strip().lower()
|
||||
t = str(row.get("type") or "").strip().lower()
|
||||
if tag == "socks-in" or t == "socks":
|
||||
inbound = row
|
||||
break
|
||||
return dict(inbound) if isinstance(inbound, dict) else {}
|
||||
|
||||
def _apply_singbox_editor_profile(self, profile, *, fallback_name: str = "") -> None:
|
||||
raw = getattr(profile, "raw_config", {}) or {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
protocol = str(getattr(profile, "protocol", "") or "").strip().lower() or "vless"
|
||||
outbounds = raw.get("outbounds") or []
|
||||
if not isinstance(outbounds, list):
|
||||
outbounds = []
|
||||
inbounds = raw.get("inbounds") or []
|
||||
if not isinstance(inbounds, list):
|
||||
inbounds = []
|
||||
|
||||
proxy = self._find_editor_proxy_outbound(outbounds)
|
||||
inbound = self._find_editor_sniff_inbound(inbounds)
|
||||
proxy_type = str(proxy.get("type") or "").strip().lower()
|
||||
if self._is_supported_editor_protocol(proxy_type):
|
||||
protocol = proxy_type
|
||||
|
||||
tls = proxy.get("tls") if isinstance(proxy.get("tls"), dict) else {}
|
||||
reality = tls.get("reality") if isinstance(tls.get("reality"), dict) else {}
|
||||
utls = tls.get("utls") if isinstance(tls.get("utls"), dict) else {}
|
||||
transport = proxy.get("transport") if isinstance(proxy.get("transport"), dict) else {}
|
||||
|
||||
security = "none"
|
||||
if bool(tls.get("enabled", False)):
|
||||
security = "tls"
|
||||
if bool(reality.get("enabled", False)):
|
||||
security = "reality"
|
||||
|
||||
transport_type = str(transport.get("type") or "").strip().lower() or "tcp"
|
||||
path = str(transport.get("path") or "").strip()
|
||||
grpc_service = str(transport.get("service_name") or "").strip()
|
||||
alpn_vals = tls.get("alpn") or []
|
||||
if not isinstance(alpn_vals, list):
|
||||
alpn_vals = []
|
||||
alpn_text = ",".join([str(x).strip() for x in alpn_vals if str(x).strip()])
|
||||
|
||||
self._singbox_editor_loading = True
|
||||
try:
|
||||
self._singbox_editor_profile_id = str(getattr(profile, "id", "") or "").strip()
|
||||
self._singbox_editor_protocol = protocol
|
||||
self._singbox_editor_source_raw = json.loads(json.dumps(raw))
|
||||
self.ent_singbox_proto_name.setText(
|
||||
str(getattr(profile, "name", "") or "").strip() or fallback_name or self._singbox_editor_profile_id
|
||||
)
|
||||
self.chk_singbox_proto_enabled.setChecked(bool(getattr(profile, "enabled", True)))
|
||||
pidx = self.cmb_singbox_proto_protocol.findData(protocol)
|
||||
self.cmb_singbox_proto_protocol.setCurrentIndex(pidx if pidx >= 0 else 0)
|
||||
self.ent_singbox_vless_server.setText(str(proxy.get("server") or "").strip())
|
||||
try:
|
||||
self.spn_singbox_vless_port.setValue(int(proxy.get("server_port") or 443))
|
||||
except Exception:
|
||||
self.spn_singbox_vless_port.setValue(443)
|
||||
self.ent_singbox_vless_uuid.setText(str(proxy.get("uuid") or "").strip())
|
||||
self.ent_singbox_proto_password.setText(str(proxy.get("password") or "").strip())
|
||||
|
||||
flow_value = str(proxy.get("flow") or "").strip()
|
||||
idx = self.cmb_singbox_vless_flow.findData(flow_value)
|
||||
if idx >= 0:
|
||||
self.cmb_singbox_vless_flow.setCurrentIndex(idx)
|
||||
else:
|
||||
self.cmb_singbox_vless_flow.setEditText(flow_value)
|
||||
|
||||
pe = str(proxy.get("packet_encoding") or "").strip().lower()
|
||||
if pe in ("none", "off", "false"):
|
||||
pe = ""
|
||||
idx = self.cmb_singbox_vless_packet_encoding.findData(pe)
|
||||
self.cmb_singbox_vless_packet_encoding.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
|
||||
ss_method = str(proxy.get("method") or "").strip().lower()
|
||||
idx = self.cmb_singbox_ss_method.findData(ss_method)
|
||||
if idx >= 0:
|
||||
self.cmb_singbox_ss_method.setCurrentIndex(idx)
|
||||
else:
|
||||
self.cmb_singbox_ss_method.setEditText(ss_method)
|
||||
self.ent_singbox_ss_plugin.setText(str(proxy.get("plugin") or "").strip())
|
||||
|
||||
try:
|
||||
self.spn_singbox_hy2_up_mbps.setValue(int(proxy.get("up_mbps") or 0))
|
||||
except Exception:
|
||||
self.spn_singbox_hy2_up_mbps.setValue(0)
|
||||
try:
|
||||
self.spn_singbox_hy2_down_mbps.setValue(int(proxy.get("down_mbps") or 0))
|
||||
except Exception:
|
||||
self.spn_singbox_hy2_down_mbps.setValue(0)
|
||||
obfs = proxy.get("obfs") if isinstance(proxy.get("obfs"), dict) else {}
|
||||
self.ent_singbox_hy2_obfs.setText(str(obfs.get("type") or "").strip())
|
||||
self.ent_singbox_hy2_obfs_password.setText(str(obfs.get("password") or "").strip())
|
||||
|
||||
cc = str(proxy.get("congestion_control") or "").strip()
|
||||
idx = self.cmb_singbox_tuic_congestion.findData(cc)
|
||||
if idx >= 0:
|
||||
self.cmb_singbox_tuic_congestion.setCurrentIndex(idx)
|
||||
else:
|
||||
self.cmb_singbox_tuic_congestion.setCurrentIndex(0)
|
||||
udp_mode = str(proxy.get("udp_relay_mode") or "").strip()
|
||||
idx = self.cmb_singbox_tuic_udp_mode.findData(udp_mode)
|
||||
self.cmb_singbox_tuic_udp_mode.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self.chk_singbox_tuic_zero_rtt.setChecked(bool(proxy.get("zero_rtt_handshake", False)))
|
||||
|
||||
self.ent_singbox_wg_private_key.setText(str(proxy.get("private_key") or "").strip())
|
||||
self.ent_singbox_wg_peer_public_key.setText(str(proxy.get("peer_public_key") or "").strip())
|
||||
self.ent_singbox_wg_psk.setText(str(proxy.get("pre_shared_key") or "").strip())
|
||||
local_addr = proxy.get("local_address") or []
|
||||
if not isinstance(local_addr, list):
|
||||
if str(local_addr or "").strip():
|
||||
local_addr = [str(local_addr).strip()]
|
||||
else:
|
||||
local_addr = []
|
||||
self.ent_singbox_wg_local_address.setText(
|
||||
",".join([str(x).strip() for x in local_addr if str(x).strip()])
|
||||
)
|
||||
reserved = proxy.get("reserved") or []
|
||||
if not isinstance(reserved, list):
|
||||
if str(reserved or "").strip():
|
||||
reserved = [str(reserved).strip()]
|
||||
else:
|
||||
reserved = []
|
||||
self.ent_singbox_wg_reserved.setText(
|
||||
",".join([str(x).strip() for x in reserved if str(x).strip()])
|
||||
)
|
||||
try:
|
||||
self.spn_singbox_wg_mtu.setValue(int(proxy.get("mtu") or 0))
|
||||
except Exception:
|
||||
self.spn_singbox_wg_mtu.setValue(0)
|
||||
|
||||
idx = self.cmb_singbox_vless_transport.findData(transport_type)
|
||||
self.cmb_singbox_vless_transport.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self.ent_singbox_vless_path.setText(path)
|
||||
self.ent_singbox_vless_grpc_service.setText(grpc_service)
|
||||
|
||||
idx = self.cmb_singbox_vless_security.findData(security)
|
||||
self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self.ent_singbox_vless_sni.setText(str(tls.get("server_name") or "").strip())
|
||||
self.ent_singbox_tls_alpn.setText(alpn_text)
|
||||
idx = self.cmb_singbox_vless_utls_fp.findData(str(utls.get("fingerprint") or "").strip())
|
||||
self.cmb_singbox_vless_utls_fp.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self.ent_singbox_vless_reality_pk.setText(str(reality.get("public_key") or "").strip())
|
||||
self.ent_singbox_vless_reality_sid.setText(str(reality.get("short_id") or "").strip())
|
||||
self.chk_singbox_vless_insecure.setChecked(bool(tls.get("insecure", False)))
|
||||
self.chk_singbox_vless_sniff.setChecked(bool(inbound.get("sniff", True)))
|
||||
finally:
|
||||
self._singbox_editor_loading = False
|
||||
self.on_singbox_vless_editor_changed()
|
||||
|
||||
def _validate_singbox_editor_form(self) -> None:
|
||||
protocol = self._current_editor_protocol()
|
||||
addr = self.ent_singbox_vless_server.text().strip()
|
||||
if not addr:
|
||||
raise RuntimeError("Address is required")
|
||||
security = str(self.cmb_singbox_vless_security.currentData() or "none").strip().lower()
|
||||
transport = str(self.cmb_singbox_vless_transport.currentData() or "tcp").strip().lower()
|
||||
if protocol == "vless":
|
||||
if not self.ent_singbox_vless_uuid.text().strip():
|
||||
raise RuntimeError("UUID is required for VLESS")
|
||||
if security == "reality" and not self.ent_singbox_vless_reality_pk.text().strip():
|
||||
raise RuntimeError("Reality public key is required for Reality security mode")
|
||||
elif protocol == "trojan":
|
||||
if not self.ent_singbox_proto_password.text().strip():
|
||||
raise RuntimeError("Password is required for Trojan")
|
||||
if security == "reality":
|
||||
raise RuntimeError("Reality security is not supported for Trojan in this editor")
|
||||
elif protocol == "shadowsocks":
|
||||
method = str(self.cmb_singbox_ss_method.currentData() or "").strip()
|
||||
if not method:
|
||||
method = self.cmb_singbox_ss_method.currentText().strip()
|
||||
if not method:
|
||||
raise RuntimeError("SS method is required for Shadowsocks")
|
||||
if not self.ent_singbox_proto_password.text().strip():
|
||||
raise RuntimeError("Password is required for Shadowsocks")
|
||||
elif protocol == "hysteria2":
|
||||
if not self.ent_singbox_proto_password.text().strip():
|
||||
raise RuntimeError("Password is required for Hysteria2")
|
||||
elif protocol == "tuic":
|
||||
if not self.ent_singbox_vless_uuid.text().strip():
|
||||
raise RuntimeError("UUID is required for TUIC")
|
||||
if not self.ent_singbox_proto_password.text().strip():
|
||||
raise RuntimeError("Password is required for TUIC")
|
||||
elif protocol == "wireguard":
|
||||
if not self.ent_singbox_wg_private_key.text().strip():
|
||||
raise RuntimeError("WireGuard private key is required")
|
||||
if not self.ent_singbox_wg_peer_public_key.text().strip():
|
||||
raise RuntimeError("WireGuard peer public key is required")
|
||||
local_addr = [str(x).strip() for x in self.ent_singbox_wg_local_address.text().split(",") if str(x).strip()]
|
||||
if not local_addr:
|
||||
raise RuntimeError("WireGuard local address is required (CIDR list)")
|
||||
self._parse_wg_reserved_values(
|
||||
[str(x).strip() for x in self.ent_singbox_wg_reserved.text().split(",") if str(x).strip()],
|
||||
strict=True,
|
||||
)
|
||||
|
||||
if protocol in ("vless", "trojan"):
|
||||
if transport == "grpc" and not self.ent_singbox_vless_grpc_service.text().strip():
|
||||
raise RuntimeError("gRPC service is required for gRPC transport")
|
||||
if transport in ("ws", "http", "httpupgrade") and not self.ent_singbox_vless_path.text().strip():
|
||||
raise RuntimeError("Transport path is required for selected transport")
|
||||
|
||||
def _build_singbox_editor_raw_config(self) -> dict[str, Any]:
|
||||
base = self._singbox_editor_source_raw
|
||||
if not isinstance(base, dict):
|
||||
base = {}
|
||||
raw: dict[str, Any] = json.loads(json.dumps(base))
|
||||
protocol = self._current_editor_protocol()
|
||||
|
||||
outbounds = raw.get("outbounds") or []
|
||||
if not isinstance(outbounds, list):
|
||||
outbounds = []
|
||||
|
||||
proxy_idx = -1
|
||||
for i, row in enumerate(outbounds):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
t = str(row.get("type") or "").strip().lower()
|
||||
tag = str(row.get("tag") or "").strip().lower()
|
||||
if self._is_supported_editor_protocol(t) or tag == "proxy":
|
||||
proxy_idx = i
|
||||
break
|
||||
|
||||
proxy: dict[str, Any]
|
||||
if proxy_idx >= 0:
|
||||
proxy = dict(outbounds[proxy_idx]) if isinstance(outbounds[proxy_idx], dict) else {}
|
||||
else:
|
||||
proxy = {}
|
||||
|
||||
proxy["type"] = protocol
|
||||
proxy["tag"] = str(proxy.get("tag") or "proxy")
|
||||
proxy["server"] = self.ent_singbox_vless_server.text().strip()
|
||||
proxy["server_port"] = int(self.spn_singbox_vless_port.value())
|
||||
# clear protocol-specific keys before repopulating
|
||||
for key in (
|
||||
"uuid",
|
||||
"password",
|
||||
"method",
|
||||
"plugin",
|
||||
"flow",
|
||||
"packet_encoding",
|
||||
"up_mbps",
|
||||
"down_mbps",
|
||||
"obfs",
|
||||
"congestion_control",
|
||||
"udp_relay_mode",
|
||||
"zero_rtt_handshake",
|
||||
"private_key",
|
||||
"peer_public_key",
|
||||
"pre_shared_key",
|
||||
"local_address",
|
||||
"reserved",
|
||||
"mtu",
|
||||
):
|
||||
proxy.pop(key, None)
|
||||
|
||||
if protocol == "vless":
|
||||
proxy["uuid"] = self.ent_singbox_vless_uuid.text().strip()
|
||||
flow = str(self.cmb_singbox_vless_flow.currentData() or "").strip()
|
||||
if not flow:
|
||||
flow = self.cmb_singbox_vless_flow.currentText().strip()
|
||||
if flow:
|
||||
proxy["flow"] = flow
|
||||
packet_encoding = str(self.cmb_singbox_vless_packet_encoding.currentData() or "").strip().lower()
|
||||
if packet_encoding and packet_encoding != "none":
|
||||
proxy["packet_encoding"] = packet_encoding
|
||||
elif protocol == "trojan":
|
||||
proxy["password"] = self.ent_singbox_proto_password.text().strip()
|
||||
elif protocol == "shadowsocks":
|
||||
method = str(self.cmb_singbox_ss_method.currentData() or "").strip()
|
||||
if not method:
|
||||
method = self.cmb_singbox_ss_method.currentText().strip()
|
||||
proxy["method"] = method
|
||||
proxy["password"] = self.ent_singbox_proto_password.text().strip()
|
||||
plugin = self.ent_singbox_ss_plugin.text().strip()
|
||||
if plugin:
|
||||
proxy["plugin"] = plugin
|
||||
elif protocol == "hysteria2":
|
||||
proxy["password"] = self.ent_singbox_proto_password.text().strip()
|
||||
up = int(self.spn_singbox_hy2_up_mbps.value())
|
||||
down = int(self.spn_singbox_hy2_down_mbps.value())
|
||||
if up > 0:
|
||||
proxy["up_mbps"] = up
|
||||
if down > 0:
|
||||
proxy["down_mbps"] = down
|
||||
obfs_type = self.ent_singbox_hy2_obfs.text().strip()
|
||||
if obfs_type:
|
||||
obfs: dict[str, Any] = {"type": obfs_type}
|
||||
obfs_password = self.ent_singbox_hy2_obfs_password.text().strip()
|
||||
if obfs_password:
|
||||
obfs["password"] = obfs_password
|
||||
proxy["obfs"] = obfs
|
||||
elif protocol == "tuic":
|
||||
proxy["uuid"] = self.ent_singbox_vless_uuid.text().strip()
|
||||
proxy["password"] = self.ent_singbox_proto_password.text().strip()
|
||||
cc = str(self.cmb_singbox_tuic_congestion.currentData() or "").strip()
|
||||
if not cc:
|
||||
cc = self.cmb_singbox_tuic_congestion.currentText().strip()
|
||||
if cc:
|
||||
proxy["congestion_control"] = cc
|
||||
udp_mode = str(self.cmb_singbox_tuic_udp_mode.currentData() or "").strip()
|
||||
if udp_mode:
|
||||
proxy["udp_relay_mode"] = udp_mode
|
||||
if self.chk_singbox_tuic_zero_rtt.isChecked():
|
||||
proxy["zero_rtt_handshake"] = True
|
||||
elif protocol == "wireguard":
|
||||
proxy["private_key"] = self.ent_singbox_wg_private_key.text().strip()
|
||||
proxy["peer_public_key"] = self.ent_singbox_wg_peer_public_key.text().strip()
|
||||
psk = self.ent_singbox_wg_psk.text().strip()
|
||||
if psk:
|
||||
proxy["pre_shared_key"] = psk
|
||||
local_addr = [str(x).strip() for x in self.ent_singbox_wg_local_address.text().split(",") if str(x).strip()]
|
||||
if local_addr:
|
||||
proxy["local_address"] = local_addr
|
||||
reserved_vals = self._parse_wg_reserved_values(
|
||||
[str(x).strip() for x in self.ent_singbox_wg_reserved.text().split(",") if str(x).strip()],
|
||||
strict=True,
|
||||
)
|
||||
if reserved_vals:
|
||||
proxy["reserved"] = reserved_vals
|
||||
mtu = int(self.spn_singbox_wg_mtu.value())
|
||||
if mtu > 0:
|
||||
proxy["mtu"] = mtu
|
||||
|
||||
transport_type = str(self.cmb_singbox_vless_transport.currentData() or "tcp").strip().lower()
|
||||
if protocol in ("vless", "trojan"):
|
||||
self._apply_proxy_transport(
|
||||
proxy,
|
||||
transport=transport_type,
|
||||
path=self.ent_singbox_vless_path.text().strip(),
|
||||
grpc_service=self.ent_singbox_vless_grpc_service.text().strip(),
|
||||
)
|
||||
else:
|
||||
proxy.pop("transport", None)
|
||||
|
||||
security = str(self.cmb_singbox_vless_security.currentData() or "none").strip().lower()
|
||||
if protocol == "vless":
|
||||
pass
|
||||
elif protocol == "trojan":
|
||||
if security == "reality":
|
||||
security = "tls"
|
||||
elif protocol in ("hysteria2", "tuic"):
|
||||
security = "tls"
|
||||
else:
|
||||
security = "none"
|
||||
|
||||
alpn = []
|
||||
for p in self.ent_singbox_tls_alpn.text().split(","):
|
||||
v = str(p or "").strip()
|
||||
if v:
|
||||
alpn.append(v)
|
||||
self._apply_proxy_tls(
|
||||
proxy,
|
||||
security=security,
|
||||
sni=self.ent_singbox_vless_sni.text().strip(),
|
||||
utls_fp=str(self.cmb_singbox_vless_utls_fp.currentData() or "").strip(),
|
||||
tls_insecure=bool(self.chk_singbox_vless_insecure.isChecked()),
|
||||
reality_public_key=self.ent_singbox_vless_reality_pk.text().strip(),
|
||||
reality_short_id=self.ent_singbox_vless_reality_sid.text().strip(),
|
||||
alpn=alpn,
|
||||
)
|
||||
|
||||
if proxy_idx >= 0:
|
||||
outbounds[proxy_idx] = proxy
|
||||
else:
|
||||
outbounds.insert(0, proxy)
|
||||
|
||||
has_direct = any(
|
||||
isinstance(row, dict)
|
||||
and str(row.get("type") or "").strip().lower() == "direct"
|
||||
and str(row.get("tag") or "").strip().lower() == "direct"
|
||||
for row in outbounds
|
||||
)
|
||||
if not has_direct:
|
||||
outbounds.append({"type": "direct", "tag": "direct"})
|
||||
raw["outbounds"] = outbounds
|
||||
|
||||
inbounds = raw.get("inbounds") or []
|
||||
if not isinstance(inbounds, list):
|
||||
inbounds = []
|
||||
inbound_idx = -1
|
||||
for i, row in enumerate(inbounds):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
tag = str(row.get("tag") or "").strip().lower()
|
||||
t = str(row.get("type") or "").strip().lower()
|
||||
if tag == "socks-in" or t == "socks":
|
||||
inbound_idx = i
|
||||
break
|
||||
inbound = (
|
||||
dict(inbounds[inbound_idx]) if inbound_idx >= 0 and isinstance(inbounds[inbound_idx], dict) else {}
|
||||
)
|
||||
inbound["type"] = str(inbound.get("type") or "socks")
|
||||
inbound["tag"] = str(inbound.get("tag") or "socks-in")
|
||||
inbound["listen"] = str(inbound.get("listen") or "127.0.0.1")
|
||||
inbound["listen_port"] = int(inbound.get("listen_port") or 10808)
|
||||
sniff = bool(self.chk_singbox_vless_sniff.isChecked())
|
||||
inbound["sniff"] = sniff
|
||||
inbound["sniff_override_destination"] = sniff
|
||||
if inbound_idx >= 0:
|
||||
inbounds[inbound_idx] = inbound
|
||||
else:
|
||||
inbounds.insert(0, inbound)
|
||||
raw["inbounds"] = inbounds
|
||||
|
||||
route = raw.get("route") if isinstance(raw.get("route"), dict) else {}
|
||||
route["final"] = str(route.get("final") or "direct")
|
||||
rules = route.get("rules") or []
|
||||
if not isinstance(rules, list):
|
||||
rules = []
|
||||
has_proxy_rule = False
|
||||
for row in rules:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
outbound = str(row.get("outbound") or "").strip().lower()
|
||||
inbound_list = row.get("inbound") or []
|
||||
if not isinstance(inbound_list, list):
|
||||
inbound_list = []
|
||||
inbound_norm = [str(x).strip().lower() for x in inbound_list if str(x).strip()]
|
||||
if outbound == "proxy" and "socks-in" in inbound_norm:
|
||||
has_proxy_rule = True
|
||||
break
|
||||
if not has_proxy_rule:
|
||||
rules.insert(0, {"inbound": ["socks-in"], "outbound": "proxy"})
|
||||
route["rules"] = rules
|
||||
raw["route"] = route
|
||||
return raw
|
||||
|
||||
def _save_singbox_editor_draft(self, client, *, profile_id: str = ""):
|
||||
protocol = self._current_editor_protocol()
|
||||
self._validate_singbox_editor_form()
|
||||
raw_cfg = self._build_singbox_editor_raw_config()
|
||||
name = self.ent_singbox_proto_name.text().strip()
|
||||
enabled = bool(self.chk_singbox_proto_enabled.isChecked())
|
||||
res = self.ctrl.singbox_profile_save_raw_for_client(
|
||||
client,
|
||||
profile_id=profile_id,
|
||||
name=name,
|
||||
enabled=enabled,
|
||||
protocol=protocol,
|
||||
raw_config=raw_cfg,
|
||||
)
|
||||
profile = self.ctrl.singbox_profile_get_for_client(client, profile_id=profile_id)
|
||||
self._singbox_editor_profile_id = str(getattr(profile, "id", "") or "").strip()
|
||||
self._singbox_editor_profile_client_id = str(getattr(client, "id", "") or "").strip()
|
||||
self._singbox_editor_protocol = str(getattr(profile, "protocol", "") or protocol).strip().lower() or protocol
|
||||
self._singbox_editor_source_raw = json.loads(json.dumps(getattr(profile, "raw_config", {}) or {}))
|
||||
return res
|
||||
|
||||
def _sync_selected_singbox_profile_link(self, *, silent: bool = True) -> None:
|
||||
client = self._selected_transport_client()
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
preferred_pid = str(getattr(client, "id", "") or "").strip()
|
||||
res = self.ctrl.singbox_profile_ensure_linked(
|
||||
client,
|
||||
preferred_profile_id=preferred_pid,
|
||||
)
|
||||
except Exception as e:
|
||||
if not silent:
|
||||
raise
|
||||
self._append_transport_log(f"[profile] auto-link skipped: {e}")
|
||||
return
|
||||
line = (res.pretty_text or "").strip()
|
||||
if not line:
|
||||
return
|
||||
# Keep noisy "already linked" messages out of normal flow.
|
||||
if "already linked" in line.lower() and silent:
|
||||
return
|
||||
self._append_transport_log(f"[profile] {line}")
|
||||
self.ctrl.log_gui(f"[singbox-profile] {line}")
|
||||
271
selective-vpn-gui/main_window/singbox/links_actions_mixin.py
Normal file
271
selective-vpn-gui/main_window/singbox/links_actions_mixin.py
Normal file
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from PySide6.QtWidgets import QApplication, QInputDialog, QMenu
|
||||
|
||||
from main_window.constants import SINGBOX_EDITOR_PROTOCOL_OPTIONS
|
||||
|
||||
|
||||
class SingBoxLinksActionsMixin:
|
||||
def _apply_singbox_editor_values(self, values: dict[str, Any]) -> None:
|
||||
incoming = dict(values or {})
|
||||
target_protocol = str(incoming.get("protocol") or self._current_editor_protocol() or "vless").strip().lower() or "vless"
|
||||
payload = self._seed_editor_values_for_protocol(
|
||||
target_protocol,
|
||||
profile_name=str(incoming.get("profile_name") or "").strip(),
|
||||
)
|
||||
payload.update(incoming)
|
||||
self._singbox_editor_loading = True
|
||||
try:
|
||||
name = str(payload.get("profile_name") or "").strip()
|
||||
self.ent_singbox_proto_name.setText(name)
|
||||
self.chk_singbox_proto_enabled.setChecked(bool(payload.get("enabled", True)))
|
||||
protocol = str(payload.get("protocol") or "").strip().lower()
|
||||
if protocol:
|
||||
pidx = self.cmb_singbox_proto_protocol.findData(protocol)
|
||||
self.cmb_singbox_proto_protocol.setCurrentIndex(pidx if pidx >= 0 else self.cmb_singbox_proto_protocol.currentIndex())
|
||||
self.ent_singbox_vless_server.setText(str(payload.get("server") or "").strip())
|
||||
|
||||
try:
|
||||
self.spn_singbox_vless_port.setValue(int(payload.get("port") or 443))
|
||||
except Exception:
|
||||
self.spn_singbox_vless_port.setValue(443)
|
||||
|
||||
self.ent_singbox_vless_uuid.setText(str(payload.get("uuid") or "").strip())
|
||||
self.ent_singbox_proto_password.setText(str(payload.get("password") or "").strip())
|
||||
|
||||
flow_v = str(payload.get("flow") or "").strip()
|
||||
flow_idx = self.cmb_singbox_vless_flow.findData(flow_v)
|
||||
if flow_idx >= 0:
|
||||
self.cmb_singbox_vless_flow.setCurrentIndex(flow_idx)
|
||||
else:
|
||||
self.cmb_singbox_vless_flow.setEditText(flow_v)
|
||||
|
||||
packet_v = str(payload.get("packet_encoding") or "").strip().lower()
|
||||
if packet_v in ("none", "off", "false"):
|
||||
packet_v = ""
|
||||
packet_idx = self.cmb_singbox_vless_packet_encoding.findData(packet_v)
|
||||
self.cmb_singbox_vless_packet_encoding.setCurrentIndex(packet_idx if packet_idx >= 0 else 0)
|
||||
|
||||
transport_v = str(payload.get("transport") or "tcp").strip().lower()
|
||||
transport_idx = self.cmb_singbox_vless_transport.findData(transport_v)
|
||||
self.cmb_singbox_vless_transport.setCurrentIndex(transport_idx if transport_idx >= 0 else 0)
|
||||
|
||||
self.ent_singbox_vless_path.setText(str(payload.get("path") or "").strip())
|
||||
self.ent_singbox_vless_grpc_service.setText(str(payload.get("grpc_service") or "").strip())
|
||||
|
||||
sec_v = str(payload.get("security") or "none").strip().lower()
|
||||
sec_idx = self.cmb_singbox_vless_security.findData(sec_v)
|
||||
self.cmb_singbox_vless_security.setCurrentIndex(sec_idx if sec_idx >= 0 else 0)
|
||||
|
||||
self.ent_singbox_vless_sni.setText(str(payload.get("sni") or "").strip())
|
||||
fp_v = str(payload.get("utls_fp") or "").strip().lower()
|
||||
fp_idx = self.cmb_singbox_vless_utls_fp.findData(fp_v)
|
||||
self.cmb_singbox_vless_utls_fp.setCurrentIndex(fp_idx if fp_idx >= 0 else 0)
|
||||
self.ent_singbox_vless_reality_pk.setText(str(payload.get("reality_public_key") or "").strip())
|
||||
self.ent_singbox_vless_reality_sid.setText(str(payload.get("reality_short_id") or "").strip())
|
||||
self.chk_singbox_vless_insecure.setChecked(bool(payload.get("tls_insecure", False)))
|
||||
self.chk_singbox_vless_sniff.setChecked(bool(payload.get("sniff", True)))
|
||||
|
||||
ss_method = str(payload.get("ss_method") or "").strip().lower()
|
||||
if ss_method:
|
||||
idx = self.cmb_singbox_ss_method.findData(ss_method)
|
||||
if idx >= 0:
|
||||
self.cmb_singbox_ss_method.setCurrentIndex(idx)
|
||||
else:
|
||||
self.cmb_singbox_ss_method.setEditText(ss_method)
|
||||
else:
|
||||
self.cmb_singbox_ss_method.setCurrentIndex(0)
|
||||
self.ent_singbox_ss_plugin.setText(str(payload.get("ss_plugin") or "").strip())
|
||||
|
||||
try:
|
||||
self.spn_singbox_hy2_up_mbps.setValue(int(payload.get("hy2_up_mbps") or 0))
|
||||
except Exception:
|
||||
self.spn_singbox_hy2_up_mbps.setValue(0)
|
||||
try:
|
||||
self.spn_singbox_hy2_down_mbps.setValue(int(payload.get("hy2_down_mbps") or 0))
|
||||
except Exception:
|
||||
self.spn_singbox_hy2_down_mbps.setValue(0)
|
||||
self.ent_singbox_hy2_obfs.setText(str(payload.get("hy2_obfs") or "").strip())
|
||||
self.ent_singbox_hy2_obfs_password.setText(str(payload.get("hy2_obfs_password") or "").strip())
|
||||
|
||||
tuic_cc = str(payload.get("tuic_congestion") or "").strip()
|
||||
idx = self.cmb_singbox_tuic_congestion.findData(tuic_cc)
|
||||
self.cmb_singbox_tuic_congestion.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
tuic_udp = str(payload.get("tuic_udp_mode") or "").strip()
|
||||
idx = self.cmb_singbox_tuic_udp_mode.findData(tuic_udp)
|
||||
self.cmb_singbox_tuic_udp_mode.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self.chk_singbox_tuic_zero_rtt.setChecked(bool(payload.get("tuic_zero_rtt", False)))
|
||||
|
||||
self.ent_singbox_wg_private_key.setText(str(payload.get("wg_private_key") or "").strip())
|
||||
self.ent_singbox_wg_peer_public_key.setText(str(payload.get("wg_peer_public_key") or "").strip())
|
||||
self.ent_singbox_wg_psk.setText(str(payload.get("wg_psk") or "").strip())
|
||||
wg_local = payload.get("wg_local_address") or []
|
||||
if isinstance(wg_local, list):
|
||||
self.ent_singbox_wg_local_address.setText(
|
||||
",".join([str(x).strip() for x in wg_local if str(x).strip()])
|
||||
)
|
||||
else:
|
||||
self.ent_singbox_wg_local_address.setText(str(wg_local or "").strip())
|
||||
wg_reserved = payload.get("wg_reserved") or []
|
||||
if isinstance(wg_reserved, list):
|
||||
self.ent_singbox_wg_reserved.setText(
|
||||
",".join([str(x).strip() for x in wg_reserved if str(x).strip()])
|
||||
)
|
||||
else:
|
||||
self.ent_singbox_wg_reserved.setText(str(wg_reserved or "").strip())
|
||||
try:
|
||||
self.spn_singbox_wg_mtu.setValue(int(payload.get("wg_mtu") or 0))
|
||||
except Exception:
|
||||
self.spn_singbox_wg_mtu.setValue(0)
|
||||
finally:
|
||||
self._singbox_editor_loading = False
|
||||
self.on_singbox_vless_editor_changed()
|
||||
|
||||
def _create_singbox_connection(
|
||||
self,
|
||||
*,
|
||||
profile_name: str,
|
||||
protocol: str = "vless",
|
||||
raw_config: dict[str, Any] | None = None,
|
||||
editor_values: dict[str, Any] | None = None,
|
||||
auto_save: bool = False,
|
||||
) -> str:
|
||||
name = str(profile_name or "").strip() or "SingBox connection"
|
||||
client_id = self._next_free_transport_client_id(name)
|
||||
proto = self._normalized_seed_protocol(protocol)
|
||||
config = self._default_new_singbox_client_config(client_id, protocol=proto)
|
||||
|
||||
created = self.ctrl.transport_client_create_action(
|
||||
client_id=client_id,
|
||||
kind="singbox",
|
||||
name=name,
|
||||
enabled=True,
|
||||
config=config,
|
||||
)
|
||||
line = (created.pretty_text or "").strip() or f"create {client_id}"
|
||||
self._append_transport_log(f"[engine] {line}")
|
||||
self.ctrl.log_gui(f"[transport-engine] {line}")
|
||||
if not created.ok:
|
||||
raise RuntimeError(line)
|
||||
|
||||
self.refresh_transport_engines(silent=True)
|
||||
if not self._select_transport_engine_by_id(client_id):
|
||||
raise RuntimeError(f"created client '{client_id}' was not found after refresh")
|
||||
|
||||
self._sync_selected_singbox_profile_link(silent=False)
|
||||
client, _eid, pid = self._selected_singbox_profile_context()
|
||||
|
||||
seed_raw = raw_config if isinstance(raw_config, dict) else self._seed_raw_config_for_protocol(proto)
|
||||
saved_seed = self.ctrl.singbox_profile_save_raw_for_client(
|
||||
client,
|
||||
profile_id=pid,
|
||||
name=name,
|
||||
enabled=True,
|
||||
protocol=proto,
|
||||
raw_config=seed_raw,
|
||||
)
|
||||
seed_line = (saved_seed.pretty_text or "").strip() or f"save profile {pid}"
|
||||
self._append_transport_log(f"[profile] {seed_line}")
|
||||
self.ctrl.log_gui(f"[singbox-profile] {seed_line}")
|
||||
self._load_singbox_editor_for_selected(silent=True)
|
||||
|
||||
if editor_values:
|
||||
payload = dict(editor_values)
|
||||
seeded = self._seed_editor_values_for_protocol(proto, profile_name=name)
|
||||
seeded.update(payload)
|
||||
payload = seeded
|
||||
if not str(payload.get("profile_name") or "").strip():
|
||||
payload["profile_name"] = name
|
||||
self._apply_singbox_editor_values(payload)
|
||||
if auto_save:
|
||||
saved = self._save_singbox_editor_draft(client, profile_id=pid)
|
||||
save_line = (saved.pretty_text or "").strip() or f"save profile {pid}"
|
||||
self._append_transport_log(f"[profile] {save_line}")
|
||||
self.ctrl.log_gui(f"[singbox-profile] {save_line}")
|
||||
return client_id
|
||||
|
||||
def on_singbox_create_connection_click(self) -> None:
|
||||
menu = QMenu(self)
|
||||
act_clip = menu.addAction("Create from clipboard")
|
||||
act_link = menu.addAction("Create from link...")
|
||||
act_manual = menu.addAction("Create manual")
|
||||
pos = self.btn_singbox_profile_create.mapToGlobal(
|
||||
self.btn_singbox_profile_create.rect().bottomLeft()
|
||||
)
|
||||
chosen = menu.exec(pos)
|
||||
if chosen is None:
|
||||
return
|
||||
|
||||
if chosen == act_clip:
|
||||
self._safe(self.on_singbox_create_connection_from_clipboard, title="Create connection error")
|
||||
return
|
||||
if chosen == act_link:
|
||||
self._safe(self.on_singbox_create_connection_from_link, title="Create connection error")
|
||||
return
|
||||
if chosen == act_manual:
|
||||
self._safe(self.on_singbox_create_connection_manual, title="Create connection error")
|
||||
|
||||
def on_singbox_create_connection_from_clipboard(self) -> None:
|
||||
raw = str(QApplication.clipboard().text() or "").strip()
|
||||
if not raw:
|
||||
raise RuntimeError("Clipboard is empty")
|
||||
payload = self._parse_connection_link_payload(raw)
|
||||
profile_name = str(payload.get("profile_name") or "").strip() or "SingBox Clipboard"
|
||||
cid = self._create_singbox_connection(
|
||||
profile_name=profile_name,
|
||||
protocol=str(payload.get("protocol") or "vless"),
|
||||
raw_config=payload.get("raw_config") if isinstance(payload.get("raw_config"), dict) else None,
|
||||
editor_values=payload.get("editor_values") if isinstance(payload.get("editor_values"), dict) else None,
|
||||
auto_save=True,
|
||||
)
|
||||
self.on_singbox_profile_edit_dialog(cid)
|
||||
|
||||
def on_singbox_create_connection_from_link(self) -> None:
|
||||
raw, ok = QInputDialog.getText(
|
||||
self,
|
||||
"Create connection from link",
|
||||
"Paste connection link (vless:// trojan:// ss:// hysteria2:// hy2:// tuic:// wireguard://):",
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
payload = self._parse_connection_link_payload(raw)
|
||||
profile_name = str(payload.get("profile_name") or "").strip() or "SingBox Link"
|
||||
cid = self._create_singbox_connection(
|
||||
profile_name=profile_name,
|
||||
protocol=str(payload.get("protocol") or "vless"),
|
||||
raw_config=payload.get("raw_config") if isinstance(payload.get("raw_config"), dict) else None,
|
||||
editor_values=payload.get("editor_values") if isinstance(payload.get("editor_values"), dict) else None,
|
||||
auto_save=True,
|
||||
)
|
||||
self.on_singbox_profile_edit_dialog(cid)
|
||||
|
||||
def on_singbox_create_connection_manual(self) -> None:
|
||||
name, ok = QInputDialog.getText(
|
||||
self,
|
||||
"Create manual connection",
|
||||
"Connection name:",
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
profile_name = str(name or "").strip() or "SingBox Manual"
|
||||
proto_title, ok = QInputDialog.getItem(
|
||||
self,
|
||||
"Create manual connection",
|
||||
"Protocol:",
|
||||
[label for label, _pid in SINGBOX_EDITOR_PROTOCOL_OPTIONS],
|
||||
0,
|
||||
False,
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
proto_map = {label.lower(): pid for label, pid in SINGBOX_EDITOR_PROTOCOL_OPTIONS}
|
||||
proto = self._normalized_seed_protocol(proto_map.get(str(proto_title or "").strip().lower(), "vless"))
|
||||
cid = self._create_singbox_connection(
|
||||
profile_name=profile_name,
|
||||
protocol=proto,
|
||||
editor_values=self._seed_editor_values_for_protocol(proto, profile_name=profile_name),
|
||||
auto_save=False,
|
||||
)
|
||||
self.on_singbox_profile_edit_dialog(cid)
|
||||
337
selective-vpn-gui/main_window/singbox/links_helpers_mixin.py
Normal file
337
selective-vpn-gui/main_window/singbox/links_helpers_mixin.py
Normal file
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import re
|
||||
from urllib.parse import unquote
|
||||
from typing import Any
|
||||
|
||||
from main_window.constants import SINGBOX_EDITOR_PROTOCOL_IDS, SINGBOX_PROTOCOL_SEED_SPEC
|
||||
|
||||
|
||||
class SingBoxLinksHelpersMixin:
|
||||
def _slugify_connection_id(self, text: str) -> str:
|
||||
raw = str(text or "").strip().lower()
|
||||
raw = re.sub(r"[^a-z0-9]+", "-", raw)
|
||||
raw = re.sub(r"-{2,}", "-", raw).strip("-")
|
||||
if not raw:
|
||||
raw = "connection"
|
||||
if not raw.startswith("sg-"):
|
||||
raw = f"sg-{raw}"
|
||||
return raw
|
||||
|
||||
def _next_free_transport_client_id(self, base_hint: str) -> str:
|
||||
base = self._slugify_connection_id(base_hint)
|
||||
existing = {str(getattr(c, "id", "") or "").strip() for c in (self._transport_clients or [])}
|
||||
if base not in existing:
|
||||
return base
|
||||
i = 2
|
||||
while True:
|
||||
cid = f"{base}-{i}"
|
||||
if cid not in existing:
|
||||
return cid
|
||||
i += 1
|
||||
|
||||
def _template_singbox_client(self):
|
||||
selected = self._selected_transport_client()
|
||||
if selected is not None and str(getattr(selected, "kind", "") or "").strip().lower() == "singbox":
|
||||
return selected
|
||||
for c in self._transport_clients or []:
|
||||
if str(getattr(c, "kind", "") or "").strip().lower() == "singbox":
|
||||
return c
|
||||
return None
|
||||
|
||||
def _default_new_singbox_client_config(self, client_id: str, *, protocol: str = "vless") -> dict[str, Any]:
|
||||
cfg: dict[str, Any] = {}
|
||||
tpl = self._template_singbox_client()
|
||||
if tpl is not None:
|
||||
src_cfg = getattr(tpl, "config", {}) or {}
|
||||
if isinstance(src_cfg, dict):
|
||||
for key in (
|
||||
"runner",
|
||||
"runtime_mode",
|
||||
"require_binary",
|
||||
"exec_start",
|
||||
"singbox_bin",
|
||||
"packaging_profile",
|
||||
"packaging_system_fallback",
|
||||
"bin_root",
|
||||
"hardening_enabled",
|
||||
"hardening_profile",
|
||||
"restart",
|
||||
"restart_sec",
|
||||
"watchdog_sec",
|
||||
"start_limit_interval_sec",
|
||||
"start_limit_burst",
|
||||
"timeout_start_sec",
|
||||
"timeout_stop_sec",
|
||||
"bootstrap_bypass_strict",
|
||||
"netns_enabled",
|
||||
"netns_name",
|
||||
"netns_auto_cleanup",
|
||||
"netns_setup_strict",
|
||||
"singbox_dns_migrate_legacy",
|
||||
"singbox_dns_migrate_strict",
|
||||
):
|
||||
if key in src_cfg:
|
||||
cfg[key] = json.loads(json.dumps(src_cfg.get(key)))
|
||||
|
||||
cid = str(client_id or "").strip()
|
||||
if not cid:
|
||||
return cfg
|
||||
|
||||
for key in ("profile", "profile_id", "singbox_profile_id"):
|
||||
cfg.pop(key, None)
|
||||
|
||||
config_path = f"/etc/selective-vpn/transports/{cid}/singbox.json"
|
||||
cfg["config_path"] = config_path
|
||||
cfg["singbox_config_path"] = config_path
|
||||
|
||||
runner = str(cfg.get("runner") or "").strip().lower()
|
||||
if not runner:
|
||||
cfg["runner"] = "systemd"
|
||||
runner = "systemd"
|
||||
|
||||
if runner == "systemd":
|
||||
cfg["unit"] = "singbox@.service"
|
||||
|
||||
if "runtime_mode" not in cfg:
|
||||
cfg["runtime_mode"] = "exec"
|
||||
if "require_binary" not in cfg:
|
||||
cfg["require_binary"] = True
|
||||
|
||||
cfg["profile_id"] = cid
|
||||
cfg["protocol"] = self._normalized_seed_protocol(protocol)
|
||||
return cfg
|
||||
|
||||
def _normalized_seed_protocol(self, protocol: str) -> str:
|
||||
proto = str(protocol or "vless").strip().lower() or "vless"
|
||||
if proto not in SINGBOX_EDITOR_PROTOCOL_IDS:
|
||||
proto = "vless"
|
||||
return proto
|
||||
|
||||
def _protocol_seed_spec(self, protocol: str) -> dict[str, Any]:
|
||||
proto = self._normalized_seed_protocol(protocol)
|
||||
spec = SINGBOX_PROTOCOL_SEED_SPEC.get(proto) or SINGBOX_PROTOCOL_SEED_SPEC.get("vless") or {}
|
||||
if not isinstance(spec, dict):
|
||||
spec = {}
|
||||
return dict(spec)
|
||||
|
||||
def _seed_editor_values_for_protocol(self, protocol: str, *, profile_name: str = "") -> dict[str, Any]:
|
||||
proto = self._normalized_seed_protocol(protocol)
|
||||
spec = self._protocol_seed_spec(proto)
|
||||
security = str(spec.get("security") or "none").strip().lower() or "none"
|
||||
port = int(spec.get("port") or (51820 if proto == "wireguard" else 443))
|
||||
return {
|
||||
"profile_name": str(profile_name or "").strip(),
|
||||
"enabled": True,
|
||||
"protocol": proto,
|
||||
"server": "",
|
||||
"port": port,
|
||||
"uuid": "",
|
||||
"password": "",
|
||||
"flow": "",
|
||||
"packet_encoding": "",
|
||||
"transport": "tcp",
|
||||
"path": "",
|
||||
"grpc_service": "",
|
||||
"security": security,
|
||||
"sni": "",
|
||||
"utls_fp": "",
|
||||
"reality_public_key": "",
|
||||
"reality_short_id": "",
|
||||
"tls_insecure": False,
|
||||
"sniff": True,
|
||||
"ss_method": "aes-128-gcm",
|
||||
"ss_plugin": "",
|
||||
"hy2_up_mbps": 0,
|
||||
"hy2_down_mbps": 0,
|
||||
"hy2_obfs": "",
|
||||
"hy2_obfs_password": "",
|
||||
"tuic_congestion": "",
|
||||
"tuic_udp_mode": "",
|
||||
"tuic_zero_rtt": False,
|
||||
"wg_private_key": "",
|
||||
"wg_peer_public_key": "",
|
||||
"wg_psk": "",
|
||||
"wg_local_address": "",
|
||||
"wg_reserved": "",
|
||||
"wg_mtu": 0,
|
||||
}
|
||||
|
||||
def _seed_raw_config_for_protocol(self, protocol: str) -> dict[str, Any]:
|
||||
proto = self._normalized_seed_protocol(protocol)
|
||||
spec = self._protocol_seed_spec(proto)
|
||||
port = int(spec.get("port") or (51820 if proto == "wireguard" else 443))
|
||||
proxy: dict[str, Any] = {
|
||||
"type": proto,
|
||||
"tag": "proxy",
|
||||
"server": "",
|
||||
"server_port": port,
|
||||
}
|
||||
proxy_defaults = spec.get("proxy_defaults") or {}
|
||||
if isinstance(proxy_defaults, dict):
|
||||
for key, value in proxy_defaults.items():
|
||||
proxy[key] = json.loads(json.dumps(value))
|
||||
|
||||
tls_security = str(spec.get("tls_security") or "").strip().lower()
|
||||
if tls_security in ("tls", "reality"):
|
||||
self._apply_proxy_tls(proxy, security=tls_security)
|
||||
return self._build_singbox_raw_config_from_proxy(proxy, sniff=True)
|
||||
|
||||
def _parse_wg_reserved_values(self, raw_values: list[str], *, strict: bool) -> list[int]:
|
||||
vals = [str(x).strip() for x in list(raw_values or []) if str(x).strip()]
|
||||
if len(vals) > 3:
|
||||
if strict:
|
||||
raise RuntimeError("WG reserved accepts up to 3 values (0..255)")
|
||||
vals = vals[:3]
|
||||
|
||||
out: list[int] = []
|
||||
for token in vals:
|
||||
try:
|
||||
num = int(token)
|
||||
except Exception:
|
||||
if strict:
|
||||
raise RuntimeError(f"WG reserved value '{token}' is not an integer")
|
||||
continue
|
||||
if num < 0 or num > 255:
|
||||
if strict:
|
||||
raise RuntimeError(f"WG reserved value '{token}' must be in range 0..255")
|
||||
continue
|
||||
out.append(num)
|
||||
return out
|
||||
|
||||
def _query_value(self, query: dict[str, list[str]], *keys: str) -> str:
|
||||
for k in keys:
|
||||
vals = query.get(str(k or "").strip())
|
||||
if not vals:
|
||||
continue
|
||||
v = str(vals[0] or "").strip()
|
||||
if v:
|
||||
return unquote(v)
|
||||
return ""
|
||||
|
||||
def _query_bool(self, query: dict[str, list[str]], *keys: str) -> bool:
|
||||
v = self._query_value(query, *keys).strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
def _query_csv(self, query: dict[str, list[str]], *keys: str) -> list[str]:
|
||||
raw = self._query_value(query, *keys)
|
||||
if not raw:
|
||||
return []
|
||||
out: list[str] = []
|
||||
for p in raw.split(","):
|
||||
val = str(p or "").strip()
|
||||
if val:
|
||||
out.append(val)
|
||||
return out
|
||||
|
||||
def _normalize_link_transport(self, value: str) -> str:
|
||||
v = str(value or "").strip().lower() or "tcp"
|
||||
if v == "raw":
|
||||
v = "tcp"
|
||||
if v in ("h2", "http2"):
|
||||
v = "http"
|
||||
if v not in ("tcp", "ws", "grpc", "http", "httpupgrade", "quic"):
|
||||
v = "tcp"
|
||||
return v
|
||||
|
||||
def _b64_urlsafe_decode(self, value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
pad = "=" * ((4 - (len(raw) % 4)) % 4)
|
||||
try:
|
||||
data = base64.urlsafe_b64decode((raw + pad).encode("utf-8"))
|
||||
return data.decode("utf-8", errors="replace")
|
||||
except (binascii.Error, ValueError):
|
||||
return ""
|
||||
|
||||
def _apply_proxy_transport(
|
||||
self,
|
||||
proxy: dict[str, Any],
|
||||
*,
|
||||
transport: str,
|
||||
path: str = "",
|
||||
grpc_service: str = "",
|
||||
) -> None:
|
||||
t = self._normalize_link_transport(transport)
|
||||
if t in ("", "tcp"):
|
||||
proxy.pop("transport", None)
|
||||
return
|
||||
tx: dict[str, Any] = {"type": t}
|
||||
if t in ("ws", "http", "httpupgrade"):
|
||||
tx["path"] = str(path or "/").strip() or "/"
|
||||
if t == "grpc":
|
||||
tx["service_name"] = str(grpc_service or "").strip()
|
||||
proxy["transport"] = tx
|
||||
|
||||
def _apply_proxy_tls(
|
||||
self,
|
||||
proxy: dict[str, Any],
|
||||
*,
|
||||
security: str,
|
||||
sni: str = "",
|
||||
utls_fp: str = "",
|
||||
tls_insecure: bool = False,
|
||||
reality_public_key: str = "",
|
||||
reality_short_id: str = "",
|
||||
alpn: list[str] | None = None,
|
||||
) -> None:
|
||||
sec = str(security or "").strip().lower()
|
||||
if sec not in ("none", "tls", "reality"):
|
||||
sec = "none"
|
||||
if sec == "none":
|
||||
proxy.pop("tls", None)
|
||||
return
|
||||
tls: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"insecure": bool(tls_insecure),
|
||||
}
|
||||
if str(sni or "").strip():
|
||||
tls["server_name"] = str(sni).strip()
|
||||
if str(utls_fp or "").strip():
|
||||
tls["utls"] = {"enabled": True, "fingerprint": str(utls_fp).strip().lower()}
|
||||
alpn_vals = [str(x).strip() for x in list(alpn or []) if str(x).strip()]
|
||||
if alpn_vals:
|
||||
tls["alpn"] = alpn_vals
|
||||
if sec == "reality":
|
||||
reality: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"public_key": str(reality_public_key or "").strip(),
|
||||
}
|
||||
sid = str(reality_short_id or "").strip()
|
||||
if sid:
|
||||
reality["short_id"] = sid
|
||||
tls["reality"] = reality
|
||||
proxy["tls"] = tls
|
||||
|
||||
def _build_singbox_raw_config_from_proxy(
|
||||
self,
|
||||
proxy: dict[str, Any],
|
||||
*,
|
||||
sniff: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "socks",
|
||||
"tag": "socks-in",
|
||||
"listen": "127.0.0.1",
|
||||
"listen_port": 10808,
|
||||
"sniff": bool(sniff),
|
||||
"sniff_override_destination": bool(sniff),
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
proxy,
|
||||
{"type": "direct", "tag": "direct"},
|
||||
],
|
||||
"route": {
|
||||
"final": "direct",
|
||||
"rules": [
|
||||
{"inbound": ["socks-in"], "outbound": "proxy"},
|
||||
],
|
||||
},
|
||||
}
|
||||
16
selective-vpn-gui/main_window/singbox/links_mixin.py
Normal file
16
selective-vpn-gui/main_window/singbox/links_mixin.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from main_window.singbox.links_actions_mixin import SingBoxLinksActionsMixin
|
||||
from main_window.singbox.links_helpers_mixin import SingBoxLinksHelpersMixin
|
||||
from main_window.singbox.links_parsers_mixin import SingBoxLinksParsersMixin
|
||||
|
||||
|
||||
class SingBoxLinksMixin(
|
||||
SingBoxLinksActionsMixin,
|
||||
SingBoxLinksParsersMixin,
|
||||
SingBoxLinksHelpersMixin,
|
||||
):
|
||||
"""Facade mixin for SingBox link import/create workflow."""
|
||||
|
||||
|
||||
__all__ = ["SingBoxLinksMixin"]
|
||||
391
selective-vpn-gui/main_window/singbox/links_parsers_mixin.py
Normal file
391
selective-vpn-gui/main_window/singbox/links_parsers_mixin.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import parse_qs, unquote, urlsplit
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SingBoxLinksParsersMixin:
|
||||
def _parse_vless_link_payload(self, link: str) -> dict[str, Any]:
|
||||
u = urlsplit(link)
|
||||
query = parse_qs(u.query or "", keep_blank_values=True)
|
||||
|
||||
uuid = unquote(str(u.username or "").strip())
|
||||
host = str(u.hostname or "").strip()
|
||||
if not uuid:
|
||||
raise RuntimeError("VLESS link has no UUID")
|
||||
if not host:
|
||||
raise RuntimeError("VLESS link has no host")
|
||||
try:
|
||||
port = int(u.port or 443)
|
||||
except Exception:
|
||||
port = 443
|
||||
|
||||
transport = self._normalize_link_transport(self._query_value(query, "type", "transport"))
|
||||
security = self._query_value(query, "security").strip().lower() or "none"
|
||||
if security == "xtls":
|
||||
security = "tls"
|
||||
if security not in ("none", "tls", "reality"):
|
||||
security = "none"
|
||||
|
||||
path = self._query_value(query, "path", "spx")
|
||||
if not path and str(u.path or "").strip() not in ("", "/"):
|
||||
path = unquote(str(u.path or "").strip())
|
||||
grpc_service = self._query_value(query, "serviceName", "service_name")
|
||||
if transport == "grpc" and not grpc_service:
|
||||
grpc_service = self._query_value(query, "path")
|
||||
|
||||
flow = self._query_value(query, "flow")
|
||||
packet_encoding = self._query_value(query, "packetEncoding", "packet_encoding").strip().lower()
|
||||
if packet_encoding in ("none", "off", "false"):
|
||||
packet_encoding = ""
|
||||
sni = self._query_value(query, "sni", "host")
|
||||
utls_fp = self._query_value(query, "fp", "fingerprint")
|
||||
reality_pk = self._query_value(query, "pbk", "public_key")
|
||||
reality_sid = self._query_value(query, "sid", "short_id")
|
||||
tls_insecure = self._query_bool(query, "allowInsecure", "insecure")
|
||||
profile_name = unquote(str(u.fragment or "").strip()) or host
|
||||
|
||||
proxy: dict[str, Any] = {
|
||||
"type": "vless",
|
||||
"tag": "proxy",
|
||||
"server": host,
|
||||
"server_port": port,
|
||||
"uuid": uuid,
|
||||
}
|
||||
if packet_encoding:
|
||||
proxy["packet_encoding"] = packet_encoding
|
||||
if flow:
|
||||
proxy["flow"] = flow
|
||||
self._apply_proxy_transport(proxy, transport=transport, path=path, grpc_service=grpc_service)
|
||||
self._apply_proxy_tls(
|
||||
proxy,
|
||||
security=security,
|
||||
sni=sni,
|
||||
utls_fp=utls_fp,
|
||||
tls_insecure=tls_insecure,
|
||||
reality_public_key=reality_pk,
|
||||
reality_short_id=reality_sid,
|
||||
)
|
||||
|
||||
editor_values = {
|
||||
"profile_name": profile_name,
|
||||
"enabled": True,
|
||||
"server": host,
|
||||
"port": port,
|
||||
"uuid": uuid,
|
||||
"flow": flow,
|
||||
"packet_encoding": packet_encoding,
|
||||
"transport": transport,
|
||||
"path": path,
|
||||
"grpc_service": grpc_service,
|
||||
"security": security,
|
||||
"sni": sni,
|
||||
"utls_fp": utls_fp,
|
||||
"reality_public_key": reality_pk,
|
||||
"reality_short_id": reality_sid,
|
||||
"tls_insecure": tls_insecure,
|
||||
"sniff": True,
|
||||
}
|
||||
return {
|
||||
"protocol": "vless",
|
||||
"profile_name": profile_name,
|
||||
"raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True),
|
||||
"editor_values": editor_values,
|
||||
}
|
||||
|
||||
def _parse_trojan_link_payload(self, link: str) -> dict[str, Any]:
|
||||
u = urlsplit(link)
|
||||
query = parse_qs(u.query or "", keep_blank_values=True)
|
||||
password = unquote(str(u.username or "").strip()) or self._query_value(query, "password")
|
||||
host = str(u.hostname or "").strip()
|
||||
if not password:
|
||||
raise RuntimeError("Trojan link has no password")
|
||||
if not host:
|
||||
raise RuntimeError("Trojan link has no host")
|
||||
try:
|
||||
port = int(u.port or 443)
|
||||
except Exception:
|
||||
port = 443
|
||||
|
||||
transport = self._normalize_link_transport(self._query_value(query, "type", "transport"))
|
||||
path = self._query_value(query, "path")
|
||||
grpc_service = self._query_value(query, "serviceName", "service_name")
|
||||
security = self._query_value(query, "security").strip().lower() or "tls"
|
||||
if security not in ("none", "tls"):
|
||||
security = "tls"
|
||||
sni = self._query_value(query, "sni", "host")
|
||||
utls_fp = self._query_value(query, "fp", "fingerprint")
|
||||
tls_insecure = self._query_bool(query, "allowInsecure", "insecure")
|
||||
alpn = self._query_csv(query, "alpn")
|
||||
profile_name = unquote(str(u.fragment or "").strip()) or host
|
||||
|
||||
proxy: dict[str, Any] = {
|
||||
"type": "trojan",
|
||||
"tag": "proxy",
|
||||
"server": host,
|
||||
"server_port": port,
|
||||
"password": password,
|
||||
}
|
||||
self._apply_proxy_transport(proxy, transport=transport, path=path, grpc_service=grpc_service)
|
||||
self._apply_proxy_tls(
|
||||
proxy,
|
||||
security=security,
|
||||
sni=sni,
|
||||
utls_fp=utls_fp,
|
||||
tls_insecure=tls_insecure,
|
||||
alpn=alpn,
|
||||
)
|
||||
return {
|
||||
"protocol": "trojan",
|
||||
"profile_name": profile_name,
|
||||
"raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True),
|
||||
}
|
||||
|
||||
def _parse_ss_link_payload(self, link: str) -> dict[str, Any]:
|
||||
raw = str(link or "").strip()
|
||||
u = urlsplit(raw)
|
||||
query = parse_qs(u.query or "", keep_blank_values=True)
|
||||
profile_name = unquote(str(u.fragment or "").strip()) or "Shadowsocks"
|
||||
|
||||
body = raw[len("ss://"):]
|
||||
body = body.split("#", 1)[0]
|
||||
body = body.split("?", 1)[0]
|
||||
method = ""
|
||||
password = ""
|
||||
host_port = ""
|
||||
|
||||
if "@" in body:
|
||||
left, host_port = body.rsplit("@", 1)
|
||||
creds = left
|
||||
if ":" not in creds:
|
||||
creds = self._b64_urlsafe_decode(creds)
|
||||
if ":" not in creds:
|
||||
raise RuntimeError("Shadowsocks link has invalid credentials")
|
||||
method, password = creds.split(":", 1)
|
||||
else:
|
||||
decoded = self._b64_urlsafe_decode(body)
|
||||
if "@" not in decoded:
|
||||
raise RuntimeError("Shadowsocks link has invalid payload")
|
||||
creds, host_port = decoded.rsplit("@", 1)
|
||||
if ":" not in creds:
|
||||
raise RuntimeError("Shadowsocks link has invalid credentials")
|
||||
method, password = creds.split(":", 1)
|
||||
|
||||
hp = urlsplit("//" + host_port)
|
||||
host = str(hp.hostname or "").strip()
|
||||
if not host:
|
||||
raise RuntimeError("Shadowsocks link has no host")
|
||||
try:
|
||||
port = int(hp.port or 8388)
|
||||
except Exception:
|
||||
port = 8388
|
||||
|
||||
proxy: dict[str, Any] = {
|
||||
"type": "shadowsocks",
|
||||
"tag": "proxy",
|
||||
"server": host,
|
||||
"server_port": port,
|
||||
"method": str(method or "").strip(),
|
||||
"password": str(password or "").strip(),
|
||||
}
|
||||
plugin = self._query_value(query, "plugin")
|
||||
if plugin:
|
||||
proxy["plugin"] = plugin
|
||||
return {
|
||||
"protocol": "shadowsocks",
|
||||
"profile_name": profile_name,
|
||||
"raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True),
|
||||
}
|
||||
|
||||
def _parse_hysteria2_link_payload(self, link: str) -> dict[str, Any]:
|
||||
u = urlsplit(link)
|
||||
query = parse_qs(u.query or "", keep_blank_values=True)
|
||||
password = unquote(str(u.username or "").strip()) or self._query_value(query, "password")
|
||||
host = str(u.hostname or "").strip()
|
||||
if not password:
|
||||
raise RuntimeError("Hysteria2 link has no password")
|
||||
if not host:
|
||||
raise RuntimeError("Hysteria2 link has no host")
|
||||
try:
|
||||
port = int(u.port or 443)
|
||||
except Exception:
|
||||
port = 443
|
||||
profile_name = unquote(str(u.fragment or "").strip()) or host
|
||||
|
||||
proxy: dict[str, Any] = {
|
||||
"type": "hysteria2",
|
||||
"tag": "proxy",
|
||||
"server": host,
|
||||
"server_port": port,
|
||||
"password": password,
|
||||
}
|
||||
up_mbps = self._query_value(query, "up_mbps", "upmbps", "up")
|
||||
down_mbps = self._query_value(query, "down_mbps", "downmbps", "down")
|
||||
try:
|
||||
if up_mbps:
|
||||
proxy["up_mbps"] = int(float(up_mbps))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if down_mbps:
|
||||
proxy["down_mbps"] = int(float(down_mbps))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
obfs_type = self._query_value(query, "obfs")
|
||||
if obfs_type:
|
||||
obfs: dict[str, Any] = {"type": obfs_type}
|
||||
obfs_pw = self._query_value(query, "obfs-password", "obfs_password")
|
||||
if obfs_pw:
|
||||
obfs["password"] = obfs_pw
|
||||
proxy["obfs"] = obfs
|
||||
|
||||
self._apply_proxy_tls(
|
||||
proxy,
|
||||
security="tls",
|
||||
sni=self._query_value(query, "sni"),
|
||||
tls_insecure=self._query_bool(query, "allowInsecure", "insecure"),
|
||||
alpn=self._query_csv(query, "alpn"),
|
||||
)
|
||||
return {
|
||||
"protocol": "hysteria2",
|
||||
"profile_name": profile_name,
|
||||
"raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True),
|
||||
}
|
||||
|
||||
def _parse_tuic_link_payload(self, link: str) -> dict[str, Any]:
|
||||
u = urlsplit(link)
|
||||
query = parse_qs(u.query or "", keep_blank_values=True)
|
||||
uuid = unquote(str(u.username or "").strip())
|
||||
password = unquote(str(u.password or "").strip())
|
||||
host = str(u.hostname or "").strip()
|
||||
if not uuid:
|
||||
raise RuntimeError("TUIC link has no UUID")
|
||||
if not password:
|
||||
raise RuntimeError("TUIC link has no password")
|
||||
if not host:
|
||||
raise RuntimeError("TUIC link has no host")
|
||||
try:
|
||||
port = int(u.port or 443)
|
||||
except Exception:
|
||||
port = 443
|
||||
profile_name = unquote(str(u.fragment or "").strip()) or host
|
||||
|
||||
proxy: dict[str, Any] = {
|
||||
"type": "tuic",
|
||||
"tag": "proxy",
|
||||
"server": host,
|
||||
"server_port": port,
|
||||
"uuid": uuid,
|
||||
"password": password,
|
||||
}
|
||||
cc = self._query_value(query, "congestion_control", "congestion")
|
||||
if cc:
|
||||
proxy["congestion_control"] = cc
|
||||
udp_mode = self._query_value(query, "udp_relay_mode")
|
||||
if udp_mode:
|
||||
proxy["udp_relay_mode"] = udp_mode
|
||||
if self._query_bool(query, "zero_rtt_handshake", "zero_rtt"):
|
||||
proxy["zero_rtt_handshake"] = True
|
||||
|
||||
self._apply_proxy_tls(
|
||||
proxy,
|
||||
security="tls",
|
||||
sni=self._query_value(query, "sni", "host"),
|
||||
utls_fp=self._query_value(query, "fp", "fingerprint"),
|
||||
tls_insecure=self._query_bool(query, "allowInsecure", "insecure"),
|
||||
alpn=self._query_csv(query, "alpn"),
|
||||
)
|
||||
return {
|
||||
"protocol": "tuic",
|
||||
"profile_name": profile_name,
|
||||
"raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True),
|
||||
}
|
||||
|
||||
def _parse_wireguard_link_payload(self, link: str) -> dict[str, Any]:
|
||||
u = urlsplit(link)
|
||||
query = parse_qs(u.query or "", keep_blank_values=True)
|
||||
|
||||
private_key = unquote(str(u.username or "").strip()) or self._query_value(query, "private_key", "privateKey")
|
||||
host = str(u.hostname or "").strip()
|
||||
if not host:
|
||||
raise RuntimeError("WireGuard link has no host")
|
||||
if not private_key:
|
||||
raise RuntimeError("WireGuard link has no private key")
|
||||
try:
|
||||
port = int(u.port or 443)
|
||||
except Exception:
|
||||
port = 443
|
||||
|
||||
peer_public_key = self._query_value(query, "peer_public_key", "public_key", "peerPublicKey")
|
||||
if not peer_public_key:
|
||||
raise RuntimeError("WireGuard link has no peer public key")
|
||||
|
||||
local_address = self._query_csv(query, "local_address", "address", "localAddress")
|
||||
if not local_address:
|
||||
raise RuntimeError("WireGuard link has no local address")
|
||||
|
||||
profile_name = unquote(str(u.fragment or "").strip()) or host
|
||||
proxy: dict[str, Any] = {
|
||||
"type": "wireguard",
|
||||
"tag": "proxy",
|
||||
"server": host,
|
||||
"server_port": port,
|
||||
"private_key": private_key,
|
||||
"peer_public_key": peer_public_key,
|
||||
"local_address": local_address,
|
||||
}
|
||||
psk = self._query_value(query, "pre_shared_key", "psk", "preSharedKey")
|
||||
if psk:
|
||||
proxy["pre_shared_key"] = psk
|
||||
reserved_vals = self._parse_wg_reserved_values(self._query_csv(query, "reserved"), strict=True)
|
||||
if reserved_vals:
|
||||
proxy["reserved"] = reserved_vals
|
||||
mtu_val = self._query_value(query, "mtu")
|
||||
try:
|
||||
mtu = int(mtu_val) if mtu_val else 0
|
||||
except Exception:
|
||||
mtu = 0
|
||||
if mtu > 0:
|
||||
proxy["mtu"] = mtu
|
||||
|
||||
return {
|
||||
"protocol": "wireguard",
|
||||
"profile_name": profile_name,
|
||||
"raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True),
|
||||
}
|
||||
|
||||
def _extract_first_connection_link(self, text: str) -> str:
|
||||
raw = str(text or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
m = re.search(r"(?i)(vless|trojan|ss|hysteria2|hy2|tuic|wireguard|wg)://\S+", raw)
|
||||
if m:
|
||||
return str(m.group(0) or "").strip()
|
||||
if "://" in raw:
|
||||
return raw.splitlines()[0].strip()
|
||||
return ""
|
||||
|
||||
def _parse_connection_link_payload(self, text: str) -> dict[str, Any]:
|
||||
raw = self._extract_first_connection_link(text)
|
||||
if not raw:
|
||||
raise RuntimeError(
|
||||
"No supported link found. Supported schemes: "
|
||||
"vless:// trojan:// ss:// hysteria2:// hy2:// tuic:// wireguard:// wg://"
|
||||
)
|
||||
u = urlsplit(raw)
|
||||
scheme = str(u.scheme or "").strip().lower()
|
||||
if scheme == "vless":
|
||||
return self._parse_vless_link_payload(raw)
|
||||
if scheme == "trojan":
|
||||
return self._parse_trojan_link_payload(raw)
|
||||
if scheme == "ss":
|
||||
return self._parse_ss_link_payload(raw)
|
||||
if scheme in ("hysteria2", "hy2"):
|
||||
return self._parse_hysteria2_link_payload(raw)
|
||||
if scheme == "tuic":
|
||||
return self._parse_tuic_link_payload(raw)
|
||||
if scheme in ("wireguard", "wg"):
|
||||
return self._parse_wireguard_link_payload(raw)
|
||||
raise RuntimeError(f"Unsupported link scheme: {scheme}")
|
||||
136
selective-vpn-gui/main_window/singbox/runtime_cards_mixin.py
Normal file
136
selective-vpn-gui/main_window/singbox/runtime_cards_mixin.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QMenu, QMessageBox
|
||||
|
||||
|
||||
class SingBoxRuntimeCardsMixin:
|
||||
def on_singbox_profile_card_context_menu(self, pos) -> None:
|
||||
item = self.lst_singbox_profile_cards.itemAt(pos)
|
||||
if item is None:
|
||||
return
|
||||
cid = str(item.data(Qt.UserRole) or "").strip()
|
||||
if not cid:
|
||||
return
|
||||
|
||||
menu = QMenu(self)
|
||||
act_run = menu.addAction("Run")
|
||||
act_edit = menu.addAction("Edit")
|
||||
act_delete = menu.addAction("Delete")
|
||||
chosen = menu.exec(self.lst_singbox_profile_cards.viewport().mapToGlobal(pos))
|
||||
if chosen is None:
|
||||
return
|
||||
|
||||
if not self._select_transport_engine_by_id(cid):
|
||||
QMessageBox.warning(self, "SingBox profile", f"Profile '{cid}' is no longer available.")
|
||||
return
|
||||
|
||||
if chosen == act_run:
|
||||
self.on_transport_engine_action("start")
|
||||
return
|
||||
if chosen == act_edit:
|
||||
self.on_singbox_profile_edit_dialog(cid)
|
||||
return
|
||||
if chosen == act_delete:
|
||||
self.on_transport_engine_delete(cid)
|
||||
return
|
||||
|
||||
def on_singbox_profile_card_selected(self) -> None:
|
||||
if self._syncing_singbox_selection:
|
||||
return
|
||||
items = self.lst_singbox_profile_cards.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
cid = str(items[0].data(Qt.UserRole) or "").strip()
|
||||
if not cid:
|
||||
return
|
||||
idx = self.cmb_transport_engine.findData(cid)
|
||||
if idx < 0:
|
||||
return
|
||||
if idx != self.cmb_transport_engine.currentIndex():
|
||||
self._syncing_singbox_selection = True
|
||||
try:
|
||||
self.cmb_transport_engine.setCurrentIndex(idx)
|
||||
finally:
|
||||
self._syncing_singbox_selection = False
|
||||
return
|
||||
self._refresh_singbox_profile_card_styles()
|
||||
self._sync_selected_singbox_profile_link(silent=True)
|
||||
self._load_singbox_editor_for_selected(silent=True)
|
||||
self._update_transport_engine_view()
|
||||
|
||||
def _singbox_value_label(self, key: str, value: str) -> str:
|
||||
v = str(value or "").strip().lower()
|
||||
if key == "routing":
|
||||
if v == "full":
|
||||
return "Full tunnel"
|
||||
return "Selective"
|
||||
if key == "dns":
|
||||
if v == "singbox_dns":
|
||||
return "SingBox DNS"
|
||||
return "System resolver"
|
||||
if key == "killswitch":
|
||||
if v == "off":
|
||||
return "Disabled"
|
||||
return "Enabled"
|
||||
return v or "—"
|
||||
|
||||
def _effective_singbox_policy(self) -> tuple[str, str, str]:
|
||||
route = str(self.cmb_singbox_global_routing.currentData() or "selective").strip().lower()
|
||||
dns = str(self.cmb_singbox_global_dns.currentData() or "system_resolver").strip().lower()
|
||||
killswitch = str(self.cmb_singbox_global_killswitch.currentData() or "on").strip().lower()
|
||||
|
||||
if not self.chk_singbox_profile_use_global_routing.isChecked():
|
||||
route = str(self.cmb_singbox_profile_routing.currentData() or route).strip().lower()
|
||||
if route == "global":
|
||||
route = str(self.cmb_singbox_global_routing.currentData() or "selective").strip().lower()
|
||||
if not self.chk_singbox_profile_use_global_dns.isChecked():
|
||||
dns = str(self.cmb_singbox_profile_dns.currentData() or dns).strip().lower()
|
||||
if dns == "global":
|
||||
dns = str(self.cmb_singbox_global_dns.currentData() or "system_resolver").strip().lower()
|
||||
if not self.chk_singbox_profile_use_global_killswitch.isChecked():
|
||||
killswitch = str(self.cmb_singbox_profile_killswitch.currentData() or killswitch).strip().lower()
|
||||
if killswitch == "global":
|
||||
killswitch = str(self.cmb_singbox_global_killswitch.currentData() or "on").strip().lower()
|
||||
return route, dns, killswitch
|
||||
|
||||
def _refresh_singbox_profile_effective(self) -> None:
|
||||
route, dns, killswitch = self._effective_singbox_policy()
|
||||
route_txt = self._singbox_value_label("routing", route)
|
||||
dns_txt = self._singbox_value_label("dns", dns)
|
||||
kill_txt = self._singbox_value_label("killswitch", killswitch)
|
||||
self.lbl_singbox_profile_effective.setText(
|
||||
f"Effective: routing={route_txt} | dns={dns_txt} | kill-switch={kill_txt}"
|
||||
)
|
||||
self.lbl_singbox_profile_effective.setStyleSheet("color: gray;")
|
||||
|
||||
def _apply_singbox_profile_controls(self) -> None:
|
||||
self.cmb_singbox_profile_routing.setEnabled(
|
||||
not self.chk_singbox_profile_use_global_routing.isChecked()
|
||||
)
|
||||
self.cmb_singbox_profile_dns.setEnabled(
|
||||
not self.chk_singbox_profile_use_global_dns.isChecked()
|
||||
)
|
||||
self.cmb_singbox_profile_killswitch.setEnabled(
|
||||
not self.chk_singbox_profile_use_global_killswitch.isChecked()
|
||||
)
|
||||
self._refresh_singbox_profile_effective()
|
||||
|
||||
def _apply_singbox_compact_visibility(self) -> None:
|
||||
show_profile = bool(self.btn_singbox_toggle_profile_settings.isChecked())
|
||||
self.grp_singbox_profile_settings.setVisible(show_profile)
|
||||
self.btn_singbox_toggle_profile_settings.setText(
|
||||
"Hide profile settings" if show_profile else "Profile settings"
|
||||
)
|
||||
|
||||
show_global = bool(self.btn_singbox_toggle_global_defaults.isChecked())
|
||||
self.grp_singbox_global_defaults.setVisible(show_global)
|
||||
self.btn_singbox_toggle_global_defaults.setText(
|
||||
"Hide global defaults" if show_global else "Global defaults"
|
||||
)
|
||||
|
||||
show_log = bool(self.btn_singbox_toggle_activity.isChecked())
|
||||
self.grp_singbox_activity.setVisible(show_log)
|
||||
self.btn_singbox_toggle_activity.setText(
|
||||
"Hide activity log" if show_log else "Activity log"
|
||||
)
|
||||
16
selective-vpn-gui/main_window/singbox/runtime_mixin.py
Normal file
16
selective-vpn-gui/main_window/singbox/runtime_mixin.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from main_window.singbox.runtime_cards_mixin import SingBoxRuntimeCardsMixin
|
||||
from main_window.singbox.runtime_profiles_mixin import SingBoxRuntimeProfilesMixin
|
||||
from main_window.singbox.runtime_transport_mixin import SingBoxRuntimeTransportMixin
|
||||
|
||||
|
||||
class SingBoxRuntimeMixin(
|
||||
SingBoxRuntimeProfilesMixin,
|
||||
SingBoxRuntimeTransportMixin,
|
||||
SingBoxRuntimeCardsMixin,
|
||||
):
|
||||
"""Facade mixin for SingBox runtime/profile actions."""
|
||||
|
||||
|
||||
__all__ = ["SingBoxRuntimeMixin"]
|
||||
428
selective-vpn-gui/main_window/singbox/runtime_profiles_mixin.py
Normal file
428
selective-vpn-gui/main_window/singbox/runtime_profiles_mixin.py
Normal file
@@ -0,0 +1,428 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
|
||||
class SingBoxRuntimeProfilesMixin:
|
||||
def on_singbox_profile_edit_dialog(self, cid: str = "") -> None:
|
||||
def work():
|
||||
target = str(cid or "").strip() or self._selected_transport_engine_id()
|
||||
if not target:
|
||||
raise RuntimeError("Select a transport engine first")
|
||||
if not self._select_transport_engine_by_id(target):
|
||||
raise RuntimeError(f"Transport engine '{target}' not found")
|
||||
|
||||
self._sync_selected_singbox_profile_link(silent=True)
|
||||
self._load_singbox_editor_for_selected(silent=False)
|
||||
client = self._selected_transport_client()
|
||||
pid = self._selected_singbox_profile_id()
|
||||
if client is None or not pid:
|
||||
raise RuntimeError("Select a SingBox profile first")
|
||||
|
||||
profile_name = self.ent_singbox_proto_name.text().strip() or str(getattr(client, "name", "") or pid).strip()
|
||||
host_layout = self.grp_singbox_profile_settings.layout()
|
||||
if host_layout is None:
|
||||
raise RuntimeError("internal layout is unavailable")
|
||||
editor = self.grp_singbox_proto_editor
|
||||
insert_at = host_layout.indexOf(editor)
|
||||
if insert_at >= 0:
|
||||
host_layout.removeWidget(editor)
|
||||
|
||||
moved = False
|
||||
dlg = QDialog(self)
|
||||
dlg.setModal(True)
|
||||
dlg.setWindowTitle(f"Edit SingBox profile: {profile_name}")
|
||||
dlg.resize(860, 680)
|
||||
dlg_layout = QVBoxLayout(dlg)
|
||||
try:
|
||||
hint = QLabel("Edit protocol fields and save draft. Use profile card menu for Run/Delete.")
|
||||
hint.setStyleSheet("color: gray;")
|
||||
dlg_layout.addWidget(hint)
|
||||
|
||||
editor.setTitle(f"{self._singbox_editor_default_title} · {profile_name}")
|
||||
editor.setParent(dlg)
|
||||
editor.setVisible(True)
|
||||
moved = True
|
||||
dlg_layout.addWidget(editor, stretch=1)
|
||||
|
||||
actions = QHBoxLayout()
|
||||
btn_save = QPushButton("Save draft")
|
||||
btn_close = QPushButton("Close")
|
||||
actions.addWidget(btn_save)
|
||||
actions.addStretch(1)
|
||||
actions.addWidget(btn_close)
|
||||
dlg_layout.addLayout(actions)
|
||||
|
||||
def save_draft_clicked() -> None:
|
||||
try:
|
||||
selected_client, _eid, selected_pid = self._selected_singbox_profile_context()
|
||||
saved = self._save_singbox_editor_draft(selected_client, profile_id=selected_pid)
|
||||
line = (saved.pretty_text or "").strip() or f"save profile {selected_pid}"
|
||||
self._append_transport_log(f"[profile] {line}")
|
||||
self.ctrl.log_gui(f"[singbox-profile] {line}")
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: profile {selected_pid} draft saved")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: green;")
|
||||
self._render_singbox_profile_cards()
|
||||
self._sync_singbox_profile_card_selection(self._selected_transport_engine_id())
|
||||
QMessageBox.information(dlg, "SingBox profile", line)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(dlg, "SingBox profile save error", str(e))
|
||||
|
||||
btn_save.clicked.connect(save_draft_clicked)
|
||||
btn_close.clicked.connect(dlg.accept)
|
||||
dlg.exec()
|
||||
finally:
|
||||
if moved:
|
||||
dlg_layout.removeWidget(editor)
|
||||
editor.setParent(self.grp_singbox_profile_settings)
|
||||
editor.setTitle(self._singbox_editor_default_title)
|
||||
if insert_at >= 0:
|
||||
host_layout.insertWidget(insert_at, editor)
|
||||
else:
|
||||
host_layout.addWidget(editor)
|
||||
editor.setVisible(False)
|
||||
|
||||
self._safe(work, title="SingBox profile edit error")
|
||||
|
||||
def on_transport_engine_action(
|
||||
self,
|
||||
action: Literal["provision", "start", "stop", "restart"],
|
||||
) -> None:
|
||||
def work():
|
||||
cid = self._selected_transport_engine_id()
|
||||
if not cid:
|
||||
raise RuntimeError("Select a transport engine first")
|
||||
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: {action} {cid}...")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
|
||||
QApplication.processEvents()
|
||||
|
||||
if action == "start":
|
||||
selected_client = self._selected_transport_client()
|
||||
if selected_client is not None and str(getattr(selected_client, "kind", "") or "").strip().lower() == "singbox":
|
||||
_client, _eid, pid = self._selected_singbox_profile_context()
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: preparing profile {pid} for start...")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
|
||||
QApplication.processEvents()
|
||||
pre = self.ctrl.singbox_profile_apply_action(
|
||||
pid,
|
||||
client_id=cid,
|
||||
restart=False,
|
||||
skip_runtime=True,
|
||||
check_binary=True,
|
||||
client=selected_client,
|
||||
)
|
||||
pre_line = (pre.pretty_text or "").strip() or f"apply profile {pid}"
|
||||
self._append_transport_log(f"[profile] {pre_line}")
|
||||
self.ctrl.log_gui(f"[singbox-profile] {pre_line}")
|
||||
if not pre.ok:
|
||||
raise RuntimeError(f"profile preflight failed: {pre_line}")
|
||||
|
||||
ok, msg = self._apply_transport_switch_policy(cid)
|
||||
self._append_transport_log(f"[switch] {msg}")
|
||||
self.ctrl.log_gui(f"[transport-switch] {msg}")
|
||||
if not ok:
|
||||
if "canceled by user" in msg.lower():
|
||||
self.refresh_transport_engines(silent=True)
|
||||
return
|
||||
raise RuntimeError(msg)
|
||||
|
||||
res = self.ctrl.transport_client_action(cid, action if action != "start" else "start")
|
||||
line = (res.pretty_text or "").strip() or f"{action} {cid}"
|
||||
self._append_transport_log(f"[engine] {line}")
|
||||
self.ctrl.log_gui(f"[transport-engine] {line}")
|
||||
if not res.ok:
|
||||
raise RuntimeError(line)
|
||||
|
||||
self.refresh_transport_engines(silent=True)
|
||||
self.refresh_status_tab()
|
||||
|
||||
self._safe(work, title="Transport engine error")
|
||||
|
||||
def on_transport_engine_delete(self, cid: str = "") -> None:
|
||||
def work():
|
||||
target = str(cid or "").strip() or self._selected_transport_engine_id()
|
||||
if not target:
|
||||
raise RuntimeError("Select a transport engine first")
|
||||
if not self._select_transport_engine_by_id(target):
|
||||
raise RuntimeError(f"Transport engine '{target}' not found")
|
||||
|
||||
ans = QMessageBox.question(
|
||||
self,
|
||||
"Delete transport profile",
|
||||
(
|
||||
f"Delete profile '{target}'?\n\n"
|
||||
"The client configuration and related runtime artifacts will be removed."
|
||||
),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if ans != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: deleting {target}...")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
|
||||
QApplication.processEvents()
|
||||
|
||||
res = self.ctrl.transport_client_delete_action(target, force=False, cleanup=True)
|
||||
if not res.ok and "force=true" in (res.pretty_text or "").lower():
|
||||
force_ans = QMessageBox.question(
|
||||
self,
|
||||
"Profile is referenced",
|
||||
(
|
||||
"This profile is referenced by current transport policy.\n"
|
||||
"Force delete anyway?"
|
||||
),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if force_ans == QMessageBox.Yes:
|
||||
res = self.ctrl.transport_client_delete_action(target, force=True, cleanup=True)
|
||||
else:
|
||||
self._append_transport_log(f"[engine] delete {target}: canceled by user")
|
||||
self.ctrl.log_gui(f"[transport-engine] delete {target}: canceled by user")
|
||||
return
|
||||
|
||||
line = (res.pretty_text or "").strip() or f"delete {target}"
|
||||
self._append_transport_log(f"[engine] {line}")
|
||||
self.ctrl.log_gui(f"[transport-engine] {line}")
|
||||
if not res.ok:
|
||||
raise RuntimeError(line)
|
||||
|
||||
self.refresh_transport_engines(silent=True)
|
||||
self.refresh_status_tab()
|
||||
|
||||
self._safe(work, title="Transport engine delete error")
|
||||
|
||||
def on_transport_policy_rollback(self) -> None:
|
||||
def work():
|
||||
self.lbl_transport_engine_meta.setText("Engine: rollback policy...")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
|
||||
QApplication.processEvents()
|
||||
|
||||
res = self.ctrl.transport_policy_rollback_action()
|
||||
line = (res.pretty_text or "").strip() or "policy rollback"
|
||||
self._append_transport_log(f"[switch] {line}")
|
||||
self.ctrl.log_gui(f"[transport-switch] {line}")
|
||||
if not res.ok:
|
||||
raise RuntimeError(line)
|
||||
|
||||
self.refresh_transport_engines(silent=True)
|
||||
self.refresh_status_tab()
|
||||
|
||||
self._safe(work, title="Transport rollback error")
|
||||
|
||||
def on_toggle_singbox_profile_settings(self, checked: bool = False) -> None:
|
||||
if checked and self.btn_singbox_toggle_global_defaults.isChecked():
|
||||
self.btn_singbox_toggle_global_defaults.setChecked(False)
|
||||
self._apply_singbox_compact_visibility()
|
||||
self._save_ui_preferences()
|
||||
|
||||
def on_toggle_singbox_global_defaults(self, checked: bool = False) -> None:
|
||||
if checked and self.btn_singbox_toggle_profile_settings.isChecked():
|
||||
self.btn_singbox_toggle_profile_settings.setChecked(False)
|
||||
self._apply_singbox_compact_visibility()
|
||||
self._save_ui_preferences()
|
||||
|
||||
def on_toggle_singbox_activity(self, _checked: bool = False) -> None:
|
||||
self._apply_singbox_compact_visibility()
|
||||
self._save_ui_preferences()
|
||||
|
||||
def on_singbox_profile_scope_changed(self, _state: int = 0) -> None:
|
||||
self._apply_singbox_profile_controls()
|
||||
self._save_ui_preferences()
|
||||
self._update_transport_engine_view()
|
||||
|
||||
def on_singbox_global_defaults_changed(self, _index: int = 0) -> None:
|
||||
self._refresh_singbox_profile_effective()
|
||||
self._save_ui_preferences()
|
||||
self._update_transport_engine_view()
|
||||
|
||||
def on_singbox_global_save(self) -> None:
|
||||
def work():
|
||||
self._save_ui_preferences()
|
||||
route, dns, killswitch = self._effective_singbox_policy()
|
||||
msg = (
|
||||
"Global defaults saved: "
|
||||
f"routing={self._singbox_value_label('routing', route)}, "
|
||||
f"dns={self._singbox_value_label('dns', dns)}, "
|
||||
f"kill-switch={self._singbox_value_label('killswitch', killswitch)}"
|
||||
)
|
||||
self._append_transport_log(f"[profile] {msg}")
|
||||
self.ctrl.log_gui(f"[singbox-settings] {msg}")
|
||||
self._safe(work, title="SingBox settings error")
|
||||
|
||||
def on_singbox_profile_save(self) -> None:
|
||||
def work():
|
||||
client, eid, pid = self._selected_singbox_profile_context()
|
||||
self._save_ui_preferences()
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: saving draft for {pid}...")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
|
||||
QApplication.processEvents()
|
||||
|
||||
saved = self._save_singbox_editor_draft(client, profile_id=pid)
|
||||
save_line = (saved.pretty_text or "").strip() or f"save profile {pid}"
|
||||
self._append_transport_log(f"[profile] {save_line}")
|
||||
self.ctrl.log_gui(f"[singbox-profile] {save_line}")
|
||||
|
||||
route, dns, killswitch = self._effective_singbox_policy()
|
||||
msg = (
|
||||
f"profile settings saved for {eid}: "
|
||||
f"routing={self._singbox_value_label('routing', route)}, "
|
||||
f"dns={self._singbox_value_label('dns', dns)}, "
|
||||
f"kill-switch={self._singbox_value_label('killswitch', killswitch)}"
|
||||
)
|
||||
self._append_transport_log(f"[profile] {msg}")
|
||||
self.ctrl.log_gui(f"[singbox-profile] {msg}")
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: profile {pid} draft saved")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: green;")
|
||||
self.refresh_transport_engines(silent=True)
|
||||
self._safe(work, title="SingBox profile save error")
|
||||
|
||||
def _selected_singbox_profile_context(self):
|
||||
client = self._selected_transport_client()
|
||||
eid = self._selected_transport_engine_id()
|
||||
pid = self._selected_singbox_profile_id()
|
||||
if not eid or client is None:
|
||||
raise RuntimeError("Select a transport engine first")
|
||||
if not pid:
|
||||
raise RuntimeError("Select a SingBox profile first")
|
||||
return client, eid, pid
|
||||
|
||||
def _run_singbox_profile_action(
|
||||
self,
|
||||
*,
|
||||
verb: str,
|
||||
runner,
|
||||
refresh_status: bool = False,
|
||||
sync_draft: bool = False,
|
||||
) -> None:
|
||||
client, eid, pid = self._selected_singbox_profile_context()
|
||||
if sync_draft:
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: syncing draft for {pid}...")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
|
||||
QApplication.processEvents()
|
||||
saved = self._save_singbox_editor_draft(client, profile_id=pid)
|
||||
save_line = (saved.pretty_text or "").strip() or f"save profile {pid}"
|
||||
self._append_transport_log(f"[profile] {save_line}")
|
||||
self.ctrl.log_gui(f"[singbox-profile] {save_line}")
|
||||
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: {verb} profile {pid}...")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
|
||||
QApplication.processEvents()
|
||||
|
||||
res = runner(client, eid, pid)
|
||||
line = (res.pretty_text or "").strip() or f"{verb} profile {pid}"
|
||||
self._append_transport_log(f"[profile] {line}")
|
||||
self.ctrl.log_gui(f"[singbox-profile] {line}")
|
||||
|
||||
if res.ok:
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: profile {pid} {verb} done")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: green;")
|
||||
else:
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: profile {pid} {verb} failed")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
|
||||
|
||||
self.refresh_transport_engines(silent=True)
|
||||
if refresh_status:
|
||||
self.refresh_status_tab()
|
||||
|
||||
def on_singbox_profile_preview(self) -> None:
|
||||
self._safe(
|
||||
lambda: self._run_singbox_profile_action(
|
||||
verb="previewing",
|
||||
runner=lambda client, _eid, pid: self.ctrl.singbox_profile_render_preview_action(
|
||||
pid,
|
||||
check_binary=None,
|
||||
persist=False,
|
||||
client=client,
|
||||
),
|
||||
refresh_status=False,
|
||||
sync_draft=True,
|
||||
),
|
||||
title="SingBox profile preview error",
|
||||
)
|
||||
|
||||
def on_singbox_profile_validate(self) -> None:
|
||||
self._safe(
|
||||
lambda: self._run_singbox_profile_action(
|
||||
verb="validating",
|
||||
runner=lambda client, _eid, pid: self.ctrl.singbox_profile_validate_action(
|
||||
pid,
|
||||
client=client,
|
||||
),
|
||||
refresh_status=False,
|
||||
sync_draft=True,
|
||||
),
|
||||
title="SingBox profile validate error",
|
||||
)
|
||||
|
||||
def on_singbox_profile_apply(self) -> None:
|
||||
self._safe(
|
||||
lambda: self._run_singbox_profile_action(
|
||||
verb="applying",
|
||||
runner=lambda client, eid, pid: self.ctrl.singbox_profile_apply_action(
|
||||
pid,
|
||||
client_id=eid,
|
||||
restart=True,
|
||||
skip_runtime=False,
|
||||
client=client,
|
||||
),
|
||||
refresh_status=True,
|
||||
sync_draft=True,
|
||||
),
|
||||
title="SingBox profile apply error",
|
||||
)
|
||||
|
||||
def on_singbox_profile_rollback(self) -> None:
|
||||
self._safe(
|
||||
lambda: self._run_singbox_profile_action(
|
||||
verb="rolling back",
|
||||
runner=lambda client, eid, pid: self.ctrl.singbox_profile_rollback_action(
|
||||
pid,
|
||||
client_id=eid,
|
||||
restart=True,
|
||||
skip_runtime=False,
|
||||
client=client,
|
||||
),
|
||||
refresh_status=True,
|
||||
),
|
||||
title="SingBox profile rollback error",
|
||||
)
|
||||
|
||||
def on_singbox_profile_history(self) -> None:
|
||||
def work():
|
||||
client, _eid, pid = self._selected_singbox_profile_context()
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: loading history for {pid}...")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
|
||||
QApplication.processEvents()
|
||||
|
||||
lines = self.ctrl.singbox_profile_history_lines(pid, limit=20, client=client)
|
||||
if not lines:
|
||||
line = f"history profile {pid}: no entries"
|
||||
self._append_transport_log(f"[history] {line}")
|
||||
self.ctrl.log_gui(f"[singbox-profile] {line}")
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: history {pid} is empty")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: gray;")
|
||||
return
|
||||
|
||||
header = f"history profile {pid}: {len(lines)} entries"
|
||||
self._append_transport_log(f"[history] {header}")
|
||||
self.ctrl.log_gui(f"[singbox-profile] {header}")
|
||||
for ln in lines:
|
||||
self._append_transport_log(f"[history] {ln}")
|
||||
self.lbl_transport_engine_meta.setText(f"Engine: history loaded for {pid}")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: green;")
|
||||
|
||||
self._safe(work, title="SingBox profile history error")
|
||||
1457
selective-vpn-gui/main_window/singbox/runtime_transport_mixin.py
Normal file
1457
selective-vpn-gui/main_window/singbox/runtime_transport_mixin.py
Normal file
File diff suppressed because it is too large
Load Diff
20
selective-vpn-gui/main_window/singbox_mixin.py
Normal file
20
selective-vpn-gui/main_window/singbox_mixin.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from main_window.singbox import (
|
||||
SingBoxCardsMixin,
|
||||
SingBoxEditorMixin,
|
||||
SingBoxLinksMixin,
|
||||
SingBoxRuntimeMixin,
|
||||
)
|
||||
|
||||
|
||||
class SingBoxMainWindowMixin(
|
||||
SingBoxRuntimeMixin,
|
||||
SingBoxLinksMixin,
|
||||
SingBoxCardsMixin,
|
||||
SingBoxEditorMixin,
|
||||
):
|
||||
"""Facade mixin for backward-compatible MainWindow inheritance."""
|
||||
|
||||
|
||||
__all__ = ["SingBoxMainWindowMixin"]
|
||||
61
selective-vpn-gui/main_window/ui_helpers_mixin.py
Normal file
61
selective-vpn-gui/main_window/ui_helpers_mixin.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from PySide6.QtWidgets import QMessageBox, QPlainTextEdit
|
||||
|
||||
from main_window.constants import _NEXT_CHECK_RE
|
||||
|
||||
|
||||
class UIHelpersMixin:
|
||||
def _safe(self, fn, *, title: str = "Error"):
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e: # pragma: no cover - GUI
|
||||
try:
|
||||
self.ctrl.log_gui(f"[ui-error] {title}: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
QMessageBox.critical(self, title, str(e))
|
||||
return None
|
||||
|
||||
def _set_text(self, widget: QPlainTextEdit, text: str, *, preserve_scroll: bool = False) -> None:
|
||||
"""Set text, optionally сохраняя положение скролла (для trace)."""
|
||||
if not preserve_scroll:
|
||||
widget.setPlainText(text)
|
||||
return
|
||||
|
||||
sb = widget.verticalScrollBar()
|
||||
old_max = sb.maximum()
|
||||
old_val = sb.value()
|
||||
at_end = old_val >= old_max - 2
|
||||
|
||||
widget.setPlainText(text)
|
||||
|
||||
new_max = sb.maximum()
|
||||
if at_end:
|
||||
sb.setValue(new_max)
|
||||
else:
|
||||
# подвинем на ту же относительную позицию, учитывая прирост размера
|
||||
sb.setValue(max(0, min(new_max, old_val+(new_max-old_max))))
|
||||
|
||||
def _append_text(self, widget: QPlainTextEdit, text: str) -> None:
|
||||
cursor = widget.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
cursor.insertText(text)
|
||||
widget.setTextCursor(cursor)
|
||||
widget.ensureCursorVisible()
|
||||
|
||||
def _clean_ui_lines(self, lines) -> str:
|
||||
buf = "\n".join([str(x) for x in (lines or [])]).replace("\r", "\n")
|
||||
out_lines = []
|
||||
for ln in buf.splitlines():
|
||||
t = ln.strip()
|
||||
if not t:
|
||||
continue
|
||||
t2 = _NEXT_CHECK_RE.sub("", t).strip()
|
||||
if not t2:
|
||||
continue
|
||||
out_lines.append(t2)
|
||||
return "\n".join(out_lines).rstrip()
|
||||
448
selective-vpn-gui/main_window/ui_location_runtime_mixin.py
Normal file
448
selective-vpn-gui/main_window/ui_location_runtime_mixin.py
Normal file
@@ -0,0 +1,448 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtCore import QTimer, Qt
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from api_client import ApiError
|
||||
from main_window.constants import LOCATION_TARGET_ROLE
|
||||
from netns_debug import singbox_clients_netns_state, singbox_netns_toggle_button
|
||||
|
||||
|
||||
class UILocationRuntimeMixin:
|
||||
def eventFilter(self, obj, event): # pragma: no cover - GUI
|
||||
cmb = getattr(self, "cmb_locations", None)
|
||||
try:
|
||||
view = cmb.view() if cmb is not None else None
|
||||
except RuntimeError:
|
||||
return super().eventFilter(obj, event)
|
||||
if obj in (cmb, view):
|
||||
if event.type() == QtCore.QEvent.KeyPress:
|
||||
if self._handle_location_keypress(event):
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _handle_location_keypress(self, event) -> bool:
|
||||
key = int(event.key())
|
||||
if key == int(Qt.Key_Backspace):
|
||||
if self._loc_typeahead_buf:
|
||||
self._loc_typeahead_buf = self._loc_typeahead_buf[:-1]
|
||||
self._apply_location_search_filter()
|
||||
self.loc_typeahead_timer.start()
|
||||
self.cmb_locations.showPopup()
|
||||
return True
|
||||
|
||||
if key == int(Qt.Key_Escape):
|
||||
self._reset_location_typeahead()
|
||||
return True
|
||||
|
||||
text = event.text() or ""
|
||||
if len(text) != 1 or not text.isprintable() or text.isspace():
|
||||
return False
|
||||
|
||||
self._loc_typeahead_buf += text.lower()
|
||||
count = self._apply_location_search_filter()
|
||||
if count == 0 and len(self._loc_typeahead_buf) > 1:
|
||||
self._loc_typeahead_buf = text.lower()
|
||||
self._apply_location_search_filter()
|
||||
self.loc_typeahead_timer.start()
|
||||
self.cmb_locations.showPopup()
|
||||
return True
|
||||
|
||||
def _apply_location_search_filter(self) -> int:
|
||||
source = list(self._all_locations or [])
|
||||
query = (self._loc_typeahead_buf or "").strip().lower()
|
||||
|
||||
if not source:
|
||||
self._set_locations_combo_items([])
|
||||
return 0
|
||||
|
||||
items = source
|
||||
if query:
|
||||
items = [
|
||||
row
|
||||
for row in source
|
||||
if self._location_matches(query, row[0], row[1], row[2], row[3])
|
||||
]
|
||||
|
||||
items = self._sort_location_items(items)
|
||||
self._set_locations_combo_items(items)
|
||||
return len(items)
|
||||
|
||||
def _location_matches(
|
||||
self,
|
||||
query: str,
|
||||
label: str,
|
||||
iso: str,
|
||||
target: str,
|
||||
name: str,
|
||||
) -> bool:
|
||||
q = (query or "").strip().lower()
|
||||
if not q:
|
||||
return True
|
||||
|
||||
iso_l = (iso or "").strip().lower()
|
||||
label_l = (label or "").strip().lower()
|
||||
target_l = (target or "").strip().lower()
|
||||
name_l = (name or "").strip().lower()
|
||||
|
||||
if iso_l.startswith(q):
|
||||
return True
|
||||
if target_l.startswith(q) or label_l.startswith(q) or name_l.startswith(q):
|
||||
return True
|
||||
|
||||
tokens = [t for t in re.split(r"[^\w]+", f"{target_l} {name_l} {label_l}") if t]
|
||||
if any(tok.startswith(q) for tok in tokens):
|
||||
return True
|
||||
return q in target_l or q in name_l or q in label_l
|
||||
|
||||
def _sort_location_items(
|
||||
self,
|
||||
items: list[tuple[str, str, str, str, int]],
|
||||
) -> list[tuple[str, str, str, str, int]]:
|
||||
mode = str(self.cmb_locations_sort.currentData() or "ping").strip().lower()
|
||||
if mode == "ping_desc":
|
||||
return sorted(items, key=lambda x: (-x[4], x[3].lower(), x[0].lower()))
|
||||
if mode == "name":
|
||||
return sorted(items, key=lambda x: (x[3].lower(), x[4], x[0].lower()))
|
||||
if mode == "name_desc":
|
||||
return sorted(items, key=lambda x: (x[3].lower(), x[4], x[0].lower()), reverse=True)
|
||||
return sorted(items, key=lambda x: (x[4], x[3].lower(), x[0].lower()))
|
||||
|
||||
def _set_locations_combo_items(self, items: list[tuple[str, str, str, str, int]]) -> None:
|
||||
prev_target = str(self.cmb_locations.currentData(LOCATION_TARGET_ROLE) or "").strip()
|
||||
prev_iso = str(self.cmb_locations.currentData() or "").strip().upper()
|
||||
desired = (self._vpn_desired_location or "").strip()
|
||||
desired_l = desired.lower()
|
||||
|
||||
self.cmb_locations.blockSignals(True)
|
||||
self.cmb_locations.clear()
|
||||
|
||||
pick = -1
|
||||
for i, (label, iso, target, _name, _ping) in enumerate(items):
|
||||
self.cmb_locations.addItem(label, iso)
|
||||
self.cmb_locations.setItemData(i, target, LOCATION_TARGET_ROLE)
|
||||
|
||||
iso_l = (iso or "").strip().lower()
|
||||
target_l = (target or "").strip().lower()
|
||||
if desired_l and desired_l in (iso_l, target_l):
|
||||
pick = i
|
||||
if pick < 0 and prev_target and prev_target == target:
|
||||
pick = i
|
||||
if pick < 0 and prev_iso and prev_iso == iso:
|
||||
pick = i
|
||||
|
||||
if self.cmb_locations.count() > 0:
|
||||
if pick < 0:
|
||||
pick = 0
|
||||
self.cmb_locations.setCurrentIndex(pick)
|
||||
model_index = self.cmb_locations.model().index(pick, 0)
|
||||
self.cmb_locations.view().setCurrentIndex(model_index)
|
||||
|
||||
self.cmb_locations.blockSignals(False)
|
||||
|
||||
def _reset_location_typeahead(self) -> None:
|
||||
self._loc_typeahead_buf = ""
|
||||
self._apply_location_search_filter()
|
||||
|
||||
def _location_name_ping(self, label: str, iso: str, target: str) -> tuple[str, int]:
|
||||
text = (label or "").strip()
|
||||
ping = 1_000_000
|
||||
|
||||
m = re.search(r"\((\d+)\s*ms\)\s*$", text, flags=re.IGNORECASE)
|
||||
if m:
|
||||
try:
|
||||
ping = int(m.group(1))
|
||||
except Exception:
|
||||
ping = 1_000_000
|
||||
text = text[:m.start()].strip()
|
||||
|
||||
iso_pref = (iso or "").strip().upper()
|
||||
pref = iso_pref + " "
|
||||
if iso_pref and text.upper().startswith(pref):
|
||||
text = text[len(pref):].strip()
|
||||
|
||||
name = text or (target or iso_pref or "").strip()
|
||||
return name, ping
|
||||
|
||||
def on_locations_sort_changed(self, _index: int = 0) -> None:
|
||||
self._apply_location_search_filter()
|
||||
self._save_ui_preferences()
|
||||
|
||||
def on_locations_refresh_click(self) -> None:
|
||||
self._safe(self._trigger_locations_refresh, title="Locations refresh error")
|
||||
|
||||
def _trigger_locations_refresh(self) -> None:
|
||||
self.lbl_locations_meta.setText("Locations: refreshing...")
|
||||
self.lbl_locations_meta.setStyleSheet("color: orange;")
|
||||
self._refresh_locations_async(force_refresh=True)
|
||||
|
||||
def _append_transport_log(self, line: str) -> None:
|
||||
msg = (line or "").strip()
|
||||
if not msg:
|
||||
return
|
||||
self._append_text(self.txt_transport, msg + "\n")
|
||||
|
||||
def _singbox_clients_netns_state(self) -> tuple[bool, bool]:
|
||||
return singbox_clients_netns_state(list(self._transport_clients or []))
|
||||
|
||||
def _refresh_transport_netns_toggle_button(self) -> None:
|
||||
all_enabled, any_enabled = self._singbox_clients_netns_state()
|
||||
text, color = singbox_netns_toggle_button(all_enabled, any_enabled)
|
||||
self.btn_transport_netns_toggle.setText(text)
|
||||
self.btn_transport_netns_toggle.setStyleSheet(f"color: {color};")
|
||||
|
||||
def _selected_transport_engine_id(self) -> str:
|
||||
return str(self.cmb_transport_engine.currentData() or "").strip()
|
||||
|
||||
def _selected_transport_client(self):
|
||||
cid = self._selected_transport_engine_id()
|
||||
if not cid:
|
||||
return None
|
||||
for client in self._transport_clients or []:
|
||||
if str(getattr(client, "id", "") or "").strip() == cid:
|
||||
return client
|
||||
return None
|
||||
|
||||
def _transport_live_health_for_client(self, client) -> tuple[str, int, str, str]:
|
||||
status = str(getattr(client, "status", "") or "").strip().lower() or "unknown"
|
||||
latency = int(getattr(getattr(client, "health", None), "latency_ms", 0) or 0)
|
||||
last_error = str(getattr(getattr(client, "health", None), "last_error", "") or "").strip()
|
||||
last_check = str(getattr(getattr(client, "health", None), "last_check", "") or "").strip()
|
||||
cid = str(getattr(client, "id", "") or "").strip()
|
||||
if not cid:
|
||||
return status, latency, last_error, last_check
|
||||
snap = self._transport_health_live.get(cid)
|
||||
if not isinstance(snap, dict):
|
||||
return status, latency, last_error, last_check
|
||||
snap_status = str(snap.get("status") or "").strip().lower()
|
||||
if snap_status:
|
||||
status = snap_status
|
||||
try:
|
||||
snap_latency = int(snap.get("latency_ms") or 0)
|
||||
if snap_latency >= 0:
|
||||
latency = snap_latency
|
||||
except Exception:
|
||||
pass
|
||||
snap_err = str(snap.get("last_error") or "").strip()
|
||||
if snap_err:
|
||||
last_error = snap_err
|
||||
snap_check = str(snap.get("last_check") or "").strip()
|
||||
if snap_check:
|
||||
last_check = snap_check
|
||||
return status, latency, last_error, last_check
|
||||
|
||||
def _country_flag(self, country_code: str) -> str:
|
||||
cc = str(country_code or "").strip().upper()
|
||||
if len(cc) != 2 or not cc.isalpha():
|
||||
return ""
|
||||
try:
|
||||
return "".join(chr(127397 + ord(ch)) for ch in cc)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _refresh_egress_identity_scope(
|
||||
self,
|
||||
scope: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
trigger_refresh: bool = True,
|
||||
min_interval_sec: float = 1.0,
|
||||
silent: bool = True,
|
||||
):
|
||||
scope_key = str(scope or "").strip().lower()
|
||||
if not scope_key:
|
||||
return None
|
||||
|
||||
now = time.monotonic()
|
||||
last = float(self._egress_identity_last_probe_ts.get(scope_key, 0.0) or 0.0)
|
||||
if not force and (now - last) < max(0.2, float(min_interval_sec)):
|
||||
return self._egress_identity_cache.get(scope_key)
|
||||
|
||||
self._egress_identity_last_probe_ts[scope_key] = now
|
||||
try:
|
||||
item = self.ctrl.egress_identity(scope_key, refresh=trigger_refresh)
|
||||
self._egress_identity_cache[scope_key] = item
|
||||
return item
|
||||
except ApiError as e:
|
||||
code = int(getattr(e, "status_code", 0) or 0)
|
||||
if not silent and code != 404:
|
||||
QMessageBox.warning(self, "Egress identity error", str(e))
|
||||
return self._egress_identity_cache.get(scope_key)
|
||||
except Exception as e:
|
||||
if not silent:
|
||||
QMessageBox.warning(self, "Egress identity error", str(e))
|
||||
return self._egress_identity_cache.get(scope_key)
|
||||
|
||||
def _format_egress_identity_short(self, item) -> str:
|
||||
if item is None:
|
||||
return ""
|
||||
ip = str(getattr(item, "ip", "") or "").strip()
|
||||
if not ip:
|
||||
return ""
|
||||
code = str(getattr(item, "country_code", "") or "").strip().upper()
|
||||
flag = self._country_flag(code)
|
||||
if flag:
|
||||
return f"{flag} {ip}"
|
||||
return ip
|
||||
|
||||
def _render_vpn_egress_label(self, item) -> None:
|
||||
if item is None:
|
||||
self.lbl_vpn_egress.setText("Egress: n/a")
|
||||
self.lbl_vpn_egress.setStyleSheet("color: gray;")
|
||||
return
|
||||
|
||||
ip = str(getattr(item, "ip", "") or "").strip()
|
||||
code = str(getattr(item, "country_code", "") or "").strip().upper()
|
||||
name = str(getattr(item, "country_name", "") or "").strip()
|
||||
stale = bool(getattr(item, "stale", False))
|
||||
refreshing = bool(getattr(item, "refresh_in_progress", False))
|
||||
last_error = str(getattr(item, "last_error", "") or "").strip()
|
||||
|
||||
if not ip:
|
||||
if refreshing:
|
||||
self.lbl_vpn_egress.setText("Egress: refreshing...")
|
||||
self.lbl_vpn_egress.setStyleSheet("color: orange;")
|
||||
return
|
||||
if last_error:
|
||||
cut = last_error if len(last_error) <= 120 else last_error[:117] + "..."
|
||||
self.lbl_vpn_egress.setText(f"Egress: n/a ({cut})")
|
||||
self.lbl_vpn_egress.setStyleSheet("color: red;")
|
||||
return
|
||||
self.lbl_vpn_egress.setText("Egress: n/a")
|
||||
self.lbl_vpn_egress.setStyleSheet("color: gray;")
|
||||
return
|
||||
|
||||
flag = self._country_flag(code)
|
||||
prefix = f"{flag} {ip}" if flag else ip
|
||||
tail = ""
|
||||
if name:
|
||||
tail = f" ({name})"
|
||||
elif code:
|
||||
tail = f" ({code})"
|
||||
if stale:
|
||||
tail += " · stale"
|
||||
self.lbl_vpn_egress.setText(f"Egress: {prefix}{tail}")
|
||||
self.lbl_vpn_egress.setStyleSheet("color: orange;" if stale else "color: #1f6b2f;")
|
||||
|
||||
def _poll_vpn_egress_after_switch(self, token: int, attempts_left: int) -> None:
|
||||
if token != self._vpn_egress_refresh_token:
|
||||
return
|
||||
item = self._refresh_egress_identity_scope(
|
||||
"adguardvpn",
|
||||
force=True,
|
||||
trigger_refresh=False,
|
||||
min_interval_sec=0.0,
|
||||
silent=True,
|
||||
)
|
||||
self._render_vpn_egress_label(item)
|
||||
if token != self._vpn_egress_refresh_token:
|
||||
return
|
||||
refresh_in_progress = bool(getattr(item, "refresh_in_progress", False)) if item is not None else True
|
||||
has_ip = bool(str(getattr(item, "ip", "") or "").strip()) if item is not None else False
|
||||
has_country = bool(
|
||||
str(getattr(item, "country_code", "") or "").strip()
|
||||
or str(getattr(item, "country_name", "") or "").strip()
|
||||
) if item is not None else False
|
||||
if attempts_left <= 0:
|
||||
return
|
||||
if has_ip and has_country and not refresh_in_progress and not self._vpn_switching_active:
|
||||
return
|
||||
delay_ms = 450 if attempts_left > 3 else 900
|
||||
QTimer.singleShot(
|
||||
delay_ms,
|
||||
lambda tok=token, left=attempts_left - 1: self._poll_vpn_egress_after_switch(tok, left),
|
||||
)
|
||||
|
||||
def _trigger_vpn_egress_refresh(self, *, reason: str = "") -> None:
|
||||
scope = "adguardvpn"
|
||||
self._vpn_egress_refresh_token += 1
|
||||
token = self._vpn_egress_refresh_token
|
||||
self._egress_identity_last_probe_ts[scope] = 0.0
|
||||
self._vpn_autoloop_refresh_pending = False
|
||||
self._vpn_autoloop_last_force_refresh_ts = time.monotonic()
|
||||
self.lbl_vpn_egress.setText("Egress: refreshing...")
|
||||
self.lbl_vpn_egress.setStyleSheet("color: orange;")
|
||||
try:
|
||||
self.ctrl.egress_identity_refresh(scopes=[scope], force=True)
|
||||
except Exception:
|
||||
pass
|
||||
if reason:
|
||||
try:
|
||||
self.ctrl.log_gui(f"[egress] force refresh: {reason}")
|
||||
except Exception:
|
||||
pass
|
||||
self._poll_vpn_egress_after_switch(token, attempts_left=14)
|
||||
|
||||
def _normalize_vpn_autoloop_state(self, unit_text: str) -> str:
|
||||
low = str(unit_text or "").strip().lower()
|
||||
if ":" in low:
|
||||
low = low.split(":", 1)[1].strip()
|
||||
if "reconnect" in low:
|
||||
return "reconnecting"
|
||||
if "disconnected" in low or "inactive" in low:
|
||||
return "down"
|
||||
if "failed" in low or "error" in low or "dead" in low:
|
||||
return "down"
|
||||
if "connected" in low:
|
||||
return "connected"
|
||||
if "active" in low or "running" in low or "enabled" in low or "up" in low:
|
||||
return "connected"
|
||||
return "unknown"
|
||||
|
||||
def _maybe_trigger_vpn_egress_refresh_on_autoloop(self, unit_text: str) -> None:
|
||||
state = self._normalize_vpn_autoloop_state(unit_text)
|
||||
prev = str(self._vpn_autoloop_last_state or "").strip().lower()
|
||||
now = time.monotonic()
|
||||
|
||||
if state in ("down", "reconnecting", "unknown"):
|
||||
self._vpn_autoloop_refresh_pending = True
|
||||
|
||||
if (
|
||||
state == "connected"
|
||||
and self._vpn_autoloop_refresh_pending
|
||||
and not self._vpn_switching_active
|
||||
and (now - float(self._vpn_autoloop_last_force_refresh_ts or 0.0)) >= 1.0
|
||||
):
|
||||
self._trigger_vpn_egress_refresh(reason=f"autoloop {prev or 'unknown'} -> connected")
|
||||
|
||||
self._vpn_autoloop_last_state = state
|
||||
|
||||
def _refresh_selected_transport_health_live(
|
||||
self,
|
||||
*,
|
||||
force: bool = False,
|
||||
min_interval_sec: float = 0.8,
|
||||
silent: bool = True,
|
||||
) -> bool:
|
||||
if not self._transport_api_supported:
|
||||
return False
|
||||
cid = self._selected_transport_engine_id()
|
||||
if not cid:
|
||||
return False
|
||||
now = time.monotonic()
|
||||
if not force and (now - self._transport_health_last_probe_ts) < max(0.2, float(min_interval_sec)):
|
||||
return False
|
||||
self._transport_health_last_probe_ts = now
|
||||
try:
|
||||
snap = self.ctrl.transport_client_health(cid)
|
||||
except ApiError as e:
|
||||
if not silent and int(getattr(e, "status_code", 0) or 0) != 404:
|
||||
QMessageBox.warning(self, "Transport health error", str(e))
|
||||
return False
|
||||
except Exception as e:
|
||||
if not silent:
|
||||
QMessageBox.warning(self, "Transport health error", str(e))
|
||||
return False
|
||||
self._transport_health_live[cid] = {
|
||||
"status": str(getattr(snap, "status", "") or "").strip().lower(),
|
||||
"latency_ms": int(getattr(snap, "latency_ms", 0) or 0),
|
||||
"last_error": str(getattr(snap, "last_error", "") or "").strip(),
|
||||
"last_check": str(getattr(snap, "last_check", "") or "").strip(),
|
||||
}
|
||||
self._render_singbox_profile_cards()
|
||||
self._sync_singbox_profile_card_selection(cid)
|
||||
self._update_transport_engine_view()
|
||||
16
selective-vpn-gui/main_window/ui_shell_mixin.py
Normal file
16
selective-vpn-gui/main_window/ui_shell_mixin.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from main_window.ui_helpers_mixin import UIHelpersMixin
|
||||
from main_window.ui_location_runtime_mixin import UILocationRuntimeMixin
|
||||
from main_window.ui_tabs_mixin import UITabsMixin
|
||||
|
||||
|
||||
class MainWindowUIShellMixin(
|
||||
UILocationRuntimeMixin,
|
||||
UIHelpersMixin,
|
||||
UITabsMixin,
|
||||
):
|
||||
"""Facade mixin for backward-compatible MainWindow inheritance."""
|
||||
|
||||
|
||||
__all__ = ["MainWindowUIShellMixin"]
|
||||
244
selective-vpn-gui/main_window/ui_tabs_main_mixin.py
Normal file
244
selective-vpn-gui/main_window/ui_tabs_main_mixin.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPlainTextEdit,
|
||||
QListView,
|
||||
QPushButton,
|
||||
QStackedWidget,
|
||||
QStyle,
|
||||
QTabWidget,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QComboBox,
|
||||
)
|
||||
|
||||
|
||||
class UITabsMainMixin:
|
||||
def _build_ui(self) -> None:
|
||||
root = QWidget()
|
||||
root_layout = QVBoxLayout(root)
|
||||
root.setLayout(root_layout)
|
||||
self.setCentralWidget(root)
|
||||
|
||||
# top bar ---------------------------------------------------------
|
||||
top = QHBoxLayout()
|
||||
root_layout.addLayout(top)
|
||||
|
||||
# клик по этому баннеру показывает whoami
|
||||
self.btn_login_banner = QPushButton("AdGuard VPN: —")
|
||||
self.btn_login_banner.setFlat(True)
|
||||
self.btn_login_banner.setStyleSheet(
|
||||
"text-align: left; border: none; color: gray;"
|
||||
)
|
||||
self.btn_login_banner.clicked.connect(self.on_login_banner_clicked)
|
||||
top.addWidget(self.btn_login_banner, stretch=1)
|
||||
|
||||
self.btn_auth = QPushButton("Login")
|
||||
self.btn_auth.clicked.connect(self.on_auth_button)
|
||||
top.addWidget(self.btn_auth)
|
||||
|
||||
self.btn_refresh_all = QPushButton("Refresh all")
|
||||
self.btn_refresh_all.clicked.connect(self.refresh_everything)
|
||||
top.addWidget(self.btn_refresh_all)
|
||||
|
||||
# tabs -------------------------------------------------------------
|
||||
self.tabs = QTabWidget()
|
||||
root_layout.addWidget(self.tabs, stretch=1)
|
||||
|
||||
self._build_tab_status()
|
||||
self._build_tab_vpn()
|
||||
self._build_tab_singbox()
|
||||
self._build_tab_multiif()
|
||||
self._build_tab_routes()
|
||||
self._build_tab_dns()
|
||||
self._build_tab_domains()
|
||||
self._build_tab_trace()
|
||||
|
||||
# ---------------- STATUS TAB ----------------
|
||||
|
||||
def _build_tab_status(self) -> None:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
grid = QFormLayout()
|
||||
layout.addLayout(grid)
|
||||
|
||||
self.st_timestamp = QLabel("—")
|
||||
self.st_counts = QLabel("—")
|
||||
self.st_iface = QLabel("—")
|
||||
self.st_route = QLabel("—")
|
||||
self.st_routes_service = QLabel("—")
|
||||
self.st_smartdns_service = QLabel("—")
|
||||
self.st_vpn_service = QLabel("—")
|
||||
|
||||
grid.addRow("Timestamp:", self.st_timestamp)
|
||||
grid.addRow("Counts:", self.st_counts)
|
||||
grid.addRow("Iface / table / mark:", self.st_iface)
|
||||
grid.addRow("Policy route:", self.st_route)
|
||||
grid.addRow("Routes service:", self.st_routes_service)
|
||||
grid.addRow("SmartDNS:", self.st_smartdns_service)
|
||||
grid.addRow("VPN service:", self.st_vpn_service)
|
||||
|
||||
btns = QHBoxLayout()
|
||||
layout.addLayout(btns)
|
||||
btn_refresh = QPushButton("Refresh")
|
||||
btn_refresh.clicked.connect(self.refresh_status_tab)
|
||||
btns.addWidget(btn_refresh)
|
||||
btns.addStretch(1)
|
||||
|
||||
self.tabs.addTab(tab, "Status")
|
||||
|
||||
# ---------------- VPN TAB ----------------
|
||||
|
||||
def _build_tab_vpn(self) -> None:
|
||||
tab = QWidget()
|
||||
self.tab_vpn = tab # нужно, чтобы переключаться сюда из шапки
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
# stack: main vs login-flow page
|
||||
self.vpn_stack = QStackedWidget()
|
||||
layout.addWidget(self.vpn_stack, stretch=1)
|
||||
|
||||
# ---- main page
|
||||
page_main = QWidget()
|
||||
main_layout = QVBoxLayout(page_main)
|
||||
|
||||
# Autoconnect group
|
||||
auto_group = QGroupBox("Autoconnect (AdGuardVPN autoloop)")
|
||||
auto_layout = QHBoxLayout(auto_group)
|
||||
self.btn_autoconnect_toggle = QPushButton("Enable autoconnect")
|
||||
self.btn_autoconnect_toggle.clicked.connect(self.on_toggle_autoconnect)
|
||||
auto_layout.addWidget(self.btn_autoconnect_toggle)
|
||||
|
||||
auto_layout.addStretch(1)
|
||||
|
||||
# справа текст "unit: active/inactive" с цветом
|
||||
self.lbl_autoconnect_state = QLabel("unit: —")
|
||||
self.lbl_autoconnect_state.setStyleSheet("color: gray;")
|
||||
auto_layout.addWidget(self.lbl_autoconnect_state)
|
||||
|
||||
main_layout.addWidget(auto_group)
|
||||
|
||||
# Locations group
|
||||
loc_group = QGroupBox("Location")
|
||||
loc_layout = QVBoxLayout(loc_group)
|
||||
loc_row = QHBoxLayout()
|
||||
loc_layout.addLayout(loc_row)
|
||||
|
||||
self.cmb_locations = QComboBox()
|
||||
# компактный popup со скроллом, а не на весь экран
|
||||
self.cmb_locations.setMaxVisibleItems(12)
|
||||
self.cmb_locations.setStyleSheet("combobox-popup: 0;")
|
||||
self.cmb_locations.setFocusPolicy(Qt.StrongFocus)
|
||||
view = QListView()
|
||||
view.setUniformItemSizes(True)
|
||||
self.cmb_locations.setView(view)
|
||||
self.cmb_locations.activated.connect(self.on_location_activated)
|
||||
self.cmb_locations.installEventFilter(self)
|
||||
view.installEventFilter(self)
|
||||
|
||||
loc_row.addWidget(self.cmb_locations, stretch=1)
|
||||
|
||||
self.cmb_locations_sort = QComboBox()
|
||||
self.cmb_locations_sort.addItem("Sort: Ping", "ping")
|
||||
self.cmb_locations_sort.addItem("Sort: Ping (slow first)", "ping_desc")
|
||||
self.cmb_locations_sort.addItem("Sort: Name", "name")
|
||||
self.cmb_locations_sort.addItem("Sort: Name (Z-A)", "name_desc")
|
||||
self.cmb_locations_sort.currentIndexChanged.connect(
|
||||
self.on_locations_sort_changed
|
||||
)
|
||||
loc_row.addWidget(self.cmb_locations_sort)
|
||||
|
||||
self.btn_locations_refresh = QToolButton()
|
||||
self.btn_locations_refresh.setAutoRaise(True)
|
||||
self.btn_locations_refresh.setIcon(
|
||||
self.style().standardIcon(QStyle.SP_BrowserReload)
|
||||
)
|
||||
self.btn_locations_refresh.setToolTip("Refresh locations now")
|
||||
self.btn_locations_refresh.setCursor(Qt.PointingHandCursor)
|
||||
self.btn_locations_refresh.setFocusPolicy(Qt.NoFocus)
|
||||
self.btn_locations_refresh.clicked.connect(self.on_locations_refresh_click)
|
||||
loc_row.addWidget(self.btn_locations_refresh)
|
||||
|
||||
self.lbl_locations_meta = QLabel("Locations: loading...")
|
||||
self.lbl_locations_meta.setStyleSheet("color: gray;")
|
||||
loc_layout.addWidget(self.lbl_locations_meta)
|
||||
self.lbl_vpn_egress = QLabel("Egress: n/a")
|
||||
self.lbl_vpn_egress.setStyleSheet("color: gray;")
|
||||
loc_layout.addWidget(self.lbl_vpn_egress)
|
||||
|
||||
main_layout.addWidget(loc_group)
|
||||
|
||||
# Status output
|
||||
self.txt_vpn = QPlainTextEdit()
|
||||
self.txt_vpn.setReadOnly(True)
|
||||
main_layout.addWidget(self.txt_vpn, stretch=1)
|
||||
|
||||
self.vpn_stack.addWidget(page_main)
|
||||
|
||||
# ---- login page
|
||||
page_login = QWidget()
|
||||
lf_layout = QVBoxLayout(page_login)
|
||||
|
||||
top = QHBoxLayout()
|
||||
lf_layout.addLayout(top)
|
||||
|
||||
self.lbl_login_flow_status = QLabel("Status: —")
|
||||
top.addWidget(self.lbl_login_flow_status)
|
||||
self.lbl_login_flow_email = QLabel("")
|
||||
self.lbl_login_flow_email.setStyleSheet("color: gray;")
|
||||
top.addWidget(self.lbl_login_flow_email)
|
||||
top.addStretch(1)
|
||||
|
||||
# URL + buttons row
|
||||
row2 = QHBoxLayout()
|
||||
lf_layout.addLayout(row2)
|
||||
row2.addWidget(QLabel("URL:"))
|
||||
self.edit_login_url = QLineEdit()
|
||||
row2.addWidget(self.edit_login_url, stretch=1)
|
||||
self.btn_login_open = QPushButton("Open")
|
||||
self.btn_login_open.clicked.connect(self.on_login_open)
|
||||
row2.addWidget(self.btn_login_open)
|
||||
self.btn_login_copy = QPushButton("Copy")
|
||||
self.btn_login_copy.clicked.connect(self.on_login_copy)
|
||||
row2.addWidget(self.btn_login_copy)
|
||||
self.btn_login_check = QPushButton("Check")
|
||||
self.btn_login_check.clicked.connect(self.on_login_check)
|
||||
row2.addWidget(self.btn_login_check)
|
||||
self.btn_login_close = QPushButton("Cancel")
|
||||
self.btn_login_close.clicked.connect(self.on_login_cancel)
|
||||
row2.addWidget(self.btn_login_close)
|
||||
self.btn_login_stop = QPushButton("Stop session")
|
||||
self.btn_login_stop.clicked.connect(self.on_login_stop)
|
||||
row2.addWidget(self.btn_login_stop)
|
||||
|
||||
# log text
|
||||
self.txt_login_flow = QPlainTextEdit()
|
||||
self.txt_login_flow.setReadOnly(True)
|
||||
lf_layout.addWidget(self.txt_login_flow, stretch=1)
|
||||
|
||||
# bottom buttons
|
||||
bottom = QHBoxLayout()
|
||||
lf_layout.addLayout(bottom)
|
||||
|
||||
# Start login визуально убираем, но объект оставим на всякий
|
||||
self.btn_login_start = QPushButton("Start login")
|
||||
self.btn_login_start.clicked.connect(self.on_start_login)
|
||||
self.btn_login_start.setVisible(False)
|
||||
bottom.addWidget(self.btn_login_start)
|
||||
|
||||
btn_back = QPushButton("Back to VPN")
|
||||
btn_back.clicked.connect(lambda: self._show_vpn_page("main"))
|
||||
bottom.addWidget(btn_back)
|
||||
bottom.addStretch(1)
|
||||
|
||||
self.vpn_stack.addWidget(page_login)
|
||||
|
||||
self.tabs.addTab(tab, "AdGuardVPN")
|
||||
16
selective-vpn-gui/main_window/ui_tabs_mixin.py
Normal file
16
selective-vpn-gui/main_window/ui_tabs_mixin.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from main_window.ui_tabs_main_mixin import UITabsMainMixin
|
||||
from main_window.ui_tabs_other_mixin import UITabsOtherMixin
|
||||
from main_window.ui_tabs_singbox_mixin import UITabsSingBoxMixin
|
||||
|
||||
|
||||
class UITabsMixin(
|
||||
UITabsOtherMixin,
|
||||
UITabsSingBoxMixin,
|
||||
UITabsMainMixin,
|
||||
):
|
||||
"""Facade mixin for MainWindow tab builders."""
|
||||
|
||||
|
||||
__all__ = ["UITabsMixin"]
|
||||
305
selective-vpn-gui/main_window/ui_tabs_other_mixin.py
Normal file
305
selective-vpn-gui/main_window/ui_tabs_other_mixin.py
Normal file
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QProgressBar,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class UITabsOtherMixin:
|
||||
def _build_tab_routes(self) -> None:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
# --- Service actions ---
|
||||
act_group = QGroupBox("Selective routes service")
|
||||
act_layout = QHBoxLayout(act_group)
|
||||
|
||||
self.btn_routes_start = QPushButton("Start")
|
||||
self.btn_routes_start.clicked.connect(
|
||||
lambda: self.on_routes_action("start")
|
||||
)
|
||||
|
||||
self.btn_routes_restart = QPushButton("Restart")
|
||||
self.btn_routes_restart.clicked.connect(
|
||||
lambda: self.on_routes_action("restart")
|
||||
)
|
||||
|
||||
self.btn_routes_stop = QPushButton("Stop")
|
||||
self.btn_routes_stop.clicked.connect(
|
||||
lambda: self.on_routes_action("stop")
|
||||
)
|
||||
|
||||
act_layout.addWidget(self.btn_routes_start)
|
||||
act_layout.addWidget(self.btn_routes_restart)
|
||||
act_layout.addWidget(self.btn_routes_stop)
|
||||
act_layout.addStretch(1)
|
||||
|
||||
layout.addWidget(act_group)
|
||||
|
||||
# --- Timer / policy route ---
|
||||
timer_group = QGroupBox("Timer")
|
||||
timer_layout = QHBoxLayout(timer_group)
|
||||
|
||||
self.chk_timer = QCheckBox("Enable timer")
|
||||
self.chk_timer.stateChanged.connect(self.on_toggle_timer)
|
||||
timer_layout.addWidget(self.chk_timer)
|
||||
|
||||
self.btn_fix_policy = QPushButton("Fix policy route")
|
||||
self.btn_fix_policy.clicked.connect(self.on_fix_policy_route)
|
||||
timer_layout.addWidget(self.btn_fix_policy)
|
||||
|
||||
timer_layout.addStretch(1)
|
||||
|
||||
layout.addWidget(timer_group)
|
||||
|
||||
# --- Traffic mode relay ---
|
||||
traffic_group = QGroupBox("Traffic mode relay")
|
||||
traffic_layout = QVBoxLayout(traffic_group)
|
||||
|
||||
relay_row = QHBoxLayout()
|
||||
self.btn_traffic_settings = QPushButton("Open traffic settings")
|
||||
self.btn_traffic_settings.clicked.connect(self.on_open_traffic_settings)
|
||||
relay_row.addWidget(self.btn_traffic_settings)
|
||||
self.btn_traffic_test = QPushButton("Test mode")
|
||||
self.btn_traffic_test.clicked.connect(self.on_test_traffic_mode)
|
||||
relay_row.addWidget(self.btn_traffic_test)
|
||||
self.btn_routes_prewarm = QPushButton("Prewarm wildcard now")
|
||||
self.btn_routes_prewarm.setToolTip("""EN: Sends DNS queries for wildcard domains to prefill agvpn_dyn4 before traffic arrives.
|
||||
RU: Делает DNS-запросы wildcard-доменов, чтобы заранее наполнить agvpn_dyn4.""")
|
||||
self.btn_routes_prewarm.clicked.connect(self.on_smartdns_prewarm)
|
||||
relay_row.addWidget(self.btn_routes_prewarm)
|
||||
self.btn_routes_precheck_debug = QPushButton("Debug precheck now")
|
||||
self.btn_routes_precheck_debug.setToolTip("""EN: Debug helper. Arms one-shot resolver precheck and requests routes restart now.
|
||||
RU: Отладочный helper. Включает one-shot precheck резолвера и запрашивает restart routes.""")
|
||||
self.btn_routes_precheck_debug.clicked.connect(self.on_routes_precheck_debug)
|
||||
relay_row.addWidget(self.btn_routes_precheck_debug)
|
||||
relay_row.addStretch(1)
|
||||
traffic_layout.addLayout(relay_row)
|
||||
|
||||
self.chk_routes_prewarm_aggressive = QCheckBox("Aggressive prewarm (use subs)")
|
||||
self.chk_routes_prewarm_aggressive.setToolTip("""EN: Aggressive mode also queries subs list. This can increase DNS load.
|
||||
RU: Агрессивный режим дополнительно дергает subs список. Может увеличить нагрузку на DNS.""")
|
||||
self.chk_routes_prewarm_aggressive.stateChanged.connect(self._on_prewarm_aggressive_changed)
|
||||
traffic_layout.addWidget(self.chk_routes_prewarm_aggressive)
|
||||
|
||||
self.lbl_routes_prewarm_mode = QLabel("Prewarm mode: wildcard-only")
|
||||
self.lbl_routes_prewarm_mode.setStyleSheet("color: gray;")
|
||||
traffic_layout.addWidget(self.lbl_routes_prewarm_mode)
|
||||
self._update_prewarm_mode_label()
|
||||
|
||||
self.lbl_traffic_mode_state = QLabel("Traffic mode: —")
|
||||
self.lbl_traffic_mode_state.setStyleSheet("color: gray;")
|
||||
traffic_layout.addWidget(self.lbl_traffic_mode_state)
|
||||
|
||||
self.lbl_traffic_mode_diag = QLabel("—")
|
||||
self.lbl_traffic_mode_diag.setStyleSheet("color: gray;")
|
||||
traffic_layout.addWidget(self.lbl_traffic_mode_diag)
|
||||
|
||||
self.lbl_routes_resolve_summary = QLabel("Resolve summary: —")
|
||||
self.lbl_routes_resolve_summary.setToolTip("""EN: Parsed from latest 'resolve summary' trace line.
|
||||
RU: Берется из последней строки 'resolve summary' в trace.""")
|
||||
self.lbl_routes_resolve_summary.setStyleSheet("color: gray;")
|
||||
traffic_layout.addWidget(self.lbl_routes_resolve_summary)
|
||||
|
||||
self.lbl_routes_recheck_summary = QLabel("Timeout recheck: —")
|
||||
self.lbl_routes_recheck_summary.setToolTip("""EN: Hidden timeout-recheck counters included in resolve summary.
|
||||
RU: Счетчики скрытого timeout-recheck из итогового resolve summary.""")
|
||||
self.lbl_routes_recheck_summary.setStyleSheet("color: gray;")
|
||||
traffic_layout.addWidget(self.lbl_routes_recheck_summary)
|
||||
|
||||
layout.addWidget(traffic_group)
|
||||
|
||||
# --- NFT progress (agvpn4) ---
|
||||
progress_row = QHBoxLayout()
|
||||
|
||||
self.routes_progress = QProgressBar()
|
||||
self.routes_progress.setRange(0, 100)
|
||||
self.routes_progress.setValue(0)
|
||||
self.routes_progress.setFormat("") # текст выводим отдельным лейблом
|
||||
self.routes_progress.setTextVisible(False)
|
||||
self.routes_progress.setEnabled(False) # idle по умолчанию
|
||||
|
||||
self.lbl_routes_progress = QLabel("NFT: idle")
|
||||
self.lbl_routes_progress.setStyleSheet("color: gray;")
|
||||
|
||||
progress_row.addWidget(self.routes_progress)
|
||||
progress_row.addWidget(self.lbl_routes_progress)
|
||||
|
||||
layout.addLayout(progress_row)
|
||||
|
||||
# --- Log output ---
|
||||
self.txt_routes = QPlainTextEdit()
|
||||
self.txt_routes.setReadOnly(True)
|
||||
layout.addWidget(self.txt_routes, stretch=1)
|
||||
|
||||
self.tabs.addTab(tab, "Routes")
|
||||
|
||||
# ---------------- DNS TAB ----------------
|
||||
|
||||
def _build_tab_dns(self) -> None:
|
||||
tab = QWidget()
|
||||
main_layout = QVBoxLayout(tab)
|
||||
|
||||
tip = QLabel("Tip: hover fields for help. Подсказка: наведи на элементы для описания.")
|
||||
tip.setWordWrap(True)
|
||||
tip.setStyleSheet("color: gray;")
|
||||
main_layout.addWidget(tip)
|
||||
|
||||
resolver_group = QGroupBox("Resolver DNS")
|
||||
resolver_group.setToolTip("""EN: Compact resolver DNS status. Open benchmark to test/apply upstreams.
|
||||
RU: Компактный статус DNS резолвера. Открой benchmark для проверки/применения апстримов.""")
|
||||
resolver_layout = QVBoxLayout(resolver_group)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.btn_dns_benchmark = QPushButton("Open DNS benchmark")
|
||||
self.btn_dns_benchmark.clicked.connect(self.on_open_dns_benchmark)
|
||||
row.addWidget(self.btn_dns_benchmark)
|
||||
row.addStretch(1)
|
||||
resolver_layout.addLayout(row)
|
||||
|
||||
self.lbl_dns_resolver_upstreams = QLabel("Resolver upstreams: default[—, —] meta[—, —]")
|
||||
self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;")
|
||||
resolver_layout.addWidget(self.lbl_dns_resolver_upstreams)
|
||||
|
||||
self.lbl_dns_resolver_health = QLabel("Resolver health: —")
|
||||
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
|
||||
resolver_layout.addWidget(self.lbl_dns_resolver_health)
|
||||
|
||||
main_layout.addWidget(resolver_group)
|
||||
|
||||
smart_group = QGroupBox("SmartDNS")
|
||||
smart_group.setToolTip("""EN: SmartDNS is used for wildcard domains in hybrid mode.
|
||||
RU: SmartDNS используется для wildcard-доменов в hybrid режиме.""")
|
||||
smart_layout = QVBoxLayout(smart_group)
|
||||
|
||||
smart_form = QFormLayout()
|
||||
self.ent_smartdns_addr = QLineEdit()
|
||||
self.ent_smartdns_addr.setToolTip("""EN: SmartDNS address in host#port format (example: 127.0.0.1#6053).
|
||||
RU: Адрес SmartDNS в формате host#port (пример: 127.0.0.1#6053).""")
|
||||
self.ent_smartdns_addr.setPlaceholderText("127.0.0.1#6053")
|
||||
self.ent_smartdns_addr.textEdited.connect(self._schedule_dns_autosave)
|
||||
smart_form.addRow("SmartDNS address", self.ent_smartdns_addr)
|
||||
smart_layout.addLayout(smart_form)
|
||||
|
||||
self.chk_dns_via_smartdns = QCheckBox("Use SmartDNS for wildcard domains")
|
||||
self.chk_dns_via_smartdns.setToolTip("""EN: Hybrid wildcard mode: wildcard domains resolve via SmartDNS, other lists resolve via direct upstreams.
|
||||
RU: Hybrid wildcard режим: wildcard-домены резолвятся через SmartDNS, остальные списки через direct апстримы.""")
|
||||
self.chk_dns_via_smartdns.stateChanged.connect(self.on_dns_mode_toggle)
|
||||
smart_layout.addWidget(self.chk_dns_via_smartdns)
|
||||
|
||||
self.lbl_dns_mode_state = QLabel("Resolver mode: unknown")
|
||||
self.lbl_dns_mode_state.setToolTip("""EN: Current resolver mode reported by API.
|
||||
RU: Текущий режим резолвера по данным API.""")
|
||||
smart_layout.addWidget(self.lbl_dns_mode_state)
|
||||
|
||||
self.chk_dns_unit_relay = QCheckBox("SmartDNS unit relay: OFF")
|
||||
self.chk_dns_unit_relay.setToolTip("""EN: Starts/stops smartdns-local.service. Service state is independent from resolver mode.
|
||||
RU: Запускает/останавливает smartdns-local.service. Состояние сервиса не равно режиму резолвера.""")
|
||||
self.chk_dns_unit_relay.stateChanged.connect(self.on_smartdns_unit_toggle)
|
||||
smart_layout.addWidget(self.chk_dns_unit_relay)
|
||||
|
||||
self.chk_dns_runtime_nftset = QCheckBox("SmartDNS runtime accelerator (nftset -> agvpn_dyn4): ON")
|
||||
self.chk_dns_runtime_nftset.setToolTip("""EN: Optional accelerator: SmartDNS can add resolved IPs to agvpn_dyn4 in runtime (via nftset).
|
||||
EN: Wildcard still works without it (resolver job + prewarm).
|
||||
RU: Опциональный ускоритель: SmartDNS может добавлять IP в agvpn_dyn4 в runtime (через nftset).
|
||||
RU: Wildcard работает и без него (resolver job + prewarm).""")
|
||||
self.chk_dns_runtime_nftset.stateChanged.connect(self.on_smartdns_runtime_toggle)
|
||||
smart_layout.addWidget(self.chk_dns_runtime_nftset)
|
||||
|
||||
self.lbl_dns_wildcard_source = QLabel("Wildcard source: resolver")
|
||||
self.lbl_dns_wildcard_source.setToolTip("""EN: Where wildcard IPs come from: resolver job, SmartDNS runtime nftset, or both.
|
||||
RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, или оба.""")
|
||||
self.lbl_dns_wildcard_source.setStyleSheet("color: gray;")
|
||||
smart_layout.addWidget(self.lbl_dns_wildcard_source)
|
||||
|
||||
main_layout.addWidget(smart_group)
|
||||
main_layout.addStretch(1)
|
||||
|
||||
self.tabs.addTab(tab, "DNS")
|
||||
|
||||
# ---------------- DOMAINS TAB ----------------
|
||||
|
||||
def _build_tab_domains(self) -> None:
|
||||
tab = QWidget()
|
||||
main_layout = QHBoxLayout(tab)
|
||||
|
||||
left = QVBoxLayout()
|
||||
main_layout.addLayout(left)
|
||||
|
||||
left.addWidget(QLabel("Files:"))
|
||||
self.lst_files = QListWidget()
|
||||
for name in (
|
||||
"bases",
|
||||
"meta-special",
|
||||
"subs",
|
||||
"static-ips",
|
||||
"last-ips-map-direct",
|
||||
"last-ips-map-wildcard",
|
||||
"wildcard-observed-hosts",
|
||||
"smartdns.conf",
|
||||
):
|
||||
QListWidgetItem(name, self.lst_files)
|
||||
self.lst_files.setCurrentRow(0)
|
||||
self.lst_files.itemSelectionChanged.connect(self.on_domains_load)
|
||||
left.addWidget(self.lst_files)
|
||||
|
||||
self.btn_domains_save = QPushButton("Save file")
|
||||
self.btn_domains_save.clicked.connect(self.on_domains_save)
|
||||
left.addWidget(self.btn_domains_save)
|
||||
left.addStretch(1)
|
||||
|
||||
right_layout = QVBoxLayout()
|
||||
main_layout.addLayout(right_layout, stretch=1)
|
||||
|
||||
self.lbl_domains_info = QLabel("—")
|
||||
self.lbl_domains_info.setStyleSheet("color: gray;")
|
||||
right_layout.addWidget(self.lbl_domains_info)
|
||||
|
||||
self.txt_domains = QPlainTextEdit()
|
||||
right_layout.addWidget(self.txt_domains, stretch=1)
|
||||
|
||||
self.tabs.addTab(tab, "Domains")
|
||||
|
||||
# ---------------- TRACE TAB ----------------
|
||||
|
||||
def _build_tab_trace(self) -> None:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
top = QHBoxLayout()
|
||||
layout.addLayout(top)
|
||||
|
||||
self.radio_trace_full = QRadioButton("Full")
|
||||
self.radio_trace_full.setChecked(True)
|
||||
self.radio_trace_full.toggled.connect(self.refresh_trace_tab)
|
||||
top.addWidget(self.radio_trace_full)
|
||||
self.radio_trace_gui = QRadioButton("Events")
|
||||
self.radio_trace_gui.toggled.connect(self.refresh_trace_tab)
|
||||
top.addWidget(self.radio_trace_gui)
|
||||
self.radio_trace_smartdns = QRadioButton("SmartDNS")
|
||||
self.radio_trace_smartdns.toggled.connect(self.refresh_trace_tab)
|
||||
top.addWidget(self.radio_trace_smartdns)
|
||||
|
||||
btn_refresh = QPushButton("Refresh")
|
||||
btn_refresh.clicked.connect(self.refresh_trace_tab)
|
||||
top.addWidget(btn_refresh)
|
||||
top.addStretch(1)
|
||||
|
||||
self.txt_trace = QPlainTextEdit()
|
||||
self.txt_trace.setReadOnly(True)
|
||||
layout.addWidget(self.txt_trace, stretch=1)
|
||||
|
||||
self.tabs.addTab(tab, "Trace")
|
||||
420
selective-vpn-gui/main_window/ui_tabs_singbox_editor_mixin.py
Normal file
420
selective-vpn-gui/main_window/ui_tabs_singbox_editor_mixin.py
Normal file
@@ -0,0 +1,420 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSize
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from main_window.constants import SINGBOX_EDITOR_PROTOCOL_IDS, SINGBOX_EDITOR_PROTOCOL_OPTIONS
|
||||
|
||||
|
||||
class UITabsSingBoxEditorMixin:
|
||||
def _build_singbox_vless_editor(self, parent_layout: QVBoxLayout) -> None:
|
||||
grp = QGroupBox("Protocol editor (client)")
|
||||
self.grp_singbox_proto_editor = grp
|
||||
lay = QVBoxLayout(grp)
|
||||
|
||||
self.lbl_singbox_proto_editor_info = QLabel(
|
||||
"Client-side fields only. Server billing/traffic/expiry fields are excluded."
|
||||
)
|
||||
self.lbl_singbox_proto_editor_info.setStyleSheet("color: gray;")
|
||||
lay.addWidget(self.lbl_singbox_proto_editor_info)
|
||||
|
||||
form = QFormLayout()
|
||||
self.frm_singbox_proto_form = form
|
||||
|
||||
self.ent_singbox_proto_name = QLineEdit()
|
||||
self.ent_singbox_proto_name.setPlaceholderText("Profile name")
|
||||
form.addRow("Profile name:", self.ent_singbox_proto_name)
|
||||
|
||||
self.chk_singbox_proto_enabled = QCheckBox("Enabled")
|
||||
self.chk_singbox_proto_enabled.setChecked(True)
|
||||
form.addRow("Enabled:", self.chk_singbox_proto_enabled)
|
||||
|
||||
self.cmb_singbox_proto_protocol = QComboBox()
|
||||
for label, pid in SINGBOX_EDITOR_PROTOCOL_OPTIONS:
|
||||
self.cmb_singbox_proto_protocol.addItem(label, pid)
|
||||
self.cmb_singbox_proto_protocol.currentIndexChanged.connect(
|
||||
self.on_singbox_vless_editor_changed
|
||||
)
|
||||
form.addRow("Protocol:", self.cmb_singbox_proto_protocol)
|
||||
|
||||
self.ent_singbox_vless_server = QLineEdit()
|
||||
self.ent_singbox_vless_server.setPlaceholderText("example.com")
|
||||
form.addRow("Address:", self.ent_singbox_vless_server)
|
||||
|
||||
self.spn_singbox_vless_port = QSpinBox()
|
||||
self.spn_singbox_vless_port.setRange(1, 65535)
|
||||
self.spn_singbox_vless_port.setValue(443)
|
||||
form.addRow("Port:", self.spn_singbox_vless_port)
|
||||
|
||||
self.ent_singbox_vless_uuid = QLineEdit()
|
||||
self.ent_singbox_vless_uuid.setPlaceholderText("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
|
||||
form.addRow("UUID:", self.ent_singbox_vless_uuid)
|
||||
|
||||
self.ent_singbox_proto_password = QLineEdit()
|
||||
self.ent_singbox_proto_password.setPlaceholderText("password")
|
||||
form.addRow("Password:", self.ent_singbox_proto_password)
|
||||
|
||||
self.cmb_singbox_vless_flow = QComboBox()
|
||||
self.cmb_singbox_vless_flow.addItem("None", "")
|
||||
# sing-box v1.12/v1.13 VLESS flow preset; field remains editable for custom/raw values.
|
||||
self.cmb_singbox_vless_flow.addItem("xtls-rprx-vision", "xtls-rprx-vision")
|
||||
self.cmb_singbox_vless_flow.setEditable(True)
|
||||
self.cmb_singbox_vless_flow.setInsertPolicy(QComboBox.NoInsert)
|
||||
form.addRow("Flow:", self.cmb_singbox_vless_flow)
|
||||
|
||||
self.cmb_singbox_vless_packet_encoding = QComboBox()
|
||||
self.cmb_singbox_vless_packet_encoding.addItem("auto", "")
|
||||
self.cmb_singbox_vless_packet_encoding.addItem("xudp", "xudp")
|
||||
form.addRow("Packet encoding:", self.cmb_singbox_vless_packet_encoding)
|
||||
|
||||
self.cmb_singbox_ss_method = QComboBox()
|
||||
self.cmb_singbox_ss_method.setEditable(True)
|
||||
self.cmb_singbox_ss_method.setInsertPolicy(QComboBox.NoInsert)
|
||||
for method in (
|
||||
"aes-128-gcm",
|
||||
"aes-256-gcm",
|
||||
"chacha20-ietf-poly1305",
|
||||
"2022-blake3-aes-128-gcm",
|
||||
"2022-blake3-aes-256-gcm",
|
||||
"none",
|
||||
):
|
||||
self.cmb_singbox_ss_method.addItem(method, method)
|
||||
form.addRow("SS method:", self.cmb_singbox_ss_method)
|
||||
|
||||
self.ent_singbox_ss_plugin = QLineEdit()
|
||||
self.ent_singbox_ss_plugin.setPlaceholderText("obfs-local;obfs=http;obfs-host=example.com")
|
||||
form.addRow("SS plugin:", self.ent_singbox_ss_plugin)
|
||||
|
||||
self.spn_singbox_hy2_up_mbps = QSpinBox()
|
||||
self.spn_singbox_hy2_up_mbps.setRange(0, 100000)
|
||||
form.addRow("HY2 up mbps:", self.spn_singbox_hy2_up_mbps)
|
||||
|
||||
self.spn_singbox_hy2_down_mbps = QSpinBox()
|
||||
self.spn_singbox_hy2_down_mbps.setRange(0, 100000)
|
||||
form.addRow("HY2 down mbps:", self.spn_singbox_hy2_down_mbps)
|
||||
|
||||
self.ent_singbox_hy2_obfs = QLineEdit()
|
||||
self.ent_singbox_hy2_obfs.setPlaceholderText("salamander")
|
||||
form.addRow("HY2 obfs type:", self.ent_singbox_hy2_obfs)
|
||||
|
||||
self.ent_singbox_hy2_obfs_password = QLineEdit()
|
||||
self.ent_singbox_hy2_obfs_password.setPlaceholderText("obfs password")
|
||||
form.addRow("HY2 obfs password:", self.ent_singbox_hy2_obfs_password)
|
||||
|
||||
self.cmb_singbox_tuic_congestion = QComboBox()
|
||||
self.cmb_singbox_tuic_congestion.setEditable(True)
|
||||
self.cmb_singbox_tuic_congestion.setInsertPolicy(QComboBox.NoInsert)
|
||||
self.cmb_singbox_tuic_congestion.addItem("Default", "")
|
||||
self.cmb_singbox_tuic_congestion.addItem("bbr", "bbr")
|
||||
self.cmb_singbox_tuic_congestion.addItem("cubic", "cubic")
|
||||
self.cmb_singbox_tuic_congestion.addItem("new_reno", "new_reno")
|
||||
form.addRow("TUIC congestion:", self.cmb_singbox_tuic_congestion)
|
||||
|
||||
self.cmb_singbox_tuic_udp_mode = QComboBox()
|
||||
self.cmb_singbox_tuic_udp_mode.addItem("Default", "")
|
||||
self.cmb_singbox_tuic_udp_mode.addItem("native", "native")
|
||||
self.cmb_singbox_tuic_udp_mode.addItem("quic", "quic")
|
||||
form.addRow("TUIC UDP relay:", self.cmb_singbox_tuic_udp_mode)
|
||||
|
||||
self.chk_singbox_tuic_zero_rtt = QCheckBox("Enable zero RTT handshake")
|
||||
form.addRow("TUIC zero RTT:", self.chk_singbox_tuic_zero_rtt)
|
||||
|
||||
self.ent_singbox_wg_private_key = QLineEdit()
|
||||
self.ent_singbox_wg_private_key.setPlaceholderText("wireguard private key")
|
||||
self.ent_singbox_wg_private_key.setEchoMode(QLineEdit.PasswordEchoOnEdit)
|
||||
form.addRow("WG private key:", self.ent_singbox_wg_private_key)
|
||||
|
||||
self.ent_singbox_wg_peer_public_key = QLineEdit()
|
||||
self.ent_singbox_wg_peer_public_key.setPlaceholderText("peer public key")
|
||||
self.ent_singbox_wg_peer_public_key.setEchoMode(QLineEdit.PasswordEchoOnEdit)
|
||||
form.addRow("WG peer public key:", self.ent_singbox_wg_peer_public_key)
|
||||
|
||||
self.ent_singbox_wg_psk = QLineEdit()
|
||||
self.ent_singbox_wg_psk.setPlaceholderText("pre-shared key (optional)")
|
||||
self.ent_singbox_wg_psk.setEchoMode(QLineEdit.PasswordEchoOnEdit)
|
||||
form.addRow("WG pre-shared key:", self.ent_singbox_wg_psk)
|
||||
|
||||
self.ent_singbox_wg_local_address = QLineEdit()
|
||||
self.ent_singbox_wg_local_address.setPlaceholderText("10.0.0.2/32,fd00::2/128")
|
||||
form.addRow("WG local address:", self.ent_singbox_wg_local_address)
|
||||
|
||||
self.ent_singbox_wg_reserved = QLineEdit()
|
||||
self.ent_singbox_wg_reserved.setPlaceholderText("0,0,0 (optional)")
|
||||
form.addRow("WG reserved:", self.ent_singbox_wg_reserved)
|
||||
|
||||
self.spn_singbox_wg_mtu = QSpinBox()
|
||||
self.spn_singbox_wg_mtu.setRange(0, 9200)
|
||||
form.addRow("WG MTU:", self.spn_singbox_wg_mtu)
|
||||
|
||||
self.cmb_singbox_vless_transport = QComboBox()
|
||||
self.cmb_singbox_vless_transport.addItem("TCP (RAW)", "tcp")
|
||||
self.cmb_singbox_vless_transport.addItem("WebSocket", "ws")
|
||||
self.cmb_singbox_vless_transport.addItem("gRPC", "grpc")
|
||||
self.cmb_singbox_vless_transport.addItem("HTTP", "http")
|
||||
self.cmb_singbox_vless_transport.addItem("HTTP Upgrade", "httpupgrade")
|
||||
self.cmb_singbox_vless_transport.addItem("QUIC", "quic")
|
||||
self.cmb_singbox_vless_transport.currentIndexChanged.connect(
|
||||
self.on_singbox_vless_editor_changed
|
||||
)
|
||||
form.addRow("Transport:", self.cmb_singbox_vless_transport)
|
||||
|
||||
self.ent_singbox_vless_path = QLineEdit()
|
||||
self.ent_singbox_vless_path.setPlaceholderText("/")
|
||||
form.addRow("Transport path:", self.ent_singbox_vless_path)
|
||||
|
||||
self.ent_singbox_vless_grpc_service = QLineEdit()
|
||||
self.ent_singbox_vless_grpc_service.setPlaceholderText("service-name")
|
||||
form.addRow("gRPC service:", self.ent_singbox_vless_grpc_service)
|
||||
|
||||
self.cmb_singbox_vless_security = QComboBox()
|
||||
self.cmb_singbox_vless_security.addItem("None", "none")
|
||||
self.cmb_singbox_vless_security.addItem("TLS", "tls")
|
||||
self.cmb_singbox_vless_security.addItem("Reality", "reality")
|
||||
self.cmb_singbox_vless_security.currentIndexChanged.connect(
|
||||
self.on_singbox_vless_editor_changed
|
||||
)
|
||||
form.addRow("Security:", self.cmb_singbox_vless_security)
|
||||
|
||||
self.ent_singbox_vless_sni = QLineEdit()
|
||||
self.ent_singbox_vless_sni.setPlaceholderText("www.example.com")
|
||||
form.addRow("SNI:", self.ent_singbox_vless_sni)
|
||||
|
||||
self.ent_singbox_tls_alpn = QLineEdit()
|
||||
self.ent_singbox_tls_alpn.setPlaceholderText("h2,http/1.1")
|
||||
form.addRow("TLS ALPN:", self.ent_singbox_tls_alpn)
|
||||
|
||||
self.cmb_singbox_vless_utls_fp = QComboBox()
|
||||
self.cmb_singbox_vless_utls_fp.addItem("Default", "")
|
||||
self.cmb_singbox_vless_utls_fp.addItem("chrome", "chrome")
|
||||
self.cmb_singbox_vless_utls_fp.addItem("firefox", "firefox")
|
||||
self.cmb_singbox_vless_utls_fp.addItem("safari", "safari")
|
||||
self.cmb_singbox_vless_utls_fp.addItem("edge", "edge")
|
||||
form.addRow("uTLS fingerprint:", self.cmb_singbox_vless_utls_fp)
|
||||
|
||||
self.ent_singbox_vless_reality_pk = QLineEdit()
|
||||
self.ent_singbox_vless_reality_pk.setPlaceholderText("Reality public key")
|
||||
form.addRow("Reality public key:", self.ent_singbox_vless_reality_pk)
|
||||
|
||||
self.ent_singbox_vless_reality_sid = QLineEdit()
|
||||
self.ent_singbox_vless_reality_sid.setPlaceholderText("short_id")
|
||||
form.addRow("Reality short id:", self.ent_singbox_vless_reality_sid)
|
||||
|
||||
self.chk_singbox_vless_insecure = QCheckBox("Allow insecure TLS")
|
||||
form.addRow("TLS insecure:", self.chk_singbox_vless_insecure)
|
||||
|
||||
self.chk_singbox_vless_sniff = QCheckBox("Enable sniffing for local inbound")
|
||||
self.chk_singbox_vless_sniff.setChecked(True)
|
||||
form.addRow("Sniffing:", self.chk_singbox_vless_sniff)
|
||||
|
||||
lay.addLayout(form)
|
||||
|
||||
wg_helpers = QHBoxLayout()
|
||||
self.btn_singbox_wg_paste_private = QToolButton()
|
||||
self.btn_singbox_wg_paste_private.setText("Paste private")
|
||||
self.btn_singbox_wg_paste_private.clicked.connect(
|
||||
lambda: self._paste_line_edit_from_clipboard(self.ent_singbox_wg_private_key)
|
||||
)
|
||||
wg_helpers.addWidget(self.btn_singbox_wg_paste_private)
|
||||
|
||||
self.btn_singbox_wg_copy_private = QToolButton()
|
||||
self.btn_singbox_wg_copy_private.setText("Copy private")
|
||||
self.btn_singbox_wg_copy_private.clicked.connect(
|
||||
lambda: self._copy_line_edit_to_clipboard(self.ent_singbox_wg_private_key)
|
||||
)
|
||||
wg_helpers.addWidget(self.btn_singbox_wg_copy_private)
|
||||
|
||||
self.btn_singbox_wg_paste_peer = QToolButton()
|
||||
self.btn_singbox_wg_paste_peer.setText("Paste peer")
|
||||
self.btn_singbox_wg_paste_peer.clicked.connect(
|
||||
lambda: self._paste_line_edit_from_clipboard(self.ent_singbox_wg_peer_public_key)
|
||||
)
|
||||
wg_helpers.addWidget(self.btn_singbox_wg_paste_peer)
|
||||
|
||||
self.btn_singbox_wg_copy_peer = QToolButton()
|
||||
self.btn_singbox_wg_copy_peer.setText("Copy peer")
|
||||
self.btn_singbox_wg_copy_peer.clicked.connect(
|
||||
lambda: self._copy_line_edit_to_clipboard(self.ent_singbox_wg_peer_public_key)
|
||||
)
|
||||
wg_helpers.addWidget(self.btn_singbox_wg_copy_peer)
|
||||
|
||||
self.btn_singbox_wg_paste_psk = QToolButton()
|
||||
self.btn_singbox_wg_paste_psk.setText("Paste PSK")
|
||||
self.btn_singbox_wg_paste_psk.clicked.connect(
|
||||
lambda: self._paste_line_edit_from_clipboard(self.ent_singbox_wg_psk)
|
||||
)
|
||||
wg_helpers.addWidget(self.btn_singbox_wg_paste_psk)
|
||||
|
||||
self.btn_singbox_wg_copy_psk = QToolButton()
|
||||
self.btn_singbox_wg_copy_psk.setText("Copy PSK")
|
||||
self.btn_singbox_wg_copy_psk.clicked.connect(
|
||||
lambda: self._copy_line_edit_to_clipboard(self.ent_singbox_wg_psk)
|
||||
)
|
||||
wg_helpers.addWidget(self.btn_singbox_wg_copy_psk)
|
||||
wg_helpers.addStretch(1)
|
||||
|
||||
self.wdg_singbox_wg_key_helpers = QWidget()
|
||||
self.wdg_singbox_wg_key_helpers.setLayout(wg_helpers)
|
||||
lay.addWidget(self.wdg_singbox_wg_key_helpers)
|
||||
|
||||
self.lbl_singbox_proto_guardrails = QLabel("Guardrails: address/port/uuid required")
|
||||
self.lbl_singbox_proto_guardrails.setStyleSheet("color: gray;")
|
||||
lay.addWidget(self.lbl_singbox_proto_guardrails)
|
||||
|
||||
parent_layout.addWidget(grp)
|
||||
|
||||
self.on_singbox_vless_editor_changed()
|
||||
|
||||
def _set_proto_form_row_visible(self, field: QWidget, visible: bool) -> None:
|
||||
field.setVisible(visible)
|
||||
label = None
|
||||
form = getattr(self, "frm_singbox_proto_form", None)
|
||||
if form is not None:
|
||||
try:
|
||||
label = form.labelForField(field)
|
||||
except Exception:
|
||||
label = None
|
||||
if label is not None:
|
||||
label.setVisible(visible)
|
||||
|
||||
def _copy_line_edit_to_clipboard(self, field: QLineEdit) -> None:
|
||||
txt = str(field.text() or "").strip()
|
||||
if txt:
|
||||
QApplication.clipboard().setText(txt)
|
||||
|
||||
def _paste_line_edit_from_clipboard(self, field: QLineEdit) -> None:
|
||||
txt = str(QApplication.clipboard().text() or "").strip()
|
||||
field.setText(txt)
|
||||
|
||||
def _current_editor_protocol(self) -> str:
|
||||
return str(self.cmb_singbox_proto_protocol.currentData() or "vless").strip().lower() or "vless"
|
||||
|
||||
def _is_supported_editor_protocol(self, protocol: str) -> bool:
|
||||
return str(protocol or "").strip().lower() in SINGBOX_EDITOR_PROTOCOL_IDS
|
||||
|
||||
def on_singbox_vless_editor_changed(self, _index: int = 0) -> None:
|
||||
protocol = self._current_editor_protocol()
|
||||
self._singbox_editor_protocol = protocol
|
||||
|
||||
transport = str(self.cmb_singbox_vless_transport.currentData() or "tcp").strip().lower()
|
||||
security = str(self.cmb_singbox_vless_security.currentData() or "none").strip().lower()
|
||||
if protocol == "vless":
|
||||
self.cmb_singbox_vless_security.setEnabled(True)
|
||||
elif protocol == "trojan":
|
||||
if security == "reality":
|
||||
idx = self.cmb_singbox_vless_security.findData("tls")
|
||||
self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 1)
|
||||
security = "tls"
|
||||
self.cmb_singbox_vless_security.setEnabled(True)
|
||||
elif protocol in ("hysteria2", "tuic"):
|
||||
idx = self.cmb_singbox_vless_security.findData("tls")
|
||||
self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 1)
|
||||
security = "tls"
|
||||
self.cmb_singbox_vless_security.setEnabled(False)
|
||||
elif protocol == "wireguard":
|
||||
idx = self.cmb_singbox_vless_security.findData("none")
|
||||
self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
security = "none"
|
||||
self.cmb_singbox_vless_security.setEnabled(False)
|
||||
else:
|
||||
idx = self.cmb_singbox_vless_security.findData("none")
|
||||
self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
security = "none"
|
||||
self.cmb_singbox_vless_security.setEnabled(False)
|
||||
|
||||
path_needed = transport in ("ws", "http", "httpupgrade")
|
||||
grpc_needed = transport == "grpc"
|
||||
transport_supported = protocol in ("vless", "trojan")
|
||||
|
||||
self.cmb_singbox_vless_transport.setEnabled(transport_supported)
|
||||
self.ent_singbox_vless_path.setEnabled(transport_supported and path_needed)
|
||||
self.ent_singbox_vless_grpc_service.setEnabled(transport_supported and grpc_needed)
|
||||
|
||||
tls_like = security in ("tls", "reality")
|
||||
reality = security == "reality"
|
||||
|
||||
self.ent_singbox_vless_sni.setEnabled(tls_like)
|
||||
self.ent_singbox_tls_alpn.setEnabled(tls_like)
|
||||
self.cmb_singbox_vless_utls_fp.setEnabled(tls_like)
|
||||
self.chk_singbox_vless_insecure.setEnabled(tls_like)
|
||||
self.ent_singbox_vless_reality_pk.setEnabled(reality)
|
||||
self.ent_singbox_vless_reality_sid.setEnabled(reality)
|
||||
|
||||
show_vless_auth = protocol == "vless"
|
||||
show_password = protocol in ("trojan", "shadowsocks", "hysteria2", "tuic")
|
||||
show_ss = protocol == "shadowsocks"
|
||||
show_hy2 = protocol == "hysteria2"
|
||||
show_tuic = protocol == "tuic"
|
||||
show_wg = protocol == "wireguard"
|
||||
|
||||
self._set_proto_form_row_visible(self.ent_singbox_vless_uuid, show_vless_auth or show_tuic)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_proto_password, show_password)
|
||||
self._set_proto_form_row_visible(self.cmb_singbox_vless_flow, show_vless_auth)
|
||||
self._set_proto_form_row_visible(self.cmb_singbox_vless_packet_encoding, show_vless_auth)
|
||||
|
||||
self._set_proto_form_row_visible(self.cmb_singbox_ss_method, show_ss)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_ss_plugin, show_ss)
|
||||
|
||||
self._set_proto_form_row_visible(self.spn_singbox_hy2_up_mbps, show_hy2)
|
||||
self._set_proto_form_row_visible(self.spn_singbox_hy2_down_mbps, show_hy2)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_hy2_obfs, show_hy2)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_hy2_obfs_password, show_hy2)
|
||||
|
||||
self._set_proto_form_row_visible(self.cmb_singbox_tuic_congestion, show_tuic)
|
||||
self._set_proto_form_row_visible(self.cmb_singbox_tuic_udp_mode, show_tuic)
|
||||
self._set_proto_form_row_visible(self.chk_singbox_tuic_zero_rtt, show_tuic)
|
||||
|
||||
self._set_proto_form_row_visible(self.ent_singbox_wg_private_key, show_wg)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_wg_peer_public_key, show_wg)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_wg_psk, show_wg)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_wg_local_address, show_wg)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_wg_reserved, show_wg)
|
||||
self._set_proto_form_row_visible(self.spn_singbox_wg_mtu, show_wg)
|
||||
self.wdg_singbox_wg_key_helpers.setVisible(show_wg)
|
||||
|
||||
self._set_proto_form_row_visible(self.cmb_singbox_vless_transport, transport_supported)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_vless_path, transport_supported)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_vless_grpc_service, transport_supported)
|
||||
|
||||
self._set_proto_form_row_visible(self.cmb_singbox_vless_security, protocol not in ("shadowsocks", "wireguard"))
|
||||
self._set_proto_form_row_visible(self.ent_singbox_vless_sni, tls_like)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_tls_alpn, tls_like)
|
||||
self._set_proto_form_row_visible(self.cmb_singbox_vless_utls_fp, tls_like)
|
||||
self._set_proto_form_row_visible(self.chk_singbox_vless_insecure, tls_like)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_vless_reality_pk, reality)
|
||||
self._set_proto_form_row_visible(self.ent_singbox_vless_reality_sid, reality)
|
||||
|
||||
tips = ["Guardrails:"]
|
||||
if protocol == "vless":
|
||||
tips.append("address/port/uuid required")
|
||||
elif protocol == "trojan":
|
||||
tips.append("address/port/password required")
|
||||
elif protocol == "shadowsocks":
|
||||
tips.append("address/port/SS method/password required")
|
||||
elif protocol == "hysteria2":
|
||||
tips.append("address/port/password required")
|
||||
elif protocol == "tuic":
|
||||
tips.append("address/port/uuid/password required")
|
||||
elif protocol == "wireguard":
|
||||
tips.append("address/port/private_key/peer_public_key/local_address required")
|
||||
if reality:
|
||||
tips.append("reality.public_key is required")
|
||||
if transport_supported and grpc_needed:
|
||||
tips.append("gRPC service is required")
|
||||
if transport_supported and path_needed:
|
||||
tips.append("transport path is required")
|
||||
self.lbl_singbox_proto_guardrails.setText(" | ".join(tips))
|
||||
734
selective-vpn-gui/main_window/ui_tabs_singbox_layout_mixin.py
Normal file
734
selective-vpn-gui/main_window/ui_tabs_singbox_layout_mixin.py
Normal file
@@ -0,0 +1,734 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHeaderView,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListView,
|
||||
QListWidget,
|
||||
QPlainTextEdit,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSpinBox,
|
||||
QTableWidget,
|
||||
QStyle,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QFrame,
|
||||
)
|
||||
|
||||
|
||||
class UITabsSingBoxLayoutMixin:
|
||||
def _build_tab_singbox(self) -> None:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
metrics_row = QHBoxLayout()
|
||||
layout.addLayout(metrics_row)
|
||||
|
||||
(
|
||||
_card_conn,
|
||||
self.lbl_singbox_metric_conn_value,
|
||||
self.lbl_singbox_metric_conn_sub,
|
||||
) = self._create_singbox_metric_card("Connection")
|
||||
metrics_row.addWidget(_card_conn, stretch=1)
|
||||
|
||||
(
|
||||
_card_profile,
|
||||
self.lbl_singbox_metric_profile_value,
|
||||
self.lbl_singbox_metric_profile_sub,
|
||||
) = self._create_singbox_metric_card("Profile")
|
||||
metrics_row.addWidget(_card_profile, stretch=1)
|
||||
|
||||
(
|
||||
_card_proto,
|
||||
self.lbl_singbox_metric_proto_value,
|
||||
self.lbl_singbox_metric_proto_sub,
|
||||
) = self._create_singbox_metric_card("Protocol / Transport / Security")
|
||||
metrics_row.addWidget(_card_proto, stretch=1)
|
||||
|
||||
(
|
||||
_card_policy,
|
||||
self.lbl_singbox_metric_policy_value,
|
||||
self.lbl_singbox_metric_policy_sub,
|
||||
) = self._create_singbox_metric_card("Routing / DNS / Killswitch")
|
||||
metrics_row.addWidget(_card_policy, stretch=1)
|
||||
|
||||
profiles_group = QGroupBox("Connection profiles")
|
||||
profiles_layout = QVBoxLayout(profiles_group)
|
||||
profiles_actions = QHBoxLayout()
|
||||
self.btn_singbox_profile_create = QPushButton("Create connection")
|
||||
self.btn_singbox_profile_create.clicked.connect(self.on_singbox_create_connection_click)
|
||||
profiles_actions.addWidget(self.btn_singbox_profile_create)
|
||||
profiles_actions.addStretch(1)
|
||||
profiles_layout.addLayout(profiles_actions)
|
||||
|
||||
self.lst_singbox_profile_cards = QListWidget()
|
||||
self.lst_singbox_profile_cards.setViewMode(QListView.IconMode)
|
||||
self.lst_singbox_profile_cards.setResizeMode(QListView.Adjust)
|
||||
self.lst_singbox_profile_cards.setMovement(QListView.Static)
|
||||
self.lst_singbox_profile_cards.setWrapping(True)
|
||||
self.lst_singbox_profile_cards.setSpacing(8)
|
||||
self.lst_singbox_profile_cards.setGridSize(QSize(240, 88))
|
||||
self.lst_singbox_profile_cards.setMinimumHeight(110)
|
||||
self.lst_singbox_profile_cards.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.lst_singbox_profile_cards.customContextMenuRequested.connect(
|
||||
self.on_singbox_profile_card_context_menu
|
||||
)
|
||||
self.lst_singbox_profile_cards.itemSelectionChanged.connect(
|
||||
self.on_singbox_profile_card_selected
|
||||
)
|
||||
profiles_layout.addWidget(self.lst_singbox_profile_cards)
|
||||
layout.addWidget(profiles_group)
|
||||
|
||||
card_group = QGroupBox("Connection card (runtime)")
|
||||
card_layout = QVBoxLayout(card_group)
|
||||
card_row = QHBoxLayout()
|
||||
card_layout.addLayout(card_row)
|
||||
|
||||
self.lbl_transport_selected_engine = QLabel("Selected profile: —")
|
||||
self.lbl_transport_selected_engine.setStyleSheet("color: gray;")
|
||||
card_row.addWidget(self.lbl_transport_selected_engine, stretch=1)
|
||||
|
||||
self.cmb_transport_engine = QComboBox()
|
||||
self.cmb_transport_engine.setMaxVisibleItems(10)
|
||||
self.cmb_transport_engine.currentIndexChanged.connect(
|
||||
self.on_transport_engine_selected
|
||||
)
|
||||
# Hidden selector: internal state source (tiles are the visible selection control).
|
||||
self.cmb_transport_engine.setVisible(False)
|
||||
|
||||
self.btn_transport_engine_refresh = QToolButton()
|
||||
self.btn_transport_engine_refresh.setAutoRaise(True)
|
||||
self.btn_transport_engine_refresh.setIcon(
|
||||
self.style().standardIcon(QStyle.SP_BrowserReload)
|
||||
)
|
||||
self.btn_transport_engine_refresh.setToolTip("Refresh engines")
|
||||
self.btn_transport_engine_refresh.clicked.connect(
|
||||
self.on_transport_engine_refresh
|
||||
)
|
||||
card_row.addWidget(self.btn_transport_engine_refresh)
|
||||
|
||||
self.btn_transport_engine_provision = QPushButton("Prepare")
|
||||
self.btn_transport_engine_provision.setToolTip(
|
||||
"Optional: pre-provision runtime/config artifacts for selected profile"
|
||||
)
|
||||
self.btn_transport_engine_provision.clicked.connect(
|
||||
lambda: self.on_transport_engine_action("provision")
|
||||
)
|
||||
card_row.addWidget(self.btn_transport_engine_provision)
|
||||
|
||||
self.btn_transport_engine_toggle = QPushButton("Disconnected")
|
||||
self.btn_transport_engine_toggle.setCheckable(True)
|
||||
self.btn_transport_engine_toggle.setToolTip(
|
||||
"Toggle connection for selected profile"
|
||||
)
|
||||
self.btn_transport_engine_toggle.clicked.connect(
|
||||
self.on_transport_engine_toggle
|
||||
)
|
||||
card_row.addWidget(self.btn_transport_engine_toggle)
|
||||
|
||||
self.btn_transport_engine_restart = QPushButton("Restart")
|
||||
self.btn_transport_engine_restart.clicked.connect(
|
||||
lambda: self.on_transport_engine_action("restart")
|
||||
)
|
||||
card_row.addWidget(self.btn_transport_engine_restart)
|
||||
|
||||
self.btn_transport_engine_rollback = QPushButton("Rollback policy")
|
||||
self.btn_transport_engine_rollback.clicked.connect(
|
||||
self.on_transport_policy_rollback
|
||||
)
|
||||
card_row.addWidget(self.btn_transport_engine_rollback)
|
||||
|
||||
self.btn_transport_netns_toggle = QPushButton("Debug netns: OFF")
|
||||
self.btn_transport_netns_toggle.setToolTip(
|
||||
"Toggle netns for all SingBox engines (debug/testing)"
|
||||
)
|
||||
self.btn_transport_netns_toggle.clicked.connect(
|
||||
self.on_transport_netns_toggle
|
||||
)
|
||||
card_row.addWidget(self.btn_transport_netns_toggle)
|
||||
|
||||
self.lbl_transport_engine_meta = QLabel("Engine: loading...")
|
||||
self.lbl_transport_engine_meta.setStyleSheet("color: gray;")
|
||||
card_layout.addWidget(self.lbl_transport_engine_meta)
|
||||
|
||||
layout.addWidget(card_group)
|
||||
|
||||
settings_toggle_row = QHBoxLayout()
|
||||
self.btn_singbox_toggle_profile_settings = QPushButton("Profile settings")
|
||||
self.btn_singbox_toggle_profile_settings.setCheckable(True)
|
||||
self.btn_singbox_toggle_profile_settings.clicked.connect(
|
||||
self.on_toggle_singbox_profile_settings
|
||||
)
|
||||
settings_toggle_row.addWidget(self.btn_singbox_toggle_profile_settings)
|
||||
self.btn_singbox_toggle_global_defaults = QPushButton("Global defaults")
|
||||
self.btn_singbox_toggle_global_defaults.setCheckable(True)
|
||||
self.btn_singbox_toggle_global_defaults.clicked.connect(
|
||||
self.on_toggle_singbox_global_defaults
|
||||
)
|
||||
settings_toggle_row.addWidget(self.btn_singbox_toggle_global_defaults)
|
||||
self.btn_singbox_toggle_activity = QPushButton("Activity log")
|
||||
self.btn_singbox_toggle_activity.setCheckable(True)
|
||||
self.btn_singbox_toggle_activity.clicked.connect(
|
||||
self.on_toggle_singbox_activity
|
||||
)
|
||||
settings_toggle_row.addWidget(self.btn_singbox_toggle_activity)
|
||||
settings_toggle_row.addStretch(1)
|
||||
layout.addLayout(settings_toggle_row)
|
||||
|
||||
profile_group = QGroupBox("Profile settings (SingBox)")
|
||||
self.grp_singbox_profile_settings = profile_group
|
||||
profile_layout = QVBoxLayout(profile_group)
|
||||
|
||||
self.lbl_singbox_profile_name = QLabel("Profile: —")
|
||||
self.lbl_singbox_profile_name.setStyleSheet("color: gray;")
|
||||
profile_layout.addWidget(self.lbl_singbox_profile_name)
|
||||
|
||||
profile_scope_row = QHBoxLayout()
|
||||
self.chk_singbox_profile_use_global_routing = QCheckBox("Use global routing defaults")
|
||||
self.chk_singbox_profile_use_global_routing.stateChanged.connect(
|
||||
self.on_singbox_profile_scope_changed
|
||||
)
|
||||
profile_scope_row.addWidget(self.chk_singbox_profile_use_global_routing)
|
||||
self.chk_singbox_profile_use_global_dns = QCheckBox("Use global DNS defaults")
|
||||
self.chk_singbox_profile_use_global_dns.stateChanged.connect(
|
||||
self.on_singbox_profile_scope_changed
|
||||
)
|
||||
profile_scope_row.addWidget(self.chk_singbox_profile_use_global_dns)
|
||||
self.chk_singbox_profile_use_global_killswitch = QCheckBox("Use global kill-switch defaults")
|
||||
self.chk_singbox_profile_use_global_killswitch.stateChanged.connect(
|
||||
self.on_singbox_profile_scope_changed
|
||||
)
|
||||
profile_scope_row.addWidget(self.chk_singbox_profile_use_global_killswitch)
|
||||
profile_scope_row.addStretch(1)
|
||||
profile_layout.addLayout(profile_scope_row)
|
||||
|
||||
profile_form = QFormLayout()
|
||||
self.cmb_singbox_profile_routing = QComboBox()
|
||||
self.cmb_singbox_profile_routing.addItem("Global default", "global")
|
||||
self.cmb_singbox_profile_routing.addItem("Selective", "selective")
|
||||
self.cmb_singbox_profile_routing.addItem("Full tunnel", "full")
|
||||
self.cmb_singbox_profile_routing.currentIndexChanged.connect(
|
||||
self.on_singbox_profile_scope_changed
|
||||
)
|
||||
profile_form.addRow("Routing mode:", self.cmb_singbox_profile_routing)
|
||||
|
||||
self.cmb_singbox_profile_dns = QComboBox()
|
||||
self.cmb_singbox_profile_dns.addItem("Global default", "global")
|
||||
self.cmb_singbox_profile_dns.addItem("System resolver", "system_resolver")
|
||||
self.cmb_singbox_profile_dns.addItem("SingBox DNS", "singbox_dns")
|
||||
self.cmb_singbox_profile_dns.currentIndexChanged.connect(
|
||||
self.on_singbox_profile_scope_changed
|
||||
)
|
||||
profile_form.addRow("DNS mode:", self.cmb_singbox_profile_dns)
|
||||
|
||||
self.cmb_singbox_profile_killswitch = QComboBox()
|
||||
self.cmb_singbox_profile_killswitch.addItem("Global default", "global")
|
||||
self.cmb_singbox_profile_killswitch.addItem("Enabled", "on")
|
||||
self.cmb_singbox_profile_killswitch.addItem("Disabled", "off")
|
||||
self.cmb_singbox_profile_killswitch.currentIndexChanged.connect(
|
||||
self.on_singbox_profile_scope_changed
|
||||
)
|
||||
profile_form.addRow("Kill-switch:", self.cmb_singbox_profile_killswitch)
|
||||
profile_layout.addLayout(profile_form)
|
||||
|
||||
profile_actions = QHBoxLayout()
|
||||
self.btn_singbox_profile_preview = QPushButton("Preview render")
|
||||
self.btn_singbox_profile_preview.clicked.connect(self.on_singbox_profile_preview)
|
||||
profile_actions.addWidget(self.btn_singbox_profile_preview)
|
||||
self.btn_singbox_profile_validate = QPushButton("Validate profile")
|
||||
self.btn_singbox_profile_validate.clicked.connect(self.on_singbox_profile_validate)
|
||||
profile_actions.addWidget(self.btn_singbox_profile_validate)
|
||||
self.btn_singbox_profile_apply = QPushButton("Apply profile")
|
||||
self.btn_singbox_profile_apply.clicked.connect(self.on_singbox_profile_apply)
|
||||
profile_actions.addWidget(self.btn_singbox_profile_apply)
|
||||
self.btn_singbox_profile_rollback = QPushButton("Rollback profile")
|
||||
self.btn_singbox_profile_rollback.clicked.connect(self.on_singbox_profile_rollback)
|
||||
profile_actions.addWidget(self.btn_singbox_profile_rollback)
|
||||
self.btn_singbox_profile_history = QPushButton("History")
|
||||
self.btn_singbox_profile_history.clicked.connect(self.on_singbox_profile_history)
|
||||
profile_actions.addWidget(self.btn_singbox_profile_history)
|
||||
self.btn_singbox_profile_save = QPushButton("Save draft")
|
||||
self.btn_singbox_profile_save.clicked.connect(self.on_singbox_profile_save)
|
||||
profile_actions.addWidget(self.btn_singbox_profile_save)
|
||||
profile_actions.addStretch(1)
|
||||
profile_layout.addLayout(profile_actions)
|
||||
|
||||
self.lbl_singbox_profile_effective = QLabel("Effective: routing=— | dns=— | kill-switch=—")
|
||||
self.lbl_singbox_profile_effective.setStyleSheet("color: gray;")
|
||||
profile_layout.addWidget(self.lbl_singbox_profile_effective)
|
||||
self._build_singbox_vless_editor(profile_layout)
|
||||
self._singbox_editor_default_title = self.grp_singbox_proto_editor.title()
|
||||
self.grp_singbox_proto_editor.setVisible(False)
|
||||
self.lbl_singbox_editor_hint = QLabel("Right-click a profile card and select Edit to open protocol settings.")
|
||||
self.lbl_singbox_editor_hint.setStyleSheet("color: gray;")
|
||||
profile_layout.addWidget(self.lbl_singbox_editor_hint)
|
||||
|
||||
layout.addWidget(profile_group)
|
||||
profile_group.setVisible(False)
|
||||
|
||||
global_group = QGroupBox("Global defaults")
|
||||
self.grp_singbox_global_defaults = global_group
|
||||
global_layout = QVBoxLayout(global_group)
|
||||
global_form = QFormLayout()
|
||||
|
||||
self.cmb_singbox_global_routing = QComboBox()
|
||||
self.cmb_singbox_global_routing.addItem("Selective", "selective")
|
||||
self.cmb_singbox_global_routing.addItem("Full tunnel", "full")
|
||||
self.cmb_singbox_global_routing.currentIndexChanged.connect(
|
||||
self.on_singbox_global_defaults_changed
|
||||
)
|
||||
global_form.addRow("Default routing mode:", self.cmb_singbox_global_routing)
|
||||
|
||||
self.cmb_singbox_global_dns = QComboBox()
|
||||
self.cmb_singbox_global_dns.addItem("System resolver", "system_resolver")
|
||||
self.cmb_singbox_global_dns.addItem("SingBox DNS", "singbox_dns")
|
||||
self.cmb_singbox_global_dns.currentIndexChanged.connect(
|
||||
self.on_singbox_global_defaults_changed
|
||||
)
|
||||
global_form.addRow("Default DNS mode:", self.cmb_singbox_global_dns)
|
||||
|
||||
self.cmb_singbox_global_killswitch = QComboBox()
|
||||
self.cmb_singbox_global_killswitch.addItem("Enabled", "on")
|
||||
self.cmb_singbox_global_killswitch.addItem("Disabled", "off")
|
||||
self.cmb_singbox_global_killswitch.currentIndexChanged.connect(
|
||||
self.on_singbox_global_defaults_changed
|
||||
)
|
||||
global_form.addRow("Default kill-switch:", self.cmb_singbox_global_killswitch)
|
||||
global_layout.addLayout(global_form)
|
||||
|
||||
global_actions = QHBoxLayout()
|
||||
self.btn_singbox_global_save = QPushButton("Save global defaults")
|
||||
self.btn_singbox_global_save.clicked.connect(self.on_singbox_global_save)
|
||||
global_actions.addWidget(self.btn_singbox_global_save)
|
||||
global_actions.addStretch(1)
|
||||
global_layout.addLayout(global_actions)
|
||||
|
||||
self.lbl_singbox_global_hint = QLabel(
|
||||
"Global defaults are used by profiles with 'Use global ...' enabled."
|
||||
)
|
||||
self.lbl_singbox_global_hint.setStyleSheet("color: gray;")
|
||||
global_layout.addWidget(self.lbl_singbox_global_hint)
|
||||
|
||||
layout.addWidget(global_group)
|
||||
global_group.setVisible(False)
|
||||
|
||||
# During UI construction routes/dns widgets are not fully created yet,
|
||||
# so apply local SingBox control state without touching global save path.
|
||||
self._apply_singbox_profile_controls()
|
||||
|
||||
# Multi-interface routing tools are placed on a dedicated tab.
|
||||
self.grp_singbox_activity = QGroupBox("Activity log")
|
||||
activity_layout = QVBoxLayout(self.grp_singbox_activity)
|
||||
self.txt_transport = QPlainTextEdit()
|
||||
self.txt_transport.setReadOnly(True)
|
||||
activity_layout.addWidget(self.txt_transport)
|
||||
layout.addWidget(self.grp_singbox_activity, stretch=1)
|
||||
self.grp_singbox_activity.setVisible(False)
|
||||
self._apply_singbox_compact_visibility()
|
||||
|
||||
self.tabs.addTab(tab, "SingBox")
|
||||
|
||||
def _build_tab_multiif(self) -> None:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.NoFrame)
|
||||
|
||||
scroll_content = QWidget()
|
||||
scroll_layout = QVBoxLayout(scroll_content)
|
||||
scroll_layout.setContentsMargins(0, 0, 0, 0)
|
||||
scroll_layout.setSpacing(8)
|
||||
|
||||
self.grp_singbox_owner_locks = self._build_singbox_owner_locks_group()
|
||||
scroll_layout.addWidget(self.grp_singbox_owner_locks)
|
||||
scroll_layout.addStretch(1)
|
||||
|
||||
scroll.setWidget(scroll_content)
|
||||
layout.addWidget(scroll, stretch=1)
|
||||
|
||||
self.tabs.addTab(tab, "MultiIF")
|
||||
|
||||
def _build_singbox_owner_locks_group(self) -> QGroupBox:
|
||||
group = QGroupBox("Routing policy & ownership locks")
|
||||
owner_locks_layout = QVBoxLayout(group)
|
||||
|
||||
owner_actions = QHBoxLayout()
|
||||
self.btn_singbox_owner_locks_refresh = QPushButton("Refresh locks")
|
||||
self.btn_singbox_owner_locks_refresh.clicked.connect(
|
||||
self.on_singbox_owner_locks_refresh
|
||||
)
|
||||
owner_actions.addWidget(self.btn_singbox_owner_locks_refresh)
|
||||
self.btn_singbox_owner_locks_clear = QPushButton("Clear locks...")
|
||||
self.btn_singbox_owner_locks_clear.clicked.connect(
|
||||
self.on_singbox_owner_locks_clear
|
||||
)
|
||||
owner_actions.addWidget(self.btn_singbox_owner_locks_clear)
|
||||
owner_actions.addWidget(QLabel("Engine:"))
|
||||
self.cmb_singbox_owner_engine_scope = QComboBox()
|
||||
self.cmb_singbox_owner_engine_scope.addItem("All", "all")
|
||||
self.cmb_singbox_owner_engine_scope.addItem("Transport", "transport")
|
||||
self.cmb_singbox_owner_engine_scope.addItem("AdGuard VPN", "adguardvpn")
|
||||
self.cmb_singbox_owner_engine_scope.currentIndexChanged.connect(
|
||||
self.on_singbox_owner_engine_scope_changed
|
||||
)
|
||||
owner_actions.addWidget(self.cmb_singbox_owner_engine_scope)
|
||||
owner_actions.addStretch(1)
|
||||
owner_locks_layout.addLayout(owner_actions)
|
||||
|
||||
self.lbl_singbox_owner_locks_summary = QLabel("Ownership: — | Locks: —")
|
||||
self.lbl_singbox_owner_locks_summary.setStyleSheet("color: gray;")
|
||||
owner_locks_layout.addWidget(self.lbl_singbox_owner_locks_summary)
|
||||
|
||||
self.lbl_singbox_interfaces_hint = QLabel("Interfaces (read-only)")
|
||||
self.lbl_singbox_interfaces_hint.setStyleSheet("color: #666;")
|
||||
owner_locks_layout.addWidget(self.lbl_singbox_interfaces_hint)
|
||||
|
||||
self.tbl_singbox_interfaces = QTableWidget(0, 7)
|
||||
self.tbl_singbox_interfaces.setHorizontalHeaderLabels(
|
||||
["Iface ID", "Mode", "Runtime iface", "NetNS", "Routing table", "Clients UP/Total", "Updated"]
|
||||
)
|
||||
self.tbl_singbox_interfaces.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.tbl_singbox_interfaces.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.tbl_singbox_interfaces.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.tbl_singbox_interfaces.verticalHeader().setVisible(False)
|
||||
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(6, QHeaderView.Stretch)
|
||||
self.tbl_singbox_interfaces.setMinimumHeight(120)
|
||||
owner_locks_layout.addWidget(self.tbl_singbox_interfaces)
|
||||
|
||||
self.lbl_singbox_policy_quick_help = QLabel(
|
||||
"Policy flow: 1) Add demo/fill intent -> 2) Validate policy -> 3) Validate & apply."
|
||||
)
|
||||
self.lbl_singbox_policy_quick_help.setStyleSheet("color: #1f6b2f;")
|
||||
owner_locks_layout.addWidget(self.lbl_singbox_policy_quick_help)
|
||||
|
||||
policy_group = QGroupBox("Policy intents")
|
||||
policy_layout = QVBoxLayout(policy_group)
|
||||
|
||||
self.lbl_singbox_policy_input_help = QLabel(
|
||||
"Intent fields: selector type | selector value | client | mode | priority"
|
||||
)
|
||||
self.lbl_singbox_policy_input_help.setStyleSheet("color: #666;")
|
||||
policy_layout.addWidget(self.lbl_singbox_policy_input_help)
|
||||
|
||||
policy_template_row = QHBoxLayout()
|
||||
self.cmb_singbox_policy_template = QComboBox()
|
||||
self.cmb_singbox_policy_template.addItem("Quick template...", "")
|
||||
self.cmb_singbox_policy_template.addItem(
|
||||
"Domain -> active client (strict)",
|
||||
{
|
||||
"selector_type": "domain",
|
||||
"selector_value": "example.com",
|
||||
"mode": "strict",
|
||||
"priority": 100,
|
||||
},
|
||||
)
|
||||
self.cmb_singbox_policy_template.addItem(
|
||||
"Wildcard domain (fallback)",
|
||||
{
|
||||
"selector_type": "domain",
|
||||
"selector_value": "*.example.com",
|
||||
"mode": "fallback",
|
||||
"priority": 200,
|
||||
},
|
||||
)
|
||||
self.cmb_singbox_policy_template.addItem(
|
||||
"CIDR subnet (strict)",
|
||||
{
|
||||
"selector_type": "cidr",
|
||||
"selector_value": "1.2.3.0/24",
|
||||
"mode": "strict",
|
||||
"priority": 100,
|
||||
},
|
||||
)
|
||||
self.cmb_singbox_policy_template.addItem(
|
||||
"IP host (strict)",
|
||||
{
|
||||
"selector_type": "cidr",
|
||||
"selector_value": "1.2.3.4",
|
||||
"mode": "strict",
|
||||
"priority": 100,
|
||||
},
|
||||
)
|
||||
self.cmb_singbox_policy_template.addItem(
|
||||
"App key (strict)",
|
||||
{
|
||||
"selector_type": "app_key",
|
||||
"selector_value": "steam",
|
||||
"mode": "strict",
|
||||
"priority": 100,
|
||||
},
|
||||
)
|
||||
self.cmb_singbox_policy_template.addItem(
|
||||
"UID (strict)",
|
||||
{
|
||||
"selector_type": "uid",
|
||||
"selector_value": "1000",
|
||||
"mode": "strict",
|
||||
"priority": 100,
|
||||
},
|
||||
)
|
||||
self.cmb_singbox_policy_template.setToolTip(
|
||||
"Prefill intent fields from a template. It does not add to draft automatically."
|
||||
)
|
||||
policy_template_row.addWidget(self.cmb_singbox_policy_template, stretch=2)
|
||||
self.btn_singbox_policy_use_template = QPushButton("Use template")
|
||||
self.btn_singbox_policy_use_template.clicked.connect(self.on_singbox_policy_use_template)
|
||||
policy_template_row.addWidget(self.btn_singbox_policy_use_template)
|
||||
self.btn_singbox_policy_add_demo = QPushButton("Add demo intent")
|
||||
self.btn_singbox_policy_add_demo.setToolTip(
|
||||
"Create one test intent (domain -> selected client) and add it to draft."
|
||||
)
|
||||
self.btn_singbox_policy_add_demo.clicked.connect(self.on_singbox_policy_add_demo_intent)
|
||||
policy_template_row.addWidget(self.btn_singbox_policy_add_demo)
|
||||
policy_template_row.addStretch(1)
|
||||
policy_layout.addLayout(policy_template_row)
|
||||
|
||||
policy_input_row = QHBoxLayout()
|
||||
self.cmb_singbox_policy_selector_type = QComboBox()
|
||||
self.cmb_singbox_policy_selector_type.addItem("domain", "domain")
|
||||
self.cmb_singbox_policy_selector_type.addItem("cidr", "cidr")
|
||||
self.cmb_singbox_policy_selector_type.addItem("app_key", "app_key")
|
||||
self.cmb_singbox_policy_selector_type.addItem("cgroup", "cgroup")
|
||||
self.cmb_singbox_policy_selector_type.addItem("uid", "uid")
|
||||
self.cmb_singbox_policy_selector_type.currentIndexChanged.connect(
|
||||
self.on_singbox_policy_selector_type_changed
|
||||
)
|
||||
policy_input_row.addWidget(self.cmb_singbox_policy_selector_type)
|
||||
|
||||
self.ent_singbox_policy_selector_value = QLineEdit()
|
||||
self.ent_singbox_policy_selector_value.setPlaceholderText("example.com")
|
||||
self.ent_singbox_policy_selector_value.setToolTip(
|
||||
"Examples: domain=example.com, cidr=1.2.3.0/24, app_key=steam, cgroup=user.slice/..., uid=1000. Press Enter to add intent."
|
||||
)
|
||||
self.ent_singbox_policy_selector_value.returnPressed.connect(self.on_singbox_policy_add_intent)
|
||||
policy_input_row.addWidget(self.ent_singbox_policy_selector_value, stretch=2)
|
||||
|
||||
self.cmb_singbox_policy_client_id = QComboBox()
|
||||
self.cmb_singbox_policy_client_id.setMinimumWidth(180)
|
||||
policy_input_row.addWidget(self.cmb_singbox_policy_client_id, stretch=1)
|
||||
|
||||
self.cmb_singbox_policy_mode = QComboBox()
|
||||
self.cmb_singbox_policy_mode.addItem("strict", "strict")
|
||||
self.cmb_singbox_policy_mode.addItem("fallback", "fallback")
|
||||
policy_input_row.addWidget(self.cmb_singbox_policy_mode)
|
||||
|
||||
self.spn_singbox_policy_priority = QSpinBox()
|
||||
self.spn_singbox_policy_priority.setRange(1, 10000)
|
||||
self.spn_singbox_policy_priority.setValue(100)
|
||||
self.spn_singbox_policy_priority.setToolTip("Intent priority")
|
||||
policy_input_row.addWidget(self.spn_singbox_policy_priority)
|
||||
|
||||
self.btn_singbox_policy_add = QPushButton("Add intent")
|
||||
self.btn_singbox_policy_add.clicked.connect(self.on_singbox_policy_add_intent)
|
||||
policy_input_row.addWidget(self.btn_singbox_policy_add)
|
||||
|
||||
self.btn_singbox_policy_load_selected = QPushButton("Load selected")
|
||||
self.btn_singbox_policy_load_selected.clicked.connect(self.on_singbox_policy_load_selected_intent)
|
||||
policy_input_row.addWidget(self.btn_singbox_policy_load_selected)
|
||||
|
||||
self.btn_singbox_policy_update_selected = QPushButton("Update selected")
|
||||
self.btn_singbox_policy_update_selected.clicked.connect(self.on_singbox_policy_update_selected_intent)
|
||||
policy_input_row.addWidget(self.btn_singbox_policy_update_selected)
|
||||
|
||||
self.btn_singbox_policy_remove = QPushButton("Remove selected")
|
||||
self.btn_singbox_policy_remove.clicked.connect(self.on_singbox_policy_remove_selected)
|
||||
policy_input_row.addWidget(self.btn_singbox_policy_remove)
|
||||
policy_layout.addLayout(policy_input_row)
|
||||
|
||||
policy_actions_row = QHBoxLayout()
|
||||
self.btn_singbox_policy_reload = QPushButton("Reload policy")
|
||||
self.btn_singbox_policy_reload.clicked.connect(self.on_singbox_policy_reload)
|
||||
policy_actions_row.addWidget(self.btn_singbox_policy_reload)
|
||||
self.btn_singbox_policy_validate = QPushButton("Validate policy")
|
||||
self.btn_singbox_policy_validate.clicked.connect(self.on_singbox_policy_validate)
|
||||
policy_actions_row.addWidget(self.btn_singbox_policy_validate)
|
||||
self.btn_singbox_policy_apply = QPushButton("Validate & apply")
|
||||
self.btn_singbox_policy_apply.clicked.connect(self.on_singbox_policy_apply)
|
||||
policy_actions_row.addWidget(self.btn_singbox_policy_apply)
|
||||
self.btn_singbox_policy_rollback = QPushButton("Rollback policy")
|
||||
self.btn_singbox_policy_rollback.clicked.connect(self.on_singbox_policy_rollback_explicit)
|
||||
policy_actions_row.addWidget(self.btn_singbox_policy_rollback)
|
||||
policy_actions_row.addStretch(1)
|
||||
policy_layout.addLayout(policy_actions_row)
|
||||
|
||||
self.lbl_singbox_policy_state = QLabel("Policy editor: loading...")
|
||||
self.lbl_singbox_policy_state.setStyleSheet("color: gray;")
|
||||
policy_layout.addWidget(self.lbl_singbox_policy_state)
|
||||
|
||||
self.lbl_singbox_policy_conflicts_hint = QLabel(
|
||||
"Validation conflicts (last validate/apply, read-only)"
|
||||
)
|
||||
self.lbl_singbox_policy_conflicts_hint.setStyleSheet("color: #666;")
|
||||
policy_layout.addWidget(self.lbl_singbox_policy_conflicts_hint)
|
||||
|
||||
self.tbl_singbox_policy_conflicts = QTableWidget(0, 5)
|
||||
self.tbl_singbox_policy_conflicts.setHorizontalHeaderLabels(
|
||||
["Type", "Severity", "Owners", "Reason", "Suggested resolution"]
|
||||
)
|
||||
self.tbl_singbox_policy_conflicts.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.tbl_singbox_policy_conflicts.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.tbl_singbox_policy_conflicts.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.tbl_singbox_policy_conflicts.verticalHeader().setVisible(False)
|
||||
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch)
|
||||
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(4, QHeaderView.Stretch)
|
||||
self.tbl_singbox_policy_conflicts.setMinimumHeight(100)
|
||||
policy_layout.addWidget(self.tbl_singbox_policy_conflicts)
|
||||
|
||||
self.tbl_singbox_policy_intents = QTableWidget(0, 5)
|
||||
self.tbl_singbox_policy_intents.setHorizontalHeaderLabels(
|
||||
["Selector type", "Selector value", "Client ID", "Mode", "Priority"]
|
||||
)
|
||||
self.tbl_singbox_policy_intents.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.tbl_singbox_policy_intents.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.tbl_singbox_policy_intents.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.tbl_singbox_policy_intents.verticalHeader().setVisible(False)
|
||||
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_policy_intents.setMinimumHeight(130)
|
||||
self.tbl_singbox_policy_intents.itemDoubleClicked.connect(
|
||||
self.on_singbox_policy_intent_double_clicked
|
||||
)
|
||||
policy_layout.addWidget(self.tbl_singbox_policy_intents)
|
||||
|
||||
self.lbl_singbox_policy_applied_hint = QLabel(
|
||||
"Applied intents (read-only, current backend policy)"
|
||||
)
|
||||
self.lbl_singbox_policy_applied_hint.setStyleSheet("color: #666;")
|
||||
policy_layout.addWidget(self.lbl_singbox_policy_applied_hint)
|
||||
|
||||
self.tbl_singbox_policy_applied = QTableWidget(0, 5)
|
||||
self.tbl_singbox_policy_applied.setHorizontalHeaderLabels(
|
||||
["Selector type", "Selector value", "Client ID", "Mode", "Priority"]
|
||||
)
|
||||
self.tbl_singbox_policy_applied.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.tbl_singbox_policy_applied.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.tbl_singbox_policy_applied.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.tbl_singbox_policy_applied.verticalHeader().setVisible(False)
|
||||
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_policy_applied.setMinimumHeight(110)
|
||||
policy_layout.addWidget(self.tbl_singbox_policy_applied)
|
||||
|
||||
owner_locks_layout.addWidget(policy_group)
|
||||
|
||||
self.lbl_singbox_ownership_hint = QLabel(
|
||||
"Ownership (read-only, populated after policy apply)"
|
||||
)
|
||||
self.lbl_singbox_ownership_hint.setStyleSheet("color: #666;")
|
||||
owner_locks_layout.addWidget(self.lbl_singbox_ownership_hint)
|
||||
|
||||
self.tbl_singbox_ownership = QTableWidget(0, 6)
|
||||
self.tbl_singbox_ownership.setHorizontalHeaderLabels(
|
||||
["Selector", "Owner", "Owner scope", "Iface / table", "Status", "Lock"]
|
||||
)
|
||||
self.tbl_singbox_ownership.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.tbl_singbox_ownership.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.tbl_singbox_ownership.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.tbl_singbox_ownership.verticalHeader().setVisible(False)
|
||||
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_ownership.setMinimumHeight(130)
|
||||
owner_locks_layout.addWidget(self.tbl_singbox_ownership)
|
||||
|
||||
filters_row = QHBoxLayout()
|
||||
self.ent_singbox_owner_lock_client = QLineEdit()
|
||||
self.ent_singbox_owner_lock_client.setPlaceholderText("Filter client_id (optional)")
|
||||
filters_row.addWidget(self.ent_singbox_owner_lock_client, stretch=1)
|
||||
self.ent_singbox_owner_lock_destination = QLineEdit()
|
||||
self.ent_singbox_owner_lock_destination.setPlaceholderText(
|
||||
"Destination IP or CSV list (optional)"
|
||||
)
|
||||
filters_row.addWidget(self.ent_singbox_owner_lock_destination, stretch=2)
|
||||
owner_locks_layout.addLayout(filters_row)
|
||||
|
||||
self.lbl_singbox_locks_hint = QLabel(
|
||||
"Destination locks (read-only, conntrack sticky state)"
|
||||
)
|
||||
self.lbl_singbox_locks_hint.setStyleSheet("color: #666;")
|
||||
owner_locks_layout.addWidget(self.lbl_singbox_locks_hint)
|
||||
|
||||
self.tbl_singbox_owner_locks = QTableWidget(0, 6)
|
||||
self.tbl_singbox_owner_locks.setHorizontalHeaderLabels(
|
||||
["Destination", "Owner", "Kind", "Iface", "Mark/Proto", "Updated"]
|
||||
)
|
||||
self.tbl_singbox_owner_locks.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.tbl_singbox_owner_locks.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.tbl_singbox_owner_locks.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.tbl_singbox_owner_locks.verticalHeader().setVisible(False)
|
||||
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
||||
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(5, QHeaderView.Stretch)
|
||||
self.tbl_singbox_owner_locks.setMinimumHeight(170)
|
||||
owner_locks_layout.addWidget(self.tbl_singbox_owner_locks)
|
||||
|
||||
self.lbl_singbox_owner_locks_hint = QLabel(
|
||||
"Clear flow is two-step confirm. Empty filter uses selected destination rows."
|
||||
)
|
||||
self.lbl_singbox_owner_locks_hint.setStyleSheet("color: gray;")
|
||||
owner_locks_layout.addWidget(self.lbl_singbox_owner_locks_hint)
|
||||
return group
|
||||
|
||||
def _create_singbox_metric_card(self, title: str) -> tuple[QFrame, QLabel, QLabel]:
|
||||
frame = QFrame()
|
||||
frame.setFrameShape(QFrame.StyledPanel)
|
||||
frame.setObjectName("singboxMetricCard")
|
||||
frame.setStyleSheet(
|
||||
"""
|
||||
QFrame#singboxMetricCard {
|
||||
border: 1px solid #c9c9c9;
|
||||
border-radius: 6px;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
"""
|
||||
)
|
||||
lay = QVBoxLayout(frame)
|
||||
lay.setContentsMargins(10, 8, 10, 8)
|
||||
lay.setSpacing(2)
|
||||
|
||||
lbl_title = QLabel(title)
|
||||
lbl_title.setStyleSheet("color: #555; font-size: 11px;")
|
||||
lay.addWidget(lbl_title)
|
||||
|
||||
lbl_value = QLabel("—")
|
||||
lbl_value.setStyleSheet("font-weight: 600;")
|
||||
lay.addWidget(lbl_value)
|
||||
|
||||
lbl_sub = QLabel("—")
|
||||
lbl_sub.setStyleSheet("color: #666; font-size: 11px;")
|
||||
lay.addWidget(lbl_sub)
|
||||
return frame, lbl_value, lbl_sub
|
||||
14
selective-vpn-gui/main_window/ui_tabs_singbox_mixin.py
Normal file
14
selective-vpn-gui/main_window/ui_tabs_singbox_mixin.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from main_window.ui_tabs_singbox_editor_mixin import UITabsSingBoxEditorMixin
|
||||
from main_window.ui_tabs_singbox_layout_mixin import UITabsSingBoxLayoutMixin
|
||||
|
||||
|
||||
class UITabsSingBoxMixin(
|
||||
UITabsSingBoxEditorMixin,
|
||||
UITabsSingBoxLayoutMixin,
|
||||
):
|
||||
"""Facade mixin for SingBox tab UI builders."""
|
||||
|
||||
|
||||
__all__ = ["UITabsSingBoxMixin"]
|
||||
63
selective-vpn-gui/main_window/workers.py
Normal file
63
selective-vpn-gui/main_window/workers.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
from dashboard_controller import DashboardController
|
||||
|
||||
|
||||
class EventThread(QtCore.QThread):
|
||||
eventReceived = QtCore.Signal(object)
|
||||
error = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, controller: DashboardController, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.ctrl = controller
|
||||
self._stop = False
|
||||
self._since = 0
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop = True
|
||||
|
||||
def run(self) -> None: # pragma: no cover - thread
|
||||
while not self._stop:
|
||||
try:
|
||||
for ev in self.ctrl.iter_events(since=self._since, stop=lambda: self._stop):
|
||||
if self._stop:
|
||||
break
|
||||
try:
|
||||
self._since = int(getattr(ev, "id", self._since))
|
||||
except Exception:
|
||||
pass
|
||||
self.eventReceived.emit(ev)
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
time.sleep(1.5)
|
||||
|
||||
|
||||
class LocationsThread(QtCore.QThread):
|
||||
loaded = QtCore.Signal(object)
|
||||
error = QtCore.Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: DashboardController,
|
||||
force_refresh: bool = False,
|
||||
parent=None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.ctrl = controller
|
||||
self.force_refresh = bool(force_refresh)
|
||||
|
||||
def run(self) -> None: # pragma: no cover - thread
|
||||
try:
|
||||
if self.force_refresh:
|
||||
self.ctrl.vpn_locations_refresh_trigger()
|
||||
self.loaded.emit(self.ctrl.vpn_locations_state_view())
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
__all__ = ["EventThread", "LocationsThread"]
|
||||
88
selective-vpn-gui/netns_debug.py
Normal file
88
selective-vpn-gui/netns_debug.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
|
||||
def singbox_clients_netns_state(clients: Iterable[Any]) -> tuple[bool, bool]:
|
||||
flags: list[bool] = []
|
||||
for client in clients:
|
||||
cfg = getattr(client, "config", {}) or {}
|
||||
if not isinstance(cfg, dict):
|
||||
cfg = {}
|
||||
flags.append(bool(cfg.get("netns_enabled", False)))
|
||||
if not flags:
|
||||
return False, False
|
||||
return all(flags), any(flags)
|
||||
|
||||
|
||||
def singbox_netns_toggle_button(all_enabled: bool, any_enabled: bool) -> tuple[str, str]:
|
||||
if all_enabled:
|
||||
return "Debug netns: ON", "green"
|
||||
if any_enabled:
|
||||
return "Debug netns: MIXED", "orange"
|
||||
return "Debug netns: OFF", "gray"
|
||||
|
||||
|
||||
def apply_singbox_netns_toggle(
|
||||
controller: Any,
|
||||
clients: Iterable[Any],
|
||||
target_enabled: bool,
|
||||
log_line: Callable[[str], None],
|
||||
) -> list[str]:
|
||||
failures: list[str] = []
|
||||
client_ids: list[str] = []
|
||||
for client in clients:
|
||||
cid = str(getattr(client, "id", "") or "").strip()
|
||||
if cid:
|
||||
client_ids.append(cid)
|
||||
|
||||
if not client_ids:
|
||||
return ["no SingBox clients selected"]
|
||||
|
||||
target = bool(target_enabled)
|
||||
result = controller.transport_netns_toggle(
|
||||
enabled=target,
|
||||
client_ids=client_ids,
|
||||
provision=True,
|
||||
restart_running=True,
|
||||
)
|
||||
|
||||
summary = (result.message or "").strip()
|
||||
if summary:
|
||||
log_line(summary)
|
||||
|
||||
for item in list(result.items or []):
|
||||
cid = str(getattr(item, "client_id", "") or "").strip() or "unknown"
|
||||
msg = str(getattr(item, "message", "") or "").strip()
|
||||
code = str(getattr(item, "code", "") or "").strip()
|
||||
status_before = str(getattr(item, "status_before", "") or "").strip().lower()
|
||||
status_after = str(getattr(item, "status_after", "") or "").strip().lower()
|
||||
config_updated = bool(getattr(item, "config_updated", False))
|
||||
provisioned = bool(getattr(item, "provisioned", False))
|
||||
restarted = bool(getattr(item, "restarted", False))
|
||||
ok = bool(getattr(item, "ok", False))
|
||||
|
||||
steps: list[str] = []
|
||||
if config_updated:
|
||||
steps.append("config")
|
||||
if provisioned:
|
||||
steps.append("provision")
|
||||
if restarted:
|
||||
steps.append("restart")
|
||||
step_text = ",".join(steps) if steps else "noop"
|
||||
|
||||
parts = [f"{cid}: {'ok' if ok else 'fail'}", f"steps={step_text}"]
|
||||
if status_before or status_after:
|
||||
parts.append(f"status {status_before or '-'}->{status_after or '-'}")
|
||||
if msg:
|
||||
parts.append(msg)
|
||||
elif code:
|
||||
parts.append(code)
|
||||
log_line(" | ".join(parts))
|
||||
|
||||
if not ok:
|
||||
failures.append(f"{cid}: {msg or code or 'toggle failed'}")
|
||||
|
||||
if not bool(getattr(result, "ok", False)) and not failures:
|
||||
failures.append((summary or "netns toggle failed").strip())
|
||||
return failures
|
||||
178
selective-vpn-gui/transport_protocol_summary.py
Normal file
178
selective-vpn-gui/transport_protocol_summary.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransportProtocolInfo:
|
||||
protocol: str = ""
|
||||
transport: str = ""
|
||||
security: str = ""
|
||||
|
||||
def summary(self) -> str:
|
||||
proto = self.protocol if self.protocol else "n/a"
|
||||
transport = self.transport if self.transport else "n/a"
|
||||
security = self.security if self.security else "n/a"
|
||||
return f"{proto} / {transport} / {security}"
|
||||
|
||||
|
||||
def transport_protocol_info(client: Any) -> TransportProtocolInfo:
|
||||
cfg = _as_dict(getattr(client, "config", {}) or {})
|
||||
protocol = _first_non_empty(
|
||||
cfg.get("protocol"),
|
||||
cfg.get("profile_protocol"),
|
||||
cfg.get("outbound"),
|
||||
cfg.get("type"),
|
||||
).lower()
|
||||
transport = _first_non_empty(
|
||||
cfg.get("transport"),
|
||||
cfg.get("network"),
|
||||
cfg.get("stream"),
|
||||
).lower()
|
||||
security = _normalize_security(
|
||||
_first_non_empty(
|
||||
cfg.get("security"),
|
||||
cfg.get("tls_security"),
|
||||
cfg.get("security_mode"),
|
||||
)
|
||||
)
|
||||
|
||||
if not protocol or not transport or not security:
|
||||
raw_cfg = _load_raw_config_from_client_config(cfg)
|
||||
if raw_cfg:
|
||||
p2, t2, s2 = _infer_from_raw_config(raw_cfg)
|
||||
if not protocol and p2:
|
||||
protocol = p2
|
||||
if not transport and t2:
|
||||
transport = t2
|
||||
if not security and s2:
|
||||
security = s2
|
||||
|
||||
if protocol and not transport:
|
||||
transport = _default_transport_for_protocol(protocol)
|
||||
if protocol and not security:
|
||||
security = "none"
|
||||
return TransportProtocolInfo(protocol=protocol, transport=transport, security=security)
|
||||
|
||||
|
||||
def transport_protocol_summary(client: Any) -> str:
|
||||
return transport_protocol_info(client).summary()
|
||||
|
||||
|
||||
def _infer_from_raw_config(raw_cfg: dict[str, Any]) -> tuple[str, str, str]:
|
||||
outbounds = raw_cfg.get("outbounds") or []
|
||||
if isinstance(outbounds, list):
|
||||
for row in outbounds:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
out_type = str(row.get("type") or "").strip().lower()
|
||||
if not out_type or out_type in ("direct", "block", "dns"):
|
||||
continue
|
||||
tx = ""
|
||||
transport_obj = row.get("transport")
|
||||
if isinstance(transport_obj, dict):
|
||||
tx = str(transport_obj.get("type") or "").strip().lower()
|
||||
if not tx:
|
||||
tx = str(row.get("network") or "").strip().lower()
|
||||
sec = _extract_security(row)
|
||||
return out_type, tx, sec
|
||||
|
||||
inbounds = raw_cfg.get("inbounds") or []
|
||||
if isinstance(inbounds, list):
|
||||
for row in inbounds:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
in_type = str(row.get("type") or "").strip().lower()
|
||||
if not in_type:
|
||||
continue
|
||||
network = str(row.get("network") or "").strip().lower()
|
||||
sec = _extract_security(row)
|
||||
return in_type, network, sec
|
||||
|
||||
return "", "", ""
|
||||
|
||||
|
||||
def _load_raw_config_from_client_config(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
path = _first_non_empty(
|
||||
cfg.get("config_path"),
|
||||
cfg.get("singbox_config_path"),
|
||||
cfg.get("raw_config_path"),
|
||||
)
|
||||
if not path:
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
parsed = json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(parsed, dict):
|
||||
return {}
|
||||
return parsed
|
||||
|
||||
|
||||
def _as_dict(raw: Any) -> dict[str, Any]:
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def _normalize_security(value: str) -> str:
|
||||
sec = str(value or "").strip().lower()
|
||||
if not sec:
|
||||
return ""
|
||||
aliases = {
|
||||
"off": "none",
|
||||
"disabled": "none",
|
||||
"plain": "none",
|
||||
"reality-tls": "reality",
|
||||
"xtls": "tls",
|
||||
}
|
||||
return aliases.get(sec, sec)
|
||||
|
||||
|
||||
def _extract_security(node: dict[str, Any]) -> str:
|
||||
sec = _normalize_security(_first_non_empty(node.get("security"), node.get("tls_security")))
|
||||
if sec:
|
||||
return sec
|
||||
|
||||
tls = _as_dict(node.get("tls"))
|
||||
if not tls:
|
||||
return ""
|
||||
enabled_raw = tls.get("enabled")
|
||||
if enabled_raw is False:
|
||||
return "none"
|
||||
|
||||
reality = _as_dict(tls.get("reality"))
|
||||
if reality:
|
||||
if _truthy(reality.get("enabled")):
|
||||
return "reality"
|
||||
if _first_non_empty(reality.get("public_key"), reality.get("short_id"), reality.get("short_ids")):
|
||||
return "reality"
|
||||
return "tls"
|
||||
|
||||
|
||||
def _truthy(raw: Any) -> bool:
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, int):
|
||||
return raw != 0
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
return False
|
||||
|
||||
|
||||
def _default_transport_for_protocol(protocol: str) -> str:
|
||||
p = str(protocol or "").strip().lower()
|
||||
if p in ("vless", "trojan", "shadowsocks", "socks", "http"):
|
||||
return "tcp"
|
||||
if p in ("wireguard", "hysteria2", "tuic"):
|
||||
return "udp"
|
||||
return ""
|
||||
|
||||
|
||||
def _first_non_empty(*values: Any) -> str:
|
||||
for value in values:
|
||||
s = str(value or "").strip()
|
||||
if s:
|
||||
return s
|
||||
return ""
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user