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

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

View File

@@ -0,0 +1,4 @@
from .client import ApiClient
from .errors import ApiError
from .models import *
from .utils import strip_ansi

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
from .views import *
from .core_controller import ControllerCoreMixin
from .status_controller import StatusControllerMixin
from .vpn_controller import VpnControllerMixin
from .routes_controller import RoutesControllerMixin
from .traffic_controller import TrafficControllerMixin
from .transport_controller import TransportControllerMixin
from .dns_controller import DNSControllerMixin
from .domains_controller import DomainsControllerMixin
from .trace_controller import TraceControllerMixin

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env python3
from __future__ import annotations
import re
from typing import Iterable, List, Optional
from api_client import CmdResult, Event, LoginState, VpnStatus
# Вырезаем спам автопроверки из логов (CLI любит писать "Next check in ...").
_NEXT_CHECK_RE = re.compile(
r"(?:\b\d+s\.)?\s*Next check in\s+\d+s\.?,?", re.IGNORECASE
)
class ControllerCoreMixin:
# -------- logging --------
def log_gui(self, msg: str) -> None:
self.client.trace_append("gui", msg)
def log_smartdns(self, msg: str) -> None:
self.client.trace_append("smartdns", msg)
# -------- events stream --------
def iter_events(self, since: int = 0, stop=None):
return self.client.events_stream(since=since, stop=stop)
def classify_event(self, ev: Event) -> List[str]:
"""Return list of areas to refresh for given event kind."""
k = (ev.kind or "").strip().lower()
if not k:
return []
if k in ("status_changed", "status_error"):
return ["status", "routes", "vpn"]
if k in ("login_state_changed", "login_state_error"):
return ["login", "vpn"]
if k == "autoloop_status_changed":
return ["vpn"]
if k == "vpn_locations_changed":
return ["vpn"]
if k == "unit_state_changed":
return ["status", "vpn", "routes", "dns"]
if k in ("trace_changed", "trace_append"):
return ["trace"]
if k == "routes_nft_progress":
# Перерисовать блок "routes" (кнопки + прогресс).
return ["routes"]
if k == "traffic_mode_changed":
return ["routes", "status"]
if k == "traffic_profiles_changed":
# Used by Traffic mode dialog (Apps/runtime) for persistent app profiles.
return ["routes"]
if k in (
"transport_client_state_changed",
"transport_client_health_changed",
"transport_client_provisioned",
"transport_policy_validated",
"transport_policy_applied",
"transport_conflict_detected",
):
return ["transport", "status"]
if k == "egress_identity_changed":
return ["vpn", "transport"]
return []
# -------- helpers --------
def _is_logged_in_state(self, st: LoginState) -> bool:
# Backend "state" может быть любым, делаем устойчивую проверку.
s = (st.state or "").strip().lower()
if st.email:
return True
if s in ("ok", "logged", "logged_in", "success", "authorized", "ready"):
return True
return False
def _level_to_color(self, level: str) -> str:
lv = (level or "").strip().lower()
if lv in ("green", "ok", "true", "success"):
return "green"
if lv in ("red", "error", "false", "failed"):
return "red"
return "orange"
def _format_policy_route(
self,
policy_ok: Optional[bool],
route_ok: Optional[bool],
) -> str:
if policy_ok is None and route_ok is None:
return "unknown (not checked)"
val = policy_ok if policy_ok is not None else route_ok
if val is True:
return "OK (default route present in VPN table)"
return "MISSING default route in VPN table"
def _resolve_routes_unit(self, iface: str) -> str:
forced = (self.routes_unit or "").strip()
if forced:
return forced
ifc = (iface or "").strip()
if ifc and ifc != "-":
return f"selective-vpn2@{ifc}.service"
return ""
# -------- formatting helpers --------
def _pretty_cmd(self, res: CmdResult) -> str:
lines: List[str] = []
lines.append("OK" if res.ok else "ERROR")
if res.message:
lines.append(res.message.strip())
if res.exit_code is not None:
lines.append(f"exit_code: {res.exit_code}")
if res.stdout.strip():
lines.append("")
lines.append("stdout:")
lines.append(res.stdout.rstrip())
if res.stderr.strip() and res.stderr.strip() != res.stdout.strip():
lines.append("")
lines.append("stderr:")
lines.append(res.stderr.rstrip())
return "\n".join(lines).strip() + "\n"
def _pretty_cmd_then_status(self, res: CmdResult, st: VpnStatus) -> str:
return (
self._pretty_cmd(res).rstrip()
+ "\n\n"
+ self._pretty_vpn_status(st).rstrip()
+ "\n"
)
def _clean_login_lines(self, lines: Iterable[str]) -> List[str]:
out: List[str] = []
for raw in lines or []:
if raw is None:
continue
s = str(raw).replace("\r", "\n")
for part in s.splitlines():
t = part.strip()
if not t:
continue
# Вырезаем спам "Next check in ...".
t2 = _NEXT_CHECK_RE.sub("", t).strip()
if not t2:
continue
# На всякий повторно.
t2 = _NEXT_CHECK_RE.sub("", t2).strip()
if not t2:
continue
out.append(t2)
return out

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
from __future__ import annotations
from typing import List, cast
from api_client import (
DNSBenchmarkResponse,
DNSBenchmarkUpstream,
DNSStatus,
DNSUpstreamPoolState,
DnsUpstreams,
SmartdnsRuntimeState,
)
from .views import ActionView, ServiceAction
class DNSControllerMixin:
def dns_upstreams_view(self) -> DnsUpstreams:
return self.client.dns_upstreams_get()
def dns_upstreams_save(self, cfg: DnsUpstreams) -> None:
self.client.dns_upstreams_set(cfg)
def dns_upstream_pool_view(self) -> DNSUpstreamPoolState:
return self.client.dns_upstream_pool_get()
def dns_upstream_pool_save(self, items: List[DNSBenchmarkUpstream]) -> DNSUpstreamPoolState:
return self.client.dns_upstream_pool_set(items)
def dns_benchmark(
self,
upstreams: List[DNSBenchmarkUpstream],
domains: List[str],
timeout_ms: int = 1800,
attempts: int = 1,
concurrency: int = 6,
profile: str = "load",
) -> DNSBenchmarkResponse:
return self.client.dns_benchmark(
upstreams=upstreams,
domains=domains,
timeout_ms=timeout_ms,
attempts=attempts,
concurrency=concurrency,
profile=profile,
)
def dns_status_view(self) -> DNSStatus:
return self.client.dns_status_get()
def dns_mode_set(self, via: bool, smartdns_addr: str) -> DNSStatus:
return self.client.dns_mode_set(via, smartdns_addr)
def smartdns_service_action(self, action: str) -> DNSStatus:
act = action.strip().lower()
if act not in ("start", "stop", "restart"):
raise ValueError(f"Invalid SmartDNS action: {action}")
return self.client.dns_smartdns_service_set(cast(ServiceAction, act))
def smartdns_prewarm(self, limit: int = 0, aggressive_subs: bool = False) -> ActionView:
res = self.client.smartdns_prewarm(limit=limit, aggressive_subs=aggressive_subs)
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
def smartdns_runtime_view(self) -> SmartdnsRuntimeState:
return self.client.smartdns_runtime_get()
def smartdns_runtime_set(self, enabled: bool, restart: bool = True) -> SmartdnsRuntimeState:
return self.client.smartdns_runtime_set(enabled=enabled, restart=restart)
# -------- Domains --------

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
from __future__ import annotations
from typing import Literal, cast
from api_client import DomainsFile, DomainsTable
class DomainsControllerMixin:
def domains_table_view(self) -> DomainsTable:
return self.client.domains_table()
def domains_file_load(self, name: str) -> DomainsFile:
nm = name.strip().lower()
if nm not in (
"bases",
"meta",
"subs",
"static",
"smartdns",
"last-ips-map",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
):
raise ValueError(f"Invalid domains file name: {name}")
return self.client.domains_file_get(
cast(
Literal[
"bases",
"meta",
"subs",
"static",
"smartdns",
"last-ips-map",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
],
nm,
)
)
def domains_file_save(self, name: str, content: str) -> None:
nm = name.strip().lower()
if nm not in (
"bases",
"meta",
"subs",
"static",
"smartdns",
"last-ips-map",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
):
raise ValueError(f"Invalid domains file name: {name}")
self.client.domains_file_set(
cast(
Literal[
"bases",
"meta",
"subs",
"static",
"smartdns",
"last-ips-map",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
],
nm,
),
content,
)
# -------- Trace --------

View File

@@ -0,0 +1,161 @@
#!/usr/bin/env python3
from __future__ import annotations
import re
from typing import cast
from api_client import CmdResult, Event
from .views import ActionView, RoutesNftProgressView, RoutesResolveSummaryView, ServiceAction
class RoutesControllerMixin:
def routes_service_action(self, action: str) -> ActionView:
act = action.strip().lower()
if act not in ("start", "stop", "restart"):
raise ValueError(f"Invalid routes action: {action}")
res = self.client.routes_service(cast(ServiceAction, act))
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
def routes_clear(self) -> ActionView:
res = self.client.routes_clear()
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
def routes_cache_restore(self) -> ActionView:
res = self.client.routes_cache_restore()
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
def routes_precheck_debug(self, run_now: bool = True) -> ActionView:
res = self.client.routes_precheck_debug(run_now=run_now)
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
def routes_fix_policy_route(self) -> ActionView:
res = self.client.routes_fix_policy_route()
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
def routes_timer_enabled(self) -> bool:
st = self.client.routes_timer_get()
return bool(st.enabled)
def routes_timer_set(self, enabled: bool) -> ActionView:
res = self.client.routes_timer_set(bool(enabled))
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
def routes_resolve_summary_view(self) -> RoutesResolveSummaryView:
dump = self.client.trace_get("full")
lines = list(getattr(dump, "lines", []) or [])
line = ""
for raw in reversed(lines):
s = str(raw or "")
if "resolve summary:" in s:
line = s
break
if not line:
return RoutesResolveSummaryView(
available=False,
text="Resolve summary: no data yet",
recheck_text="Timeout recheck: —",
color="gray",
recheck_color="gray",
)
tail = line.split("resolve summary:", 1)[1]
pairs: dict[str, int] = {}
for m in re.finditer(r"([a-zA-Z0-9_]+)=(-?\d+)", tail):
k = str(m.group(1) or "").strip().lower()
try:
pairs[k] = int(m.group(2))
except Exception:
continue
unique_ips = int(pairs.get("unique_ips", 0))
direct_ips = int(pairs.get("direct_ips", 0))
wildcard_ips = int(pairs.get("wildcard_ips", 0))
unresolved = int(pairs.get("unresolved", 0))
unresolved_live = int(pairs.get("unresolved_live", 0))
unresolved_suppressed = int(pairs.get("unresolved_suppressed", 0))
q_hits = int(pairs.get("quarantine_hits", 0))
dns_attempts = int(pairs.get("dns_attempts", 0))
dns_timeout = int(pairs.get("dns_timeout", 0))
dns_cooldown_skips = int(pairs.get("dns_cooldown_skips", 0))
live_batch_target = int(pairs.get("live_batch_target", 0))
live_batch_deferred = int(pairs.get("live_batch_deferred", 0))
live_batch_p1 = int(pairs.get("live_batch_p1", 0))
live_batch_p2 = int(pairs.get("live_batch_p2", 0))
live_batch_p3 = int(pairs.get("live_batch_p3", 0))
live_batch_nxheavy_pct = int(pairs.get("live_batch_nxheavy_pct", 0))
live_batch_nxheavy_skip = int(pairs.get("live_batch_nxheavy_skip", 0))
r_checked = int(pairs.get("timeout_recheck_checked", 0))
r_recovered = int(pairs.get("timeout_recheck_recovered", 0))
r_recovered_ips = int(pairs.get("timeout_recheck_recovered_ips", 0))
r_still_timeout = int(pairs.get("timeout_recheck_still_timeout", 0))
r_now_nx = int(pairs.get("timeout_recheck_now_nxdomain", 0))
r_now_tmp = int(pairs.get("timeout_recheck_now_temporary", 0))
text = (
f"Resolve: ips={unique_ips} (direct={direct_ips}, wildcard={wildcard_ips}, "
f"+recheck_ips={r_recovered_ips}) | unresolved={unresolved} "
f"(live={unresolved_live}, suppressed={unresolved_suppressed}) | "
f"quarantine_hits={q_hits} | dns_timeout={dns_timeout} "
f"| cooldown_skips={dns_cooldown_skips} | attempts={dns_attempts} "
f"| live_batch={live_batch_target} deferred={live_batch_deferred} "
f"(p1={live_batch_p1}, p2={live_batch_p2}, p3={live_batch_p3}, nx_pct={live_batch_nxheavy_pct}, nx_skip={live_batch_nxheavy_skip})"
)
recheck_text = (
f"Timeout recheck: checked={r_checked} recovered={r_recovered} "
f"still_timeout={r_still_timeout} now_nxdomain={r_now_nx} now_temporary={r_now_tmp}"
)
color = "green" if unresolved < 4000 else ("#b58900" if unresolved < 10000 else "red")
if dns_timeout > 500 and color == "green":
color = "#b58900"
if live_batch_p3 > 0 and (live_batch_p1+live_batch_p2) > 0:
ratio = float(live_batch_p3) / float(live_batch_p1 + live_batch_p2 + live_batch_p3)
if ratio > 0.8:
color = "#b58900" if color == "green" else color
if ratio > 0.95:
color = "red"
recheck_color = "green" if r_still_timeout <= 20 else ("#b58900" if r_still_timeout <= 100 else "red")
return RoutesResolveSummaryView(
available=True,
text=text,
recheck_text=recheck_text,
color=color,
recheck_color=recheck_color,
)
def routes_nft_progress_from_event(self, ev: Event) -> RoutesNftProgressView:
"""
Превращает Event(kind='routes_nft_progress') в удобную модель
для прогресс-бара/лейбла.
"""
payload = (
getattr(ev, "data", None)
or getattr(ev, "payload", None)
or getattr(ev, "extra", None)
or {}
)
if not isinstance(payload, dict):
payload = {}
try:
percent = int(payload.get("percent", 0))
except Exception:
percent = 0
msg = str(payload.get("message", "")) if payload is not None else ""
if not msg:
msg = "Updating nft set…"
active = 0 <= percent < 100
return RoutesNftProgressView(
percent=percent,
message=msg,
active=active,
)
# -------- DNS / SmartDNS --------

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
from __future__ import annotations
from api_client import LoginState, Status, UnitState, VpnStatus
from .views import LoginView, StatusOverviewView
class StatusControllerMixin:
def get_login_view(self) -> LoginView:
st: LoginState = self.client.get_login_state()
# Prefer backend UI-ready "text" if provided, else build it.
if st.text:
txt = st.text
else:
if st.email:
txt = f"AdGuard VPN: logged in as {st.email}"
else:
txt = "AdGuard VPN: (no login data)"
logged_in = self._is_logged_in_state(st)
# Цвет: либо из backend, либо простой нормализованный вариант
if st.color:
color = st.color
else:
if logged_in:
color = "green"
else:
s = (st.state or "").strip().lower()
color = "orange" if s in ("unknown", "checking") else "red"
return LoginView(
text=txt,
color=color,
logged_in=logged_in,
email=st.email or "",
)
def get_status_overview(self) -> StatusOverviewView:
st: Status = self.client.get_status()
routes_unit = self._resolve_routes_unit(st.iface)
routes_s: UnitState = (
self.client.systemd_state(routes_unit)
if routes_unit
else UnitState(state="unknown")
)
smartdns_s: UnitState = self.client.systemd_state(self.smartdns_unit)
vpn_st: VpnStatus = self.client.vpn_status()
counts = f"domains={st.domain_count}, ips={st.ip_count}"
iface = f"iface={st.iface} table={st.table} mark={st.mark}"
policy_route = self._format_policy_route(st.policy_route_ok, st.route_ok)
# SmartDNS: если state пустой/unknown — считаем это ошибкой
smart_state = smartdns_s.state or "unknown"
if smart_state.lower() in ("", "unknown", "failed"):
smart_state = "ERROR (unknown state)"
return StatusOverviewView(
timestamp=st.timestamp or "",
counts=counts,
iface_table_mark=iface,
policy_route=policy_route,
routes_service=f"{routes_unit or 'selective-vpn2@<auto>.service'}: {routes_s.state}",
smartdns_service=f"{self.smartdns_unit}: {smart_state}",
# это состояние самого VPN-юнита, НЕ autoloop:
# т.е. работает ли AdGuardVPN-daemon / туннель
vpn_service=f"VPN: {vpn_st.unit_state}",
)

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env python3
from __future__ import annotations
from .views import TraceMode
from api_client import TraceDump
class TraceControllerMixin:
# -------- Trace --------
def trace_view(self, mode: TraceMode = "full") -> TraceDump:
return self.client.trace_get(mode)

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env python3
from __future__ import annotations
from typing import List, Optional
from api_client import (
CmdResult,
TrafficAppMarkItem,
TrafficAppMarksResult,
TrafficAppMarksStatus,
TrafficAppProfile,
TrafficAppProfileSaveResult,
TrafficAudit,
TrafficCandidates,
TrafficInterfaces,
TrafficModeStatus,
)
from .views import TrafficModeView
class TrafficControllerMixin:
def traffic_mode_view(self) -> TrafficModeView:
st: TrafficModeStatus = self.client.traffic_mode_get()
return TrafficModeView(
desired_mode=(st.desired_mode or st.mode or "selective"),
applied_mode=(st.applied_mode or "direct"),
preferred_iface=st.preferred_iface or "",
advanced_active=bool(st.advanced_active),
auto_local_bypass=bool(st.auto_local_bypass),
auto_local_active=bool(st.auto_local_active),
ingress_reply_bypass=bool(st.ingress_reply_bypass),
ingress_reply_active=bool(st.ingress_reply_active),
bypass_candidates=int(st.bypass_candidates),
force_vpn_subnets=list(st.force_vpn_subnets or []),
force_vpn_uids=list(st.force_vpn_uids or []),
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
force_direct_subnets=list(st.force_direct_subnets or []),
force_direct_uids=list(st.force_direct_uids or []),
force_direct_cgroups=list(st.force_direct_cgroups or []),
overrides_applied=int(st.overrides_applied),
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
cgroup_warning=st.cgroup_warning or "",
active_iface=st.active_iface or "",
iface_reason=st.iface_reason or "",
ingress_rule_present=bool(st.ingress_rule_present),
ingress_nft_active=bool(st.ingress_nft_active),
probe_ok=bool(st.probe_ok),
probe_message=st.probe_message or "",
healthy=bool(st.healthy),
message=st.message or "",
)
def traffic_mode_set(
self,
mode: str,
preferred_iface: Optional[str] = None,
auto_local_bypass: Optional[bool] = None,
ingress_reply_bypass: Optional[bool] = None,
force_vpn_subnets: Optional[List[str]] = None,
force_vpn_uids: Optional[List[str]] = None,
force_vpn_cgroups: Optional[List[str]] = None,
force_direct_subnets: Optional[List[str]] = None,
force_direct_uids: Optional[List[str]] = None,
force_direct_cgroups: Optional[List[str]] = None,
) -> TrafficModeView:
st: TrafficModeStatus = self.client.traffic_mode_set(
mode,
preferred_iface,
auto_local_bypass,
ingress_reply_bypass,
force_vpn_subnets,
force_vpn_uids,
force_vpn_cgroups,
force_direct_subnets,
force_direct_uids,
force_direct_cgroups,
)
return TrafficModeView(
desired_mode=(st.desired_mode or st.mode or mode),
applied_mode=(st.applied_mode or "direct"),
preferred_iface=st.preferred_iface or "",
advanced_active=bool(st.advanced_active),
auto_local_bypass=bool(st.auto_local_bypass),
auto_local_active=bool(st.auto_local_active),
ingress_reply_bypass=bool(st.ingress_reply_bypass),
ingress_reply_active=bool(st.ingress_reply_active),
bypass_candidates=int(st.bypass_candidates),
force_vpn_subnets=list(st.force_vpn_subnets or []),
force_vpn_uids=list(st.force_vpn_uids or []),
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
force_direct_subnets=list(st.force_direct_subnets or []),
force_direct_uids=list(st.force_direct_uids or []),
force_direct_cgroups=list(st.force_direct_cgroups or []),
overrides_applied=int(st.overrides_applied),
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
cgroup_warning=st.cgroup_warning or "",
active_iface=st.active_iface or "",
iface_reason=st.iface_reason or "",
ingress_rule_present=bool(st.ingress_rule_present),
ingress_nft_active=bool(st.ingress_nft_active),
probe_ok=bool(st.probe_ok),
probe_message=st.probe_message or "",
healthy=bool(st.healthy),
message=st.message or "",
)
def traffic_mode_test(self) -> TrafficModeView:
st: TrafficModeStatus = self.client.traffic_mode_test()
return TrafficModeView(
desired_mode=(st.desired_mode or st.mode or "selective"),
applied_mode=(st.applied_mode or "direct"),
preferred_iface=st.preferred_iface or "",
advanced_active=bool(st.advanced_active),
auto_local_bypass=bool(st.auto_local_bypass),
auto_local_active=bool(st.auto_local_active),
ingress_reply_bypass=bool(st.ingress_reply_bypass),
ingress_reply_active=bool(st.ingress_reply_active),
bypass_candidates=int(st.bypass_candidates),
force_vpn_subnets=list(st.force_vpn_subnets or []),
force_vpn_uids=list(st.force_vpn_uids or []),
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
force_direct_subnets=list(st.force_direct_subnets or []),
force_direct_uids=list(st.force_direct_uids or []),
force_direct_cgroups=list(st.force_direct_cgroups or []),
overrides_applied=int(st.overrides_applied),
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
cgroup_warning=st.cgroup_warning or "",
active_iface=st.active_iface or "",
iface_reason=st.iface_reason or "",
ingress_rule_present=bool(st.ingress_rule_present),
ingress_nft_active=bool(st.ingress_nft_active),
probe_ok=bool(st.probe_ok),
probe_message=st.probe_message or "",
healthy=bool(st.healthy),
message=st.message or "",
)
def traffic_advanced_reset(self) -> TrafficModeView:
st: TrafficModeStatus = self.client.traffic_advanced_reset()
return TrafficModeView(
desired_mode=(st.desired_mode or st.mode or "selective"),
applied_mode=(st.applied_mode or "direct"),
preferred_iface=st.preferred_iface or "",
advanced_active=bool(st.advanced_active),
auto_local_bypass=bool(st.auto_local_bypass),
auto_local_active=bool(st.auto_local_active),
ingress_reply_bypass=bool(st.ingress_reply_bypass),
ingress_reply_active=bool(st.ingress_reply_active),
bypass_candidates=int(st.bypass_candidates),
force_vpn_subnets=list(st.force_vpn_subnets or []),
force_vpn_uids=list(st.force_vpn_uids or []),
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
force_direct_subnets=list(st.force_direct_subnets or []),
force_direct_uids=list(st.force_direct_uids or []),
force_direct_cgroups=list(st.force_direct_cgroups or []),
overrides_applied=int(st.overrides_applied),
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
cgroup_warning=st.cgroup_warning or "",
active_iface=st.active_iface or "",
iface_reason=st.iface_reason or "",
ingress_rule_present=bool(st.ingress_rule_present),
ingress_nft_active=bool(st.ingress_nft_active),
probe_ok=bool(st.probe_ok),
probe_message=st.probe_message or "",
healthy=bool(st.healthy),
message=st.message or "",
)
def traffic_interfaces(self) -> List[str]:
st: TrafficInterfaces = self.client.traffic_interfaces_get()
vals = [x for x in st.interfaces if x]
if st.preferred_iface and st.preferred_iface not in vals:
vals.insert(0, st.preferred_iface)
return vals
def traffic_candidates(self) -> TrafficCandidates:
return self.client.traffic_candidates_get()
def traffic_appmarks_status(self) -> TrafficAppMarksStatus:
return self.client.traffic_appmarks_status()
def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]:
return self.client.traffic_appmarks_items()
def traffic_appmarks_apply(
self,
*,
op: str,
target: str,
cgroup: str = "",
unit: str = "",
command: str = "",
app_key: str = "",
timeout_sec: int = 0,
) -> TrafficAppMarksResult:
return self.client.traffic_appmarks_apply(
op=op,
target=target,
cgroup=cgroup,
unit=unit,
command=command,
app_key=app_key,
timeout_sec=timeout_sec,
)
def traffic_app_profiles_list(self) -> List[TrafficAppProfile]:
return self.client.traffic_app_profiles_list()
def traffic_app_profile_upsert(
self,
*,
id: str = "",
name: str = "",
app_key: str = "",
command: str,
target: str,
ttl_sec: int = 0,
vpn_profile: str = "",
) -> TrafficAppProfileSaveResult:
return self.client.traffic_app_profile_upsert(
id=id,
name=name,
app_key=app_key,
command=command,
target=target,
ttl_sec=ttl_sec,
vpn_profile=vpn_profile,
)
def traffic_app_profile_delete(self, id: str) -> CmdResult:
return self.client.traffic_app_profile_delete(id)
def traffic_audit(self) -> TrafficAudit:
return self.client.traffic_audit_get()
# -------- Transport flow (E4.2 foundation) --------

View File

@@ -0,0 +1,807 @@
#!/usr/bin/env python3
from __future__ import annotations
from dataclasses import replace
import json
from typing import Any, Dict, List, Optional, cast
from api_client import (
ApiError,
CmdResult,
SingBoxProfile,
SingBoxProfileApplyResult,
SingBoxProfileHistoryResult,
SingBoxProfileIssue,
SingBoxProfileRenderResult,
SingBoxProfileRollbackResult,
SingBoxProfilesState,
SingBoxProfileValidateResult,
TransportCapabilities,
TransportClient,
TransportClientActionResult,
TransportClientHealthSnapshot,
TransportInterfacesSnapshot,
TransportConflict,
TransportConflicts,
TransportHealthRefreshResult,
TransportNetnsToggleResult,
TransportOwnerLocksClearResult,
TransportOwnerLocksSnapshot,
TransportOwnershipSnapshot,
TransportPolicy,
TransportPolicyApplyResult,
TransportPolicyIntent,
TransportPolicyValidateResult,
)
from .views import ActionView, TransportClientAction, TransportFlowPhase, TransportPolicyFlowView
class TransportControllerMixin:
def transport_clients(
self,
enabled_only: bool = False,
kind: str = "",
include_virtual: bool = False,
) -> List[TransportClient]:
return self.client.transport_clients_get(
enabled_only=enabled_only,
kind=kind,
include_virtual=include_virtual,
)
def transport_interfaces(self) -> TransportInterfacesSnapshot:
return self.client.transport_interfaces_get()
def transport_health_refresh(
self,
*,
client_ids: Optional[List[str]] = None,
force: bool = False,
) -> TransportHealthRefreshResult:
return self.client.transport_health_refresh(client_ids=client_ids, force=force)
def transport_client_health(self, client_id: str) -> TransportClientHealthSnapshot:
return self.client.transport_client_health_get(client_id)
def transport_client_create_action(
self,
*,
client_id: str,
kind: str,
name: str = "",
enabled: bool = True,
config: Optional[Dict[str, Any]] = None,
) -> ActionView:
cid = str(client_id or "").strip()
if not cid:
raise ValueError("missing transport client id")
res: CmdResult = self.client.transport_client_create(
client_id=cid,
kind=str(kind or "").strip().lower(),
name=name,
enabled=enabled,
config=config,
)
msg = res.message or "client create completed"
return ActionView(ok=bool(res.ok), pretty_text=f"create {cid}: {msg}")
def transport_client_action(self, client_id: str, action: TransportClientAction) -> ActionView:
res: TransportClientActionResult = self.client.transport_client_action(client_id, action)
status_bits = []
before = (res.status_before or "").strip()
after = (res.status_after or "").strip()
if before or after:
status_bits.append(f"status {before or '-'} -> {after or '-'}")
if res.code:
status_bits.append(f"code={res.code}")
if res.last_error:
status_bits.append(f"last_error={res.last_error}")
extra = f" ({'; '.join(status_bits)})" if status_bits else ""
msg = res.message or f"{res.action} completed"
return ActionView(ok=bool(res.ok), pretty_text=f"{res.action} {res.client_id}: {msg}{extra}")
def transport_client_patch_action(
self,
client_id: str,
*,
name: Optional[str] = None,
enabled: Optional[bool] = None,
config: Optional[Dict[str, Any]] = None,
) -> ActionView:
cid = str(client_id or "").strip()
if not cid:
raise ValueError("missing transport client id")
res: CmdResult = self.client.transport_client_patch(
cid,
name=name,
enabled=enabled,
config=config,
)
msg = res.message or "client patch completed"
return ActionView(ok=bool(res.ok), pretty_text=f"patch {cid}: {msg}")
def transport_client_delete_action(
self,
client_id: str,
*,
force: bool = False,
cleanup: bool = True,
) -> ActionView:
cid = str(client_id or "").strip()
if not cid:
raise ValueError("missing transport client id")
res: CmdResult = self.client.transport_client_delete(cid, force=force, cleanup=cleanup)
msg = res.message or "delete completed"
return ActionView(ok=bool(res.ok), pretty_text=f"delete {cid}: {msg}")
def transport_netns_toggle(
self,
*,
enabled: Optional[bool] = None,
client_ids: Optional[List[str]] = None,
provision: bool = True,
restart_running: bool = True,
) -> TransportNetnsToggleResult:
ids = [
str(x).strip()
for x in (client_ids or [])
if str(x).strip()
] if client_ids is not None else None
return self.client.transport_netns_toggle(
enabled=enabled,
client_ids=ids,
provision=provision,
restart_running=restart_running,
)
def transport_policy_rollback_action(self, base_revision: int = 0) -> ActionView:
base = int(base_revision or 0)
if base <= 0:
base = int(self.client.transport_policy_get().revision or 0)
res: TransportPolicyApplyResult = self.client.transport_policy_rollback(base_revision=base)
if res.ok:
msg = res.message or "policy rollback applied"
bits = [f"revision={int(res.policy_revision or 0)}"]
if res.apply_id:
bits.append(f"apply_id={res.apply_id}")
return ActionView(ok=True, pretty_text=f"{msg} ({', '.join(bits)})")
msg = res.message or "policy rollback failed"
if res.code:
msg = f"{msg} (code={res.code})"
return ActionView(ok=False, pretty_text=msg)
def transport_policy(self) -> TransportPolicy:
return self.client.transport_policy_get()
def transport_ownership(self) -> TransportOwnershipSnapshot:
return self.client.transport_ownership_get()
def transport_owner_locks(self) -> TransportOwnerLocksSnapshot:
return self.client.transport_owner_locks_get()
def transport_owner_locks_clear(
self,
*,
base_revision: int = 0,
client_id: str = "",
destination_ip: str = "",
destination_ips: Optional[List[str]] = None,
confirm_token: str = "",
) -> TransportOwnerLocksClearResult:
return self.client.transport_owner_locks_clear(
base_revision=int(base_revision or 0),
client_id=str(client_id or "").strip(),
destination_ip=str(destination_ip or "").strip(),
destination_ips=[
str(x).strip()
for x in list(destination_ips or [])
if str(x).strip()
],
confirm_token=str(confirm_token or "").strip(),
)
def transport_owner_locks_clear_action(
self,
*,
base_revision: int = 0,
client_id: str = "",
destination_ip: str = "",
destination_ips: Optional[List[str]] = None,
confirm_token: str = "",
) -> ActionView:
res = self.transport_owner_locks_clear(
base_revision=base_revision,
client_id=client_id,
destination_ip=destination_ip,
destination_ips=destination_ips,
confirm_token=confirm_token,
)
bits: List[str] = []
if res.code:
bits.append(f"code={res.code}")
bits.append(f"match={int(res.match_count)}")
bits.append(f"cleared={int(res.cleared_count)}")
bits.append(f"remaining={int(res.remaining_count)}")
msg = (res.message or "owner-lock clear").strip()
return ActionView(ok=bool(res.ok), pretty_text=f"{msg} ({', '.join(bits)})")
def transport_conflicts(self) -> TransportConflicts:
return self.client.transport_conflicts_get()
def transport_capabilities(self) -> TransportCapabilities:
return self.client.transport_capabilities_get()
def transport_flow_draft(
self,
intents: Optional[List[TransportPolicyIntent]] = None,
*,
base_revision: int = 0,
) -> TransportPolicyFlowView:
pol = self.client.transport_policy_get()
rev = int(base_revision) if int(base_revision or 0) > 0 else int(pol.revision)
return TransportPolicyFlowView(
phase="draft",
intents=list(intents) if intents is not None else list(pol.intents),
base_revision=rev,
current_revision=int(pol.revision),
applied_revision=0,
confirm_token="",
valid=False,
block_count=0,
warn_count=0,
diff_added=0,
diff_changed=0,
diff_removed=0,
conflicts=[],
apply_id="",
rollback_available=False,
message="draft ready",
code="",
)
def transport_flow_update_draft(
self,
flow: TransportPolicyFlowView,
intents: List[TransportPolicyIntent],
*,
base_revision: int = 0,
) -> TransportPolicyFlowView:
rev = int(base_revision) if int(base_revision or 0) > 0 else int(flow.current_revision or flow.base_revision)
return replace(
flow,
phase="draft",
intents=list(intents),
base_revision=rev,
applied_revision=0,
confirm_token="",
valid=False,
block_count=0,
warn_count=0,
diff_added=0,
diff_changed=0,
diff_removed=0,
conflicts=[],
apply_id="",
rollback_available=False,
message="draft updated",
code="",
)
def transport_flow_validate(
self,
flow: TransportPolicyFlowView,
*,
allow_warnings: bool = True,
) -> TransportPolicyFlowView:
res: TransportPolicyValidateResult = self.client.transport_policy_validate(
base_revision=int(flow.base_revision or 0),
intents=list(flow.intents),
allow_warnings=allow_warnings,
force_override=False,
)
phase: TransportFlowPhase = "validated"
if not res.valid or int(res.summary.block_count) > 0:
phase = "risky"
return replace(
flow,
phase=phase,
base_revision=int(res.base_revision or flow.base_revision),
current_revision=int(res.base_revision or flow.current_revision),
confirm_token=res.confirm_token,
valid=bool(res.valid),
block_count=int(res.summary.block_count),
warn_count=int(res.summary.warn_count),
diff_added=int(res.diff.added),
diff_changed=int(res.diff.changed),
diff_removed=int(res.diff.removed),
conflicts=list(res.conflicts or []),
apply_id="",
rollback_available=False,
message=res.message or ("validated" if phase == "validated" else "blocking conflicts found"),
code=res.code or "",
)
def transport_flow_confirm(self, flow: TransportPolicyFlowView) -> TransportPolicyFlowView:
if flow.phase != "risky":
raise ValueError("confirm step is allowed only after risky validate")
if not flow.confirm_token:
raise ValueError("missing confirm token; run validate again")
return replace(
flow,
phase="confirm",
message="force apply requires explicit confirmation",
code="FORCE_CONFIRM_REQUIRED",
)
def transport_flow_apply(
self,
flow: TransportPolicyFlowView,
*,
force_override: bool = False,
) -> TransportPolicyFlowView:
if flow.phase == "draft":
return replace(
flow,
message="policy must be validated before apply",
code="VALIDATE_REQUIRED",
)
if flow.phase == "risky" and not force_override:
return replace(
flow,
message="policy has blocking conflicts; open confirm step",
code="POLICY_CONFLICT_BLOCK",
)
if force_override and flow.phase != "confirm":
return replace(
flow,
phase="risky",
message="force apply requires confirm state",
code="FORCE_CONFIRM_REQUIRED",
)
if force_override and not flow.confirm_token:
return replace(
flow,
phase="risky",
message="confirm token is missing or expired; run validate again",
code="FORCE_OVERRIDE_CONFIRM_REQUIRED",
)
res: TransportPolicyApplyResult = self.client.transport_policy_apply(
base_revision=int(flow.base_revision),
intents=list(flow.intents),
force_override=bool(force_override),
confirm_token=flow.confirm_token if force_override else "",
)
return self._transport_flow_from_apply_result(flow, res)
def transport_flow_rollback(self, flow: TransportPolicyFlowView) -> TransportPolicyFlowView:
base = int(flow.current_revision or flow.base_revision)
res: TransportPolicyApplyResult = self.client.transport_policy_rollback(base_revision=base)
return self._transport_flow_from_apply_result(flow, res)
def _transport_flow_from_apply_result(
self,
flow: TransportPolicyFlowView,
res: TransportPolicyApplyResult,
) -> TransportPolicyFlowView:
if res.ok:
pol = self.client.transport_policy_get()
applied_rev = int(res.policy_revision or pol.revision)
return TransportPolicyFlowView(
phase="applied",
intents=list(pol.intents),
base_revision=applied_rev,
current_revision=applied_rev,
applied_revision=applied_rev,
confirm_token="",
valid=True,
block_count=0,
warn_count=0,
diff_added=0,
diff_changed=0,
diff_removed=0,
conflicts=[],
apply_id=res.apply_id or "",
rollback_available=bool(res.rollback_available),
message=res.message or "policy applied",
code=res.code or "",
)
if res.code == "POLICY_REVISION_MISMATCH":
current_rev = int(res.current_revision or 0)
if current_rev <= 0:
current_rev = int(self.client.transport_policy_get().revision)
return replace(
flow,
phase="draft",
base_revision=current_rev,
current_revision=current_rev,
confirm_token="",
valid=False,
message="policy revision changed; validate again",
code=res.code,
)
if res.code in ("POLICY_CONFLICT_BLOCK", "FORCE_OVERRIDE_CONFIRM_REQUIRED"):
conflicts = list(res.conflicts or flow.conflicts)
block_count = len([x for x in conflicts if (x.severity or "").strip().lower() == "block"])
return replace(
flow,
phase="risky",
valid=False,
block_count=block_count,
conflicts=conflicts,
message=res.message or "blocking conflicts",
code=res.code,
)
return replace(
flow,
phase="error",
valid=False,
message=res.message or "transport apply failed",
code=res.code or "TRANSPORT_APPLY_ERROR",
)
def singbox_profile_id_for_client(self, client: Optional[TransportClient]) -> str:
if client is None:
return ""
cfg = getattr(client, "config", {}) or {}
if isinstance(cfg, dict):
for key in ("profile_id", "singbox_profile_id", "profile"):
v = str(cfg.get(key) or "").strip()
if v:
return v
return str(getattr(client, "id", "") or "").strip()
def singbox_profile_ensure_linked(
self,
client: TransportClient,
*,
preferred_profile_id: str = "",
) -> ActionView:
pid, state = self._ensure_singbox_profile_for_client(
client,
preferred_profile_id=str(preferred_profile_id or "").strip(),
)
cid = str(getattr(client, "id", "") or "").strip()
if state == "created":
return ActionView(ok=True, pretty_text=f"profile {pid} created and linked to {cid}")
if state == "linked":
return ActionView(ok=True, pretty_text=f"profile {pid} linked to {cid}")
return ActionView(ok=True, pretty_text=f"profile {pid} already linked to {cid}")
def singbox_profile_validate_action(
self,
profile_id: str,
*,
check_binary: Optional[bool] = None,
client: Optional[TransportClient] = None,
) -> ActionView:
pid = self._resolve_singbox_profile_id(profile_id, client)
res: SingBoxProfileValidateResult = self.client.transport_singbox_profile_validate(
pid,
check_binary=check_binary,
)
ok = bool(res.ok and res.valid)
if ok:
msg = res.message or "profile is valid"
return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("validate", pid, msg, res))
msg = res.message or "profile validation failed"
return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("validate", pid, msg, res))
def singbox_profile_render_preview_action(
self,
profile_id: str,
*,
check_binary: Optional[bool] = None,
persist: bool = False,
client: Optional[TransportClient] = None,
) -> ActionView:
pid = self._resolve_singbox_profile_id(profile_id, client)
res: SingBoxProfileRenderResult = self.client.transport_singbox_profile_render(
pid,
check_binary=check_binary,
persist=bool(persist),
)
ok = bool(res.ok and res.valid)
if ok:
msg = res.message or ("rendered" if persist else "render preview ready")
return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("render", pid, msg, res))
msg = res.message or "render failed"
return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("render", pid, msg, res))
def singbox_profile_apply_action(
self,
profile_id: str,
*,
client_id: str = "",
restart: Optional[bool] = True,
skip_runtime: bool = False,
check_binary: Optional[bool] = None,
client: Optional[TransportClient] = None,
) -> ActionView:
pid = self._resolve_singbox_profile_id(profile_id, client)
cid = str(client_id or "").strip()
if not cid and client is not None:
cid = str(getattr(client, "id", "") or "").strip()
res: SingBoxProfileApplyResult = self.client.transport_singbox_profile_apply(
pid,
client_id=cid,
restart=restart,
skip_runtime=skip_runtime,
check_binary=check_binary,
)
if res.ok:
msg = res.message or "profile applied"
return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("apply", pid, msg, res))
msg = res.message or "profile apply failed"
return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("apply", pid, msg, res))
def singbox_profile_rollback_action(
self,
profile_id: str,
*,
client_id: str = "",
restart: Optional[bool] = True,
skip_runtime: bool = False,
history_id: str = "",
client: Optional[TransportClient] = None,
) -> ActionView:
pid = self._resolve_singbox_profile_id(profile_id, client)
cid = str(client_id or "").strip()
if not cid and client is not None:
cid = str(getattr(client, "id", "") or "").strip()
res: SingBoxProfileRollbackResult = self.client.transport_singbox_profile_rollback(
pid,
client_id=cid,
history_id=history_id,
restart=restart,
skip_runtime=skip_runtime,
)
if res.ok:
msg = res.message or "profile rollback applied"
return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("rollback", pid, msg, res))
msg = res.message or "profile rollback failed"
return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("rollback", pid, msg, res))
def singbox_profile_history_lines(
self,
profile_id: str,
*,
limit: int = 20,
client: Optional[TransportClient] = None,
) -> List[str]:
pid = self._resolve_singbox_profile_id(profile_id, client)
res: SingBoxProfileHistoryResult = self.client.transport_singbox_profile_history(pid, limit=limit)
lines: List[str] = []
for it in list(res.items or []):
lines.append(self._format_singbox_history_line(it))
return lines
def singbox_profile_get_for_client(
self,
client: TransportClient,
*,
profile_id: str = "",
) -> SingBoxProfile:
pid = self._resolve_singbox_profile_id(profile_id, client)
return self.client.transport_singbox_profile_get(pid)
def singbox_profile_save_raw_for_client(
self,
client: TransportClient,
*,
profile_id: str = "",
name: str = "",
enabled: bool = True,
protocol: str = "vless",
raw_config: Optional[Dict[str, Any]] = None,
) -> ActionView:
pid = self._resolve_singbox_profile_id(profile_id, client)
current = self.client.transport_singbox_profile_get(pid)
snap = self.client.transport_singbox_profile_patch(
pid,
base_revision=int(current.profile_revision or 0),
name=(str(name or "").strip() or current.name or pid),
enabled=bool(enabled),
protocol=(str(protocol or "").strip().lower() or "vless"),
mode="raw",
raw_config=cast(Dict[str, Any], raw_config or {}),
)
item = snap.item
if item is None:
return ActionView(ok=False, pretty_text=f"save profile {pid}: backend returned empty item")
return ActionView(
ok=True,
pretty_text=(
f"save profile {pid}: revision={int(item.profile_revision or 0)} "
f"render_revision={int(item.render_revision or 0)}"
),
)
def _format_singbox_profile_action(
self,
action: str,
profile_id: str,
message: str,
res: SingBoxProfileValidateResult | SingBoxProfileRenderResult | SingBoxProfileApplyResult | SingBoxProfileRollbackResult,
) -> str:
bits: List[str] = []
if getattr(res, "code", ""):
bits.append(f"code={str(getattr(res, 'code', '')).strip()}")
rev = int(getattr(res, "profile_revision", 0) or 0)
if rev > 0:
bits.append(f"rev={rev}")
diff = getattr(res, "diff", None)
if diff is not None:
added = int(getattr(diff, "added", 0) or 0)
changed = int(getattr(diff, "changed", 0) or 0)
removed = int(getattr(diff, "removed", 0) or 0)
bits.append(f"diff=+{added}/~{changed}/-{removed}")
render_digest = str(getattr(res, "render_digest", "") or "").strip()
if render_digest:
bits.append(f"digest={render_digest[:12]}")
client_id = str(getattr(res, "client_id", "") or "").strip()
if client_id:
bits.append(f"client={client_id}")
config_path = str(getattr(res, "config_path", "") or "").strip()
if config_path:
bits.append(f"config={config_path}")
history_id = str(getattr(res, "history_id", "") or "").strip()
if history_id:
bits.append(f"history={history_id}")
render_path = str(getattr(res, "render_path", "") or "").strip()
if render_path:
bits.append(f"render={render_path}")
render_revision = int(getattr(res, "render_revision", 0) or 0)
if render_revision > 0:
bits.append(f"render_rev={render_revision}")
rollback_available = bool(getattr(res, "rollback_available", False))
if rollback_available:
bits.append("rollback=available")
errors = cast(List[SingBoxProfileIssue], list(getattr(res, "errors", []) or []))
warnings = cast(List[SingBoxProfileIssue], list(getattr(res, "warnings", []) or []))
if warnings:
bits.append(f"warnings={len(warnings)}")
if errors:
bits.append(f"errors={len(errors)}")
first = self._format_singbox_issue_brief(errors[0])
if first:
bits.append(f"first_error={first}")
tail = f" ({'; '.join(bits)})" if bits else ""
return f"{action} profile {profile_id}: {message}{tail}"
def _format_singbox_history_line(self, it) -> str:
at = str(getattr(it, "at", "") or "").strip() or "-"
action = str(getattr(it, "action", "") or "").strip() or "event"
status = str(getattr(it, "status", "") or "").strip() or "unknown"
msg = str(getattr(it, "message", "") or "").strip()
code = str(getattr(it, "code", "") or "").strip()
digest = str(getattr(it, "render_digest", "") or "").strip()
client_id = str(getattr(it, "client_id", "") or "").strip()
bits: List[str] = []
if code:
bits.append(f"code={code}")
if client_id:
bits.append(f"client={client_id}")
if digest:
bits.append(f"digest={digest[:12]}")
tail = f" ({'; '.join(bits)})" if bits else ""
body = msg or "-"
return f"{at} | {action} | {status} | {body}{tail}"
def _resolve_singbox_profile_id(self, profile_id: str, client: Optional[TransportClient]) -> str:
pid = str(profile_id or "").strip()
if client is not None:
ensured_pid, _ = self._ensure_singbox_profile_for_client(client, preferred_profile_id=pid)
pid = ensured_pid
if not pid:
raise ValueError("missing singbox profile id")
return pid
def _ensure_singbox_profile_for_client(
self,
client: TransportClient,
*,
preferred_profile_id: str = "",
) -> tuple[str, str]:
cid = str(getattr(client, "id", "") or "").strip()
if not cid:
raise ValueError("missing transport client id")
pid = str(preferred_profile_id or "").strip()
if not pid:
pid = self.singbox_profile_id_for_client(client)
if not pid:
raise ValueError("cannot resolve singbox profile id for selected client")
try:
cur = self.client.transport_singbox_profile_get(pid)
except ApiError as e:
if int(getattr(e, "status_code", 0) or 0) != 404:
raise
raw_cfg = self._load_singbox_raw_config_from_client(client)
protocol = self._infer_singbox_protocol(client, raw_cfg)
snap: SingBoxProfilesState = self.client.transport_singbox_profile_create(
profile_id=pid,
name=str(getattr(client, "name", "") or "").strip() or pid,
mode="raw",
protocol=protocol,
raw_config=raw_cfg,
meta={"client_id": cid},
enabled=True,
)
created = snap.item
if created is None:
raise RuntimeError("profile create returned empty item")
return str(created.id or pid).strip(), "created"
meta = dict(cur.meta or {})
if str(meta.get("client_id") or "").strip() == cid:
return pid, "ok"
meta["client_id"] = cid
snap = self.client.transport_singbox_profile_patch(
pid,
base_revision=int(cur.profile_revision or 0),
meta=meta,
)
if snap.item is not None:
pid = str(snap.item.id or pid).strip()
return pid, "linked"
def _load_singbox_raw_config_from_client(self, client: TransportClient) -> dict:
cfg = getattr(client, "config", {}) or {}
if not isinstance(cfg, dict):
return {}
path = str(cfg.get("config_path") or "").strip()
if not path:
return {}
try:
with open(path, "r", encoding="utf-8") as f:
parsed = json.load(f)
if isinstance(parsed, dict):
return cast(dict, parsed)
except Exception:
return {}
return {}
def _infer_singbox_protocol(self, client: TransportClient, raw_cfg: dict) -> str:
cfg = getattr(client, "config", {}) or {}
if isinstance(cfg, dict):
p = str(cfg.get("protocol") or "").strip().lower()
if p:
return p
if isinstance(raw_cfg, dict):
outbounds = raw_cfg.get("outbounds") or []
if isinstance(outbounds, list):
for row in outbounds:
if not isinstance(row, dict):
continue
t = str(row.get("type") or "").strip().lower()
if not t:
continue
if t in ("direct", "block", "dns"):
continue
return t
return "vless"
def _format_singbox_issue_brief(self, issue: SingBoxProfileIssue) -> str:
code = str(getattr(issue, "code", "") or "").strip()
field = str(getattr(issue, "field", "") or "").strip()
message = str(getattr(issue, "message", "") or "").strip()
parts = [x for x in (code, field, message) if x]
if not parts:
return ""
out = ": ".join(parts[:2]) if len(parts) > 1 else parts[0]
if len(parts) > 2:
out = f"{out}: {parts[2]}"
return out if len(out) <= 140 else out[:137] + "..."

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Literal
from api_client import TransportConflict, TransportPolicyIntent
TraceMode = Literal["full", "gui", "smartdns"]
ServiceAction = Literal["start", "stop", "restart"]
LoginAction = Literal["open", "check", "cancel"]
TransportClientAction = Literal["provision", "start", "stop", "restart"]
TransportFlowPhase = Literal["draft", "validated", "risky", "confirm", "applied", "error"]
@dataclass(frozen=True)
class LoginView:
text: str
color: str
logged_in: bool
email: str
@dataclass(frozen=True)
class StatusOverviewView:
timestamp: str
counts: str
iface_table_mark: str
policy_route: str
routes_service: str
smartdns_service: str
vpn_service: str
@dataclass(frozen=True)
class VpnStatusView:
desired_location: str
pretty_text: str
@dataclass(frozen=True)
class ActionView:
ok: bool
pretty_text: str
@dataclass(frozen=True)
class LoginFlowView:
phase: str
level: str
dot_color: str
status_text: str
url: str
email: str
alive: bool
cursor: int
lines: List[str]
can_open: bool
can_check: bool
can_cancel: bool
@dataclass(frozen=True)
class VpnAutoconnectView:
"""Для блока Autoconnect на вкладке AdGuardVPN."""
enabled: bool # True = включён autoloop
unit_text: str # строка вида "unit: active"
color: str # "green" / "red" / "orange"
@dataclass(frozen=True)
class RoutesNftProgressView:
"""Прогресс обновления nft-наборов (agvpn4)."""
percent: int
message: str
active: bool # True — пока идёт апдейт, False — когда закончили / ничего не идёт
@dataclass(frozen=True)
class TrafficModeView:
desired_mode: str
applied_mode: str
preferred_iface: str
advanced_active: bool
auto_local_bypass: bool
auto_local_active: bool
ingress_reply_bypass: bool
ingress_reply_active: bool
bypass_candidates: int
force_vpn_subnets: List[str]
force_vpn_uids: List[str]
force_vpn_cgroups: List[str]
force_direct_subnets: List[str]
force_direct_uids: List[str]
force_direct_cgroups: List[str]
overrides_applied: int
cgroup_resolved_uids: int
cgroup_warning: str
active_iface: str
iface_reason: str
ingress_rule_present: bool
ingress_nft_active: bool
probe_ok: bool
probe_message: str
healthy: bool
message: str
@dataclass(frozen=True)
class RoutesResolveSummaryView:
available: bool
text: str
recheck_text: str
color: str
recheck_color: str
@dataclass(frozen=True)
class TransportPolicyFlowView:
phase: TransportFlowPhase
intents: List[TransportPolicyIntent]
base_revision: int
current_revision: int
applied_revision: int
confirm_token: str
valid: bool
block_count: int
warn_count: int
diff_added: int
diff_changed: int
diff_removed: int
conflicts: List[TransportConflict]
apply_id: str
rollback_available: bool
message: str
code: str

View File

@@ -0,0 +1,305 @@
#!/usr/bin/env python3
from __future__ import annotations
from typing import List, Optional, cast
from api_client import (
CmdResult,
EgressIdentity,
EgressIdentityRefreshResult,
LoginSessionAction,
LoginSessionStart,
LoginSessionState,
LoginState,
VpnLocation,
VpnLocationsState,
VpnStatus,
)
from .views import ActionView, LoginAction, LoginFlowView, VpnAutoconnectView, VpnStatusView
class VpnControllerMixin:
def vpn_locations_view(self) -> List[VpnLocation]:
return self.client.vpn_locations()
def vpn_locations_state_view(self) -> VpnLocationsState:
return self.client.vpn_locations_state()
def vpn_locations_refresh_trigger(self) -> None:
self.client.vpn_locations_refresh_trigger()
def vpn_status_view(self) -> VpnStatusView:
st = self.client.vpn_status()
pretty = self._pretty_vpn_status(st)
return VpnStatusView(
desired_location=st.desired_location,
pretty_text=pretty,
)
def vpn_status_model(self) -> VpnStatus:
return self.client.vpn_status()
# --- autoconnect / autoloop ---
def _autoconnect_from_auto(self, auto) -> bool:
"""
Вытаскиваем True/False из ответа /vpn/autoloop/status.
Приоритет:
1) явное поле auto.enabled (bool)
2) эвристика по status_word / raw_text
"""
enabled_field = getattr(auto, "enabled", None)
if isinstance(enabled_field, bool):
return enabled_field
word = (getattr(auto, "status_word", "") or "").strip().lower()
raw = (getattr(auto, "raw_text", "") or "").lower()
# приоритет — явные статусы
if word in (
"active",
"running",
"enabled",
"on",
"up",
"started",
"ok",
"true",
"yes",
):
return True
if word in ("inactive", "stopped", "disabled", "off", "down", "false", "no"):
return False
# фоллбек — по raw_text
if "inactive" in raw or "disabled" in raw or "failed" in raw:
return False
if "active" in raw or "running" in raw or "enabled" in raw:
return True
return False
def vpn_autoconnect_view(self) -> VpnAutoconnectView:
try:
auto = self.client.vpn_autoloop_status()
except Exception as e:
return VpnAutoconnectView(
enabled=False,
unit_text=f"unit: ERROR ({e})",
color="red",
)
enabled = self._autoconnect_from_auto(auto)
unit_state = (
getattr(auto, "unit_state", "") # если backend так отдаёт
or (auto.status_word or "")
or "unknown"
)
text = f"unit: {unit_state}"
low = f"{unit_state} {(auto.raw_text or '')}".lower()
if any(x in low for x in ("failed", "error", "unknown", "inactive", "dead")):
color = "red"
elif "active" in low or "running" in low or "enabled" in low:
color = "green"
else:
color = "orange"
return VpnAutoconnectView(enabled=enabled, unit_text=text, color=color)
def vpn_autoconnect_enabled(self) -> bool:
"""Старый интерфейс — оставляем для кнопки toggle."""
return self.vpn_autoconnect_view().enabled
def vpn_set_autoconnect(self, enable: bool) -> VpnStatusView:
res = self.client.vpn_autoconnect(enable)
st = self.client.vpn_status()
pretty = self._pretty_cmd_then_status(res, st)
return VpnStatusView(
desired_location=st.desired_location,
pretty_text=pretty,
)
def vpn_set_location(self, target: str, iso: str = "", label: str = "") -> VpnStatusView:
self.client.vpn_set_location(target=target, iso=iso, label=label)
st = self.client.vpn_status()
pretty = self._pretty_vpn_status(st)
return VpnStatusView(
desired_location=st.desired_location,
pretty_text=pretty,
)
def egress_identity(self, scope: str, *, refresh: bool = False) -> EgressIdentity:
return self.client.egress_identity_get(scope, refresh=refresh)
def egress_identity_refresh(
self,
*,
scopes: Optional[List[str]] = None,
force: bool = False,
) -> EgressIdentityRefreshResult:
return self.client.egress_identity_refresh(scopes=scopes, force=force)
def _pretty_vpn_status(self, st: VpnStatus) -> str:
lines = [
f"unit_state: {st.unit_state}",
f"desired_location: {st.desired_location or ''}",
f"status: {st.status_word}",
]
if st.raw_text:
lines.append("")
lines.append(st.raw_text.strip())
return "\n".join(lines).strip() + "\n"
# -------- Login Flow (interactive) --------
def login_flow_start(self) -> LoginFlowView:
s: LoginSessionStart = self.client.vpn_login_session_start()
dot = self._level_to_color(s.level)
if not s.ok:
txt = s.error or "Failed to start login session"
return LoginFlowView(
phase=s.phase or "failed",
level=s.level or "red",
dot_color="red",
status_text=txt,
url="",
email="",
alive=False,
cursor=0,
lines=[txt],
can_open=False,
can_check=False,
can_cancel=False,
)
if (s.phase or "").lower() == "already_logged":
txt = (
f"Already logged in as {s.email}"
if s.email
else "Already logged in"
)
return LoginFlowView(
phase="already_logged",
level="green",
dot_color="green",
status_text=txt,
url="",
email=s.email or "",
alive=False,
cursor=0,
lines=[txt],
can_open=False,
can_check=False,
can_cancel=False,
)
txt = f"Login started (pid={s.pid})" if s.pid else "Login started"
return LoginFlowView(
phase=s.phase or "starting",
level=s.level or "yellow",
dot_color=dot,
status_text=txt,
url="",
email="",
alive=True,
cursor=0,
lines=[],
can_open=True,
can_check=True,
can_cancel=True,
)
def login_flow_poll(self, since: int) -> LoginFlowView:
st: LoginSessionState = self.client.vpn_login_session_state(since=since)
dot = self._level_to_color(st.level)
phase = (st.phase or "").lower()
if phase == "waiting_browser":
status_txt = "Waiting for browser authorization…"
elif phase == "checking":
status_txt = "Checking…"
elif phase == "success":
status_txt = "✅ Logged in"
elif phase == "failed":
status_txt = "❌ Login failed"
elif phase == "cancelled":
status_txt = "Cancelled"
elif phase == "already_logged":
status_txt = (
f"Already logged in as {st.email}"
if st.email
else "Already logged in"
)
else:
status_txt = st.phase or ""
clean_lines = self._clean_login_lines(st.lines)
return LoginFlowView(
phase=st.phase,
level=st.level,
dot_color=dot,
status_text=status_txt,
url=st.url,
email=st.email,
alive=st.alive,
cursor=st.cursor,
can_open=st.can_open,
can_check=st.can_cancel,
can_cancel=st.can_cancel,
lines=clean_lines,
)
def login_flow_action(self, action: str) -> ActionView:
act = action.strip().lower()
if act not in ("open", "check", "cancel"):
raise ValueError(f"Invalid login action: {action}")
res: LoginSessionAction = self.client.vpn_login_session_action(
cast(LoginAction, act)
)
if not res.ok:
txt = res.error or "Login action failed"
return ActionView(ok=False, pretty_text=txt + "\n")
txt = f"OK: {act} → phase={res.phase} level={res.level}"
return ActionView(ok=True, pretty_text=txt + "\n")
def login_flow_stop(self) -> ActionView:
res = self.client.vpn_login_session_stop()
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
def vpn_logout(self) -> ActionView:
res = self.client.vpn_logout()
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
# Баннер "AdGuard VPN: logged in as ...", по клику показываем инфу как в CLI
def login_banner_cli_text(self) -> str:
try:
st: LoginState = self.client.get_login_state()
except Exception as e:
return f"Failed to query login state: {e}"
# backend может не иметь поля error, поэтому через getattr
err = getattr(st, "error", None) or getattr(st, "message", None)
if err:
return str(err)
if st.email:
return f"You are already logged in.\nCurrent user is {st.email}"
if st.state:
return f"Login state: {st.state}"
return "No login information available."
# -------- Routes --------

File diff suppressed because it is too large Load Diff

View File

@@ -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;")

View 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",
]

View 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",
]

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

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

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

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

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

View 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",
]

View 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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

View 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

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

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

View 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

View 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