baseline: api+gui traffic mode + candidates picker

Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
This commit is contained in:
beckline
2026-02-14 15:32:25 +03:00
parent 50e2999cad
commit 10a10f44a8
55 changed files with 16488 additions and 0 deletions

View File

@@ -0,0 +1,645 @@
#!/usr/bin/env python3
import argparse
import concurrent.futures
import json
import os
import sys
import time
from collections import defaultdict
# --- dnspython --------------------------------------------------------
try:
import dns.resolver
import dns.reversename
import dns.exception
except ImportError as e:
print(f"[resolver] dnspython is required: {e}", file=sys.stderr)
sys.exit(2)
# --------------------------------------------------------------------
# Общий DNS-конфиг
# --------------------------------------------------------------------
DNS_CONFIG_PATH = "/etc/selective-vpn/dns-upstreams.conf"
DEFAULT_DNS_DEFAULT = ["94.140.14.14", "94.140.15.15"]
DEFAULT_DNS_META = ["46.243.231.30", "46.243.231.41"]
DNS_DEFAULT = DEFAULT_DNS_DEFAULT.copy()
DNS_META = DEFAULT_DNS_META.copy()
# --------------------------------------------------------------------
# helpers
# --------------------------------------------------------------------
def log(msg, trace_log=None):
line = f"[resolver] {msg}"
print(line, file=sys.stderr)
if trace_log:
try:
with open(trace_log, "a") as f:
f.write(line + "\n")
except Exception:
pass
def is_private_ipv4(ip: str) -> bool:
"""
ip может быть "A.B.C.D" или "A.B.C.D/nn".
Возвращаем True, если адрес из приватных диапазонов.
"""
parts = ip.split("/")
base = parts[0]
try:
o1, o2, o3, o4 = map(int, base.split("."))
except ValueError:
return True
if o1 == 10:
return True
if o1 == 127:
return True
if o1 == 0:
return True
if o1 == 192 and o2 == 168:
return True
if o1 == 172 and 16 <= o2 <= 31:
return True
return False
def load_list(path):
if not os.path.exists(path):
return []
out = []
with open(path, "r") as f:
for line in f:
s = line.strip()
if not s or s.startswith("#"):
continue
out.append(s)
return out
def load_cache(path):
if not os.path.exists(path):
return {}
try:
with open(path, "r") as f:
return json.load(f)
except Exception:
return {}
def save_cache(path, data):
tmp = path + ".tmp"
try:
with open(tmp, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
os.replace(tmp, path)
except Exception:
pass
def split_dns(dns: str):
"""
Разбор записи вида:
"1.2.3.4" -> ("1.2.3.4", None)
"1.2.3.4#6053" -> ("1.2.3.4", "6053")
"""
if "#" in dns:
host, port = dns.split("#", 1)
host = host.strip()
port = port.strip()
if not host:
host = "127.0.0.1"
if not port:
port = "53"
return host, port
return dns, None
# --------------------------------------------------------------------
# dnspython-резолвы
# --------------------------------------------------------------------
def dig_a(host, dns_list, timeout=3):
"""
A-резолв через dnspython.
dns_list: либо строка "IP[#PORT]", либо список таких строк.
"""
if isinstance(dns_list, str):
dns_list = [dns_list]
ips = []
for entry in dns_list:
server, port = split_dns(entry)
if not server:
continue
r = dns.resolver.Resolver(configure=False)
r.nameservers = [server]
if port:
try:
r.port = int(port)
except ValueError:
r.port = 53
r.timeout = timeout
r.lifetime = timeout
try:
answer = r.resolve(host, "A")
except dns.exception.DNSException:
continue
except Exception:
continue
for rr in answer:
s = rr.to_text().strip()
parts = s.split(".")
if len(parts) != 4:
continue
if all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
if not is_private_ipv4(s) and s not in ips:
ips.append(s)
return ips
def dig_ptr(ip, upstream, timeout=3):
"""
PTR-резолв: ip -> список имён.
dns может быть "IP" или "IP#PORT".
"""
server, port = split_dns(upstream)
if not server:
return []
r = dns.resolver.Resolver(configure=False)
r.nameservers = [server]
if port:
try:
r.port = int(port)
except ValueError:
r.port = 53
r.timeout = timeout
r.lifetime = timeout
try:
rev = dns.reversename.from_address(ip)
except Exception:
return []
try:
answer = r.resolve(rev, "PTR")
except dns.exception.DNSException:
return []
except Exception:
return []
names = []
for rr in answer:
s = rr.to_text().strip()
if s.endswith("."):
s = s[:-1]
if s:
names.append(s.lower())
return names
# --------------------------------------------------------------------
# Загрузка DNS-конфига
# --------------------------------------------------------------------
def load_dns_config(path=DNS_CONFIG_PATH, trace_log=None):
"""
Читает /etc/selective-vpn/dns-upstreams.conf и обновляет
глобальные DNS_DEFAULT / DNS_META.
Формат строк:
default 1.2.3.4 5.6.7.8
meta 9.9.9.9 8.8.8.8
Можно использовать "ip#port", например 127.0.0.1#6053.
"""
global DNS_DEFAULT, DNS_META
if not os.path.exists(path):
DNS_DEFAULT = DEFAULT_DNS_DEFAULT.copy()
DNS_META = DEFAULT_DNS_META.copy()
log(
f"dns-config: {path} not found, fallback to built-in defaults "
f"(default={DNS_DEFAULT}, meta={DNS_META})",
trace_log,
)
return
dflt = []
meta = []
try:
with open(path, "r") as f:
for line in f:
s = line.strip()
if not s or s.startswith("#"):
continue
parts = s.split()
if len(parts) < 2:
continue
key = parts[0].lower()
addrs = parts[1:]
if key == "default":
dflt.extend(addrs)
elif key == "meta":
meta.extend(addrs)
except Exception as e:
DNS_DEFAULT = DEFAULT_DNS_DEFAULT.copy()
DNS_META = DEFAULT_DNS_META.copy()
log(
f"dns-config: failed to read {path}: {e}, fallback to built-in defaults "
f"(default={DNS_DEFAULT}, meta={DNS_META})",
trace_log,
)
return
if not dflt:
dflt = DEFAULT_DNS_DEFAULT.copy()
log(
"dns-config: no 'default' section, fallback to built-in for default",
trace_log,
)
if not meta:
meta = DEFAULT_DNS_META.copy()
log("dns-config: no 'meta' section, fallback to built-in for meta", trace_log)
DNS_DEFAULT = dflt
DNS_META = meta
log(
f"dns-config: accept {path}: "
f"default={', '.join(DNS_DEFAULT)}; meta={', '.join(DNS_META)}",
trace_log,
)
def resolve_host(host, meta_special, trace_log=None):
"""
Forward-резолв одного домена (A-записи).
DNS берём из DNS_DEFAULT / DNS_META, которые загрузил load_dns_config().
"""
if host in meta_special:
dns_list = DNS_META
else:
dns_list = DNS_DEFAULT
ips = dig_a(host, dns_list)
uniq = []
for ip in ips:
if ip not in uniq:
uniq.append(ip)
if uniq:
log(f"{host}: {', '.join(uniq)}", trace_log)
else:
log(f"{host}: no IPs", trace_log)
return uniq
def parse_static_entries(static_lines):
"""
static_lines — строки из static-ips.txt.
Возвращаем список кортежей (ip_entry, base_ip, comment).
"""
entries = []
for line in static_lines:
s = line.strip()
if not s or s.startswith("#"):
continue
if "#" in s:
ip_part, comment = s.split("#", 1)
ip_part = ip_part.strip()
comment = comment.strip()
else:
ip_part = s
comment = ""
if not ip_part:
continue
if is_private_ipv4(ip_part):
continue
base_ip = ip_part.split("/", 1)[0]
entries.append((ip_part, base_ip, comment))
return entries
def resolve_static_entries(static_entries, ptr_cache, ttl_sec, trace_log=None):
"""
static_entries: список кортежей (ip_entry, base_ip, comment).
ip_entry — как в static-ips.txt (может быть с /mask)
base_ip — A.B.C.D (без маски)
comment — текст после # или "".
Возвращаем dict: ip_entry -> список меток,
уже с префиксом '*' (чтобы можно было искать).
"""
now = int(time.time())
result = {}
for ip_entry, base_ip, comment in static_entries:
labels = []
# 1) если есть комментарий — он главнее всего
if comment:
labels.append(f"*{comment}")
# 2) если комментария нет, пробуем PTR (с кэшем)
if not comment:
cache_entry = ptr_cache.get(base_ip)
names = []
if (
cache_entry
and isinstance(cache_entry, dict)
and isinstance(cache_entry.get("last_resolved"), (int, float))
):
age = now - cache_entry["last_resolved"]
cached_names = cache_entry.get("names") or []
if age <= ttl_sec and cached_names:
names = cached_names
if not names:
# PTR через те же DNS, что и обычный трафик (используем первый из default)
dns_for_ptr = DNS_DEFAULT[0] if DNS_DEFAULT else DEFAULT_DNS_DEFAULT[0]
try:
names = dig_ptr(base_ip, dns_for_ptr) or []
except Exception as e:
log(
f"PTR failed for {base_ip} (using {dns_for_ptr}): "
f"{type(e).__name__}: {e}",
trace_log,
)
names = []
uniq_names = []
for n in names:
if n not in uniq_names:
uniq_names.append(n)
names = uniq_names
ptr_cache[base_ip] = {
"names": names,
"last_resolved": now,
}
for n in names:
labels.append(f"*{n}")
# 3) если вообще ничего нет — ставим общий тег
if not labels:
labels = ["*[STATIC-IP]"]
result[ip_entry] = labels
log(f"static {ip_entry}: labels={', '.join(labels)}", trace_log)
return result
# --------------------------------------------------------------------
# API-слой: одна чистая функция, которую легко вызвать откуда угодно
# --------------------------------------------------------------------
def run_resolver_job(
*,
domains,
meta_special,
static_lines,
cache_path,
ptr_cache_path,
ttl_sec,
workers,
trace_log=None,
):
"""
Главный API резолвера.
Вход:
domains — список доменов
meta_special — set() доменов из meta-special.txt
static_lines — строки из static-ips.txt
cache_path — путь к domain-cache.json
ptr_cache_path— путь к ptr-cache.json
ttl_sec — TTL кэша доменов / PTR
workers — число потоков
trace_log — путь к trace.log (или None)
Выход: dict с ключами:
ips — отсортированный список IP/подсетей
ip_map — список (ip, label) пар (домен или *LABEL)
domain_cache — обновлённый кэш доменов
ptr_cache — обновлённый PTR-кэш
summary — статистика (dict)
"""
# --- подгружаем DNS-конфиг ---
load_dns_config(DNS_CONFIG_PATH, trace_log)
meta_special = set(meta_special or [])
log(f"domains to resolve: {len(domains)}", trace_log)
# --- кэши ---
domain_cache = load_cache(cache_path)
ptr_cache = load_cache(ptr_cache_path)
now = int(time.time())
# --- разруливаем: что берём из domain_cache, что резолвим ---
fresh_from_cache = {}
to_resolve = []
for d in domains:
entry = domain_cache.get(d)
if entry and isinstance(entry, dict):
ts = entry.get("last_resolved") or 0
ips = entry.get("ips") or []
if isinstance(ts, (int, float)) and isinstance(ips, list) and ips:
if now - ts <= ttl_sec:
valid_ips = [ip for ip in ips if not is_private_ipv4(ip)]
if valid_ips:
fresh_from_cache[d] = valid_ips
continue
to_resolve.append(d)
log(
f"from cache: {len(fresh_from_cache)}, to resolve: {len(to_resolve)}",
trace_log,
)
resolved = dict(fresh_from_cache)
total_domains = len(domains)
cache_hits = len(fresh_from_cache)
resolved_now = 0
unresolved = 0
# --- параллельный резолв доменов ---
if to_resolve:
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
fut2host = {
ex.submit(resolve_host, d, meta_special, trace_log): d
for d in to_resolve
}
for fut in concurrent.futures.as_completed(fut2host):
d = fut2host[fut]
try:
ips = fut.result()
except Exception as e:
log(f"{d}: resolver exception: {e}", trace_log)
ips = []
if ips:
resolved[d] = ips
domain_cache[d] = {
"ips": ips,
"last_resolved": now,
}
resolved_now += 1
else:
unresolved += 1
# --- читаем static-ips и готовим список для PTR ---
static_entries = parse_static_entries(static_lines)
log(f"static entries: {len(static_entries)}", trace_log)
# --- PTR/labels для static-ips ---
static_label_map = resolve_static_entries(
static_entries, ptr_cache, ttl_sec, trace_log
)
# --- собираем общий список IP и map ---
ip_set = set()
ip_to_domains = defaultdict(set)
# доменные IP
for d, ips in resolved.items():
for ip in ips:
ip_set.add(ip)
ip_to_domains[ip].add(d)
# статические IP / сети
for ip_entry, _, _ in static_entries:
ip_set.add(ip_entry)
for label in static_label_map.get(ip_entry, []):
ip_to_domains[ip_entry].add(label)
unique_ip_count = len(ip_set)
if unique_ip_count == 0:
log("no IPs resolved at all", trace_log)
else:
log(f"resolver done: {unique_ip_count} unique IPs", trace_log)
ips_sorted = sorted(ip_set)
# flatten ip_map
ip_map_pairs = []
for ip in ips_sorted:
for dom in sorted(ip_to_domains[ip]):
ip_map_pairs.append((ip, dom))
summary = {
"domains_total": total_domains,
"from_cache": cache_hits,
"resolved_now": resolved_now,
"unresolved": unresolved,
"static_entries": len(static_entries),
"unique_ips": unique_ip_count,
}
log(
"summary: domains=%d, from_cache=%d, resolved_now=%d, "
"unresolved=%d, static_entries=%d, unique_ips=%d"
% (
summary["domains_total"],
summary["from_cache"],
summary["resolved_now"],
summary["unresolved"],
summary["static_entries"],
summary["unique_ips"],
),
trace_log,
)
return {
"ips": ips_sorted,
"ip_map": ip_map_pairs,
"domain_cache": domain_cache,
"ptr_cache": ptr_cache,
"summary": summary,
}
# --------------------------------------------------------------------
# CLI-обёртка вокруг API-функции (для bash-скрипта)
# --------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--domains", required=True, help="file with domains (one per line)")
ap.add_argument("--output-ips", required=True, help="file to write unique IPs")
ap.add_argument(
"--output-map",
required=True,
help="file to write IP<TAB>domain map",
)
ap.add_argument("--meta-file", required=True, help="meta-special.txt path")
ap.add_argument("--static-ips", required=True, help="static-ips.txt path")
ap.add_argument("--cache", required=True, help="domain-cache.json path")
ap.add_argument("--ptr-cache", required=True, help="ptr-cache.json path")
ap.add_argument("--trace-log", default=None)
ap.add_argument("--workers", type=int, default=40)
ap.add_argument("--ttl-sec", type=int, default=24 * 3600)
args = ap.parse_args()
trace_log = args.trace_log
try:
# входные данные для API-функции
domains = load_list(args.domains)
meta_special = load_list(args.meta_file)
static_lines = []
if os.path.exists(args.static_ips):
with open(args.static_ips, "r") as f:
static_lines = f.read().splitlines()
job_result = run_resolver_job(
domains=domains,
meta_special=meta_special,
static_lines=static_lines,
cache_path=args.cache,
ptr_cache_path=args.ptr_cache,
ttl_sec=args.ttl_sec,
workers=args.workers,
trace_log=trace_log,
)
ips_sorted = job_result["ips"]
ip_map_pairs = job_result["ip_map"]
domain_cache = job_result["domain_cache"]
ptr_cache = job_result["ptr_cache"]
# output-ips: по одному IP/подсети
with open(args.output_ips, "w") as f:
for ip in ips_sorted:
f.write(ip + "\n")
# output-map: IP<TAB>домен/метка
with open(args.output_map, "w") as f:
for ip, dom in ip_map_pairs:
f.write(f"{ip}\t{dom}\n")
# сохраняем кэши
save_cache(args.cache, domain_cache)
save_cache(args.ptr_cache, ptr_cache)
return 0
except Exception as e:
# настоящий фатал
log(f"FATAL resolver error: {e}", trace_log)
import traceback
traceback.print_exc(file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,847 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DashboardController
Тонкий "мозг" между UI и ApiClient.
UI не должен знать URL'ы / JSON, только вызывать методы этого контроллера.
"""
from __future__ import annotations
from dataclasses import dataclass
import os
import re
from typing import Iterable, List, Literal, Optional, cast
# вырезаем спам автопроверки из логов (CLI любит писать "Next check in ...")
_NEXT_CHECK_RE = re.compile(
r"(?:\b\d+s\.)?\s*Next check in\s+\d+s\.?", re.IGNORECASE
)
from api_client import (
ApiClient,
CmdResult,
DNSStatus,
DnsUpstreams,
DomainsFile,
DomainsTable,
Event,
LoginState,
Status,
TrafficCandidates,
TrafficInterfaces,
TrafficModeStatus,
TraceDump,
UnitState,
VpnLocation,
VpnStatus,
SmartdnsRuntimeState,
# login flow models
LoginSessionStart,
LoginSessionState,
LoginSessionAction,
)
TraceMode = Literal["full", "gui", "smartdns"]
ServiceAction = Literal["start", "stop", "restart"]
LoginAction = Literal["open", "check", "cancel"]
# ---------------------------
# View models (UI-friendly)
# ---------------------------
@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
auto_local_bypass: 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
probe_ok: bool
probe_message: str
healthy: bool
message: str
# ---------------------------
# Controller
# ---------------------------
class DashboardController:
def __init__(
self,
client: ApiClient,
*,
routes_unit: Optional[str] = None,
smartdns_unit: Optional[str] = None,
) -> None:
self.client = client
self.routes_unit = (
routes_unit
or os.environ.get("SELECTIVE_VPN_ROUTES_UNIT")
or ""
)
self.smartdns_unit = (
smartdns_unit
or os.environ.get("SELECTIVE_VPN_SMARTDNS_UNIT")
or "smartdns-local.service"
)
# -------- 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 == "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"]
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"
# -------- overview / status --------
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}",
)
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 ""
# -------- VPN --------
def vpn_locations_view(self) -> List[VpnLocation]:
return self.client.vpn_locations()
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,
)
# --- 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, iso: str) -> VpnStatusView:
self.client.vpn_set_location(iso)
st = self.client.vpn_status()
pretty = self._pretty_vpn_status(st)
return VpnStatusView(
desired_location=st.desired_location,
pretty_text=pretty,
)
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 --------
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_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 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 "",
auto_local_bypass=bool(st.auto_local_bypass),
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 "",
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,
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,
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 "",
auto_local_bypass=bool(st.auto_local_bypass),
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 "",
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 "",
auto_local_bypass=bool(st.auto_local_bypass),
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 "",
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 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 --------
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_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 --------
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"):
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"], 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"):
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"], nm), content
)
# -------- Trace --------
def trace_view(self, mode: TraceMode = "full") -> TraceDump:
return self.client.trace_get(mode)
# -------- 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,2 @@
###
# Default bases list (seed). Add domains here; one per line.

View File

@@ -0,0 +1 @@
# meta domains (seed)

View File

@@ -0,0 +1 @@
# static IPs (seed)

View File

@@ -0,0 +1,3 @@
www
api
static

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,901 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Selective-VPN Dashboard (UI only)
RULES:
- This file must NOT know anything about REST paths, HTTP methods, or JSON keys.
- It talks ONLY to DashboardController (which uses ApiClient).
"""
from __future__ import annotations
import re
import subprocess
import sys
import tkinter as tk
from tkinter import messagebox
from tkinter import ttk
from typing import Literal, Optional, cast, Tuple
from api_client import ApiClient, DnsUpstreams
from dashboard_controller import DashboardController
TraceMode = Literal["full", "gui", "smartdns"]
# убираем спам автопроверки из логов UI (на всякий случай, даже если почистил controller)
_NEXT_CHECK_RE = re.compile(r"(?:\b\d+s\.)?\s*Next check in\s+\d+s\.?", re.IGNORECASE)
class App(ttk.Frame):
def __init__(self, master: tk.Tk, ctrl: DashboardController) -> None:
super().__init__(master)
self.master = master
self.ctrl = ctrl
# login-flow runtime
self._login_flow_active: bool = False
self._login_cursor: int = 0
self._login_url_opened: bool = False
self._login_poll_after_id: Optional[str] = None
self._build_ui()
self._wire_events()
self.after(50, self.refresh_everything)
self.master.protocol("WM_DELETE_WINDOW", self._on_close)
# ---------------- UI BUILD ----------------
def _build_ui(self) -> None:
self.master.title("Selective-VPN Dashboard")
self.pack(fill="both", expand=True)
# Top bar
top = ttk.Frame(self)
top.pack(fill="x", padx=10, pady=(10, 6))
self.btn_refresh = ttk.Button(top, text="Refresh all", command=self.refresh_everything)
self.btn_refresh.pack(side="left")
# Login indicator (dot + text)
self.login_dot = tk.Canvas(top, width=12, height=12, highlightthickness=0)
self.login_dot.pack(side="left", padx=(12, 4))
self._login_dot_id = self.login_dot.create_oval(2, 2, 10, 10, fill="gray", outline="")
self.lbl_login = ttk.Label(top, text="AdGuard VPN: ...", font=("TkDefaultFont", 10, "bold"))
self.lbl_login.pack(side="left", padx=(0, 10))
# Single auth button (Login/Logout)
self.btn_auth = ttk.Button(top, text="Login", command=self.on_auth_button)
self.btn_auth.pack(side="left")
self.lbl_hint = ttk.Label(top, text="(GUI contains no API logic)", foreground="gray")
self.lbl_hint.pack(side="right")
# Notebook
self.nb = ttk.Notebook(self)
self.nb.pack(fill="both", expand=True, padx=10, pady=(0, 10))
self._build_tab_status()
self._build_tab_vpn()
self._build_tab_routes()
self._build_tab_dns()
self._build_tab_domains()
self._build_tab_trace()
def _build_tab_status(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text="Status")
frm = ttk.Frame(tab)
frm.pack(fill="both", expand=True, padx=10, pady=10)
grid = ttk.Frame(frm)
grid.pack(fill="x")
def row(r: int, label: str) -> ttk.Label:
ttk.Label(grid, text=label).grid(row=r, column=0, sticky="w", pady=2)
v = ttk.Label(grid, text="")
v.grid(row=r, column=1, sticky="w", pady=2, padx=(10, 0))
return v
self.st_timestamp = row(0, "Timestamp")
self.st_counts = row(1, "Counts")
self.st_iface = row(2, "Iface/Table/Mark")
self.st_route = row(3, "Policy route")
self.st_routesvc = row(4, "Routes service")
self.st_smartdns = row(5, "SmartDNS service")
self.st_vpnsvc = row(6, "VPN service")
btns = ttk.Frame(frm)
btns.pack(fill="x", pady=(10, 0))
ttk.Button(btns, text="Refresh status", command=self.refresh_status_tab).pack(side="left")
def _build_tab_vpn(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text="AdGuardVPN")
# Pages container
self.vpn_pages = ttk.Frame(tab)
self.vpn_pages.pack(fill="both", expand=True, padx=10, pady=10)
self.vpn_page_main = ttk.Frame(self.vpn_pages)
self.vpn_page_login = ttk.Frame(self.vpn_pages)
for p in (self.vpn_page_main, self.vpn_page_login):
p.grid(row=0, column=0, sticky="nsew")
self.vpn_pages.rowconfigure(0, weight=1)
self.vpn_pages.columnconfigure(0, weight=1)
# -------- Page 1: main VPN controls (Enter Login removed) --------
frm = self.vpn_page_main
top_actions = ttk.Frame(frm)
top_actions.pack(fill="x", pady=(0, 10))
ttk.Button(top_actions, text="Refresh", command=self.refresh_vpn_tab).pack(side="right")
# Autoconnect toggle
ac = ttk.LabelFrame(frm, text="Auto-connect")
ac.pack(fill="x")
self.var_autoconnect = tk.BooleanVar(value=False)
self.chk_autoconnect = ttk.Checkbutton(
ac,
text="Enable auto-connect",
variable=self.var_autoconnect,
command=self.on_toggle_autoconnect,
)
self.chk_autoconnect.pack(side="left", padx=10, pady=8)
# Location picker
loc = ttk.LabelFrame(frm, text="Location")
loc.pack(fill="x", pady=(10, 0))
self.cmb_location = ttk.Combobox(loc, state="readonly", width=40)
self.cmb_location.pack(side="left", padx=10, pady=8)
self.btn_set_location = ttk.Button(loc, text="Set location", command=self.on_set_location)
self.btn_set_location.pack(side="left", padx=6, pady=8)
self.lbl_vpn_desired = ttk.Label(loc, text="Desired: —", foreground="gray")
self.lbl_vpn_desired.pack(side="left", padx=12)
# Status output
st = ttk.LabelFrame(frm, text="VPN Status")
st.pack(fill="both", expand=True, pady=(10, 0))
self.txt_vpn = tk.Text(st, height=12, wrap="none")
self.txt_vpn.pack(fill="both", expand=True, padx=10, pady=10)
# -------- Page 2: Login flow --------
lf = self.vpn_page_login
lf_top = ttk.Frame(lf)
lf_top.pack(fill="x", pady=(0, 10))
ttk.Button(lf_top, text="← Back", command=self.on_login_back).pack(side="left")
self.login_flow_dot = tk.Canvas(lf_top, width=14, height=14, highlightthickness=0)
self.login_flow_dot.pack(side="left", padx=(10, 4))
self._login_flow_dot_id = self.login_flow_dot.create_oval(2, 2, 12, 12, fill="orange", outline="")
self.lbl_login_flow_status = ttk.Label(lf_top, text="Status: —", font=("TkDefaultFont", 10, "bold"))
self.lbl_login_flow_status.pack(side="left", padx=(0, 10))
self.lbl_login_flow_email = ttk.Label(lf_top, text="", foreground="gray")
self.lbl_login_flow_email.pack(side="left")
url_row = ttk.Frame(lf)
url_row.pack(fill="x", pady=(0, 10))
ttk.Label(url_row, text="URL:").pack(side="left")
self.var_login_url = tk.StringVar(value="")
self.ent_login_url = ttk.Entry(url_row, textvariable=self.var_login_url, state="readonly")
self.ent_login_url.pack(side="left", fill="x", expand=True, padx=8)
self.btn_login_copy = ttk.Button(url_row, text="Copy", command=self.on_login_copy)
self.btn_login_copy.pack(side="left", padx=(0, 6))
self.btn_login_open = ttk.Button(url_row, text="Open", command=self.on_login_open)
self.btn_login_open.pack(side="left")
ctrl_row = ttk.Frame(lf)
ctrl_row.pack(fill="x", pady=(0, 10))
self.btn_login_check = ttk.Button(ctrl_row, text="Check", command=self.on_login_check)
self.btn_login_check.pack(side="left")
self.btn_login_close = ttk.Button(ctrl_row, text="Close (cancel)", command=self.on_login_cancel)
self.btn_login_close.pack(side="left", padx=6)
self.btn_login_stop = ttk.Button(ctrl_row, text="Stop (force)", command=self.on_login_stop)
self.btn_login_stop.pack(side="left", padx=6)
# Log output
out = ttk.LabelFrame(lf, text="Login output")
out.pack(fill="both", expand=True)
self.txt_login_flow = tk.Text(out, wrap="word", height=16)
self.txt_login_flow.pack(fill="both", expand=True, padx=10, pady=10)
self._show_vpn_page("main")
def _build_tab_routes(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text="Routes")
frm = ttk.Frame(tab)
frm.pack(fill="both", expand=True, padx=10, pady=10)
svc = ttk.LabelFrame(frm, text="Routes service")
svc.pack(fill="x")
ttk.Button(svc, text="Start", command=lambda: self.on_routes_action("start")).pack(side="left", padx=10, pady=8)
ttk.Button(svc, text="Stop", command=lambda: self.on_routes_action("stop")).pack(side="left", padx=6, pady=8)
ttk.Button(svc, text="Restart", command=lambda: self.on_routes_action("restart")).pack(side="left", padx=6, pady=8)
ttk.Button(svc, text="Clear routes", command=self.on_routes_clear).pack(side="right", padx=10, pady=8)
timer = ttk.LabelFrame(frm, text="Timer")
timer.pack(fill="x", pady=(10, 0))
self.var_timer = tk.BooleanVar(value=False)
self.chk_timer = ttk.Checkbutton(timer, text="Enable timer", variable=self.var_timer, command=self.on_toggle_timer)
self.chk_timer.pack(side="left", padx=10, pady=8)
ttk.Button(timer, text="Fix policy route", command=self.on_fix_policy_route).pack(side="right", padx=10, pady=8)
out = ttk.LabelFrame(frm, text="Output")
out.pack(fill="both", expand=True, pady=(10, 0))
self.txt_routes = tk.Text(out, height=12, wrap="none")
self.txt_routes.pack(fill="both", expand=True, padx=10, pady=10)
def _build_tab_dns(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text="DNS")
frm = ttk.Frame(tab)
frm.pack(fill="both", expand=True, padx=10, pady=10)
ups = ttk.LabelFrame(frm, text="Upstreams")
ups.pack(fill="x")
def add_field(r: int, label: str) -> ttk.Entry:
ttk.Label(ups, text=label).grid(row=r, column=0, sticky="w", padx=10, pady=4)
e = ttk.Entry(ups, width=60)
e.grid(row=r, column=1, sticky="we", padx=10, pady=4)
return e
ups.columnconfigure(1, weight=1)
self.ent_def1 = add_field(0, "default1")
self.ent_def2 = add_field(1, "default2")
self.ent_meta1 = add_field(2, "meta1")
self.ent_meta2 = add_field(3, "meta2")
btns = ttk.Frame(frm)
btns.pack(fill="x", pady=(10, 0))
ttk.Button(btns, text="Refresh", command=self.refresh_dns_tab).pack(side="left")
ttk.Button(btns, text="Save", command=self.on_save_upstreams).pack(side="left", padx=6)
sm = ttk.LabelFrame(frm, text="SmartDNS")
sm.pack(fill="both", expand=True, pady=(10, 0))
top = ttk.Frame(sm)
top.pack(fill="x", padx=10, pady=(10, 6))
self.lbl_smartdns_state = ttk.Label(top, text="Service: —")
self.lbl_smartdns_state.pack(side="left")
ttk.Button(top, text="Start", command=lambda: self.on_smartdns_action("start")).pack(side="right", padx=6)
ttk.Button(top, text="Stop", command=lambda: self.on_smartdns_action("stop")).pack(side="right")
mid = ttk.Frame(sm)
mid.pack(fill="both", expand=True, padx=10, pady=(0, 10))
ttk.Label(mid, text="Wildcards (one per line):").pack(anchor="w")
self.txt_wildcards = tk.Text(mid, height=10, wrap="none")
self.txt_wildcards.pack(fill="both", expand=True, pady=(4, 6))
btns2 = ttk.Frame(mid)
btns2.pack(fill="x")
ttk.Button(btns2, text="Refresh", command=self.refresh_dns_tab).pack(side="left")
ttk.Button(btns2, text="Save", command=self.on_save_wildcards).pack(side="left", padx=6)
def _build_tab_domains(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text="Domains")
frm = ttk.Frame(tab)
frm.pack(fill="both", expand=True, padx=10, pady=10)
left = ttk.Frame(frm)
left.pack(side="left", fill="y")
right = ttk.Frame(frm)
right.pack(side="left", fill="both", expand=True, padx=(10, 0))
ttk.Label(left, text="Files:").pack(anchor="w")
self.lst_files = tk.Listbox(left, height=6, exportselection=False)
for name in ("bases", "meta", "subs", "static"):
self.lst_files.insert("end", name)
self.lst_files.selection_set(0)
self.lst_files.pack(fill="y", pady=(4, 8))
ttk.Button(left, text="Refresh table", command=self.refresh_domains_tab).pack(fill="x")
ttk.Button(left, text="Load file", command=self.on_domains_load).pack(fill="x", pady=(6, 0))
ttk.Button(left, text="Save file", command=self.on_domains_save).pack(fill="x", pady=(6, 0))
ttk.Button(left, text="Load AGVPN table", command=self.on_load_agvpn_table).pack(fill="x", pady=(10, 0))
ttk.Button(left, text="Load SmartDNS table", command=self.on_load_smartdns_table).pack(fill="x", pady=(6, 0))
top = ttk.Frame(right)
top.pack(fill="x")
self.lbl_domains_info = ttk.Label(top, text="", foreground="gray")
self.lbl_domains_info.pack(side="left")
self.txt_domains = tk.Text(right, wrap="none")
self.txt_domains.pack(fill="both", expand=True, pady=(6, 0))
def _build_tab_trace(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text="Trace")
frm = ttk.Frame(tab)
frm.pack(fill="both", expand=True, padx=10, pady=10)
top = ttk.Frame(frm)
top.pack(fill="x")
self.var_trace_mode = tk.StringVar(value="full")
for m, title in (("full", "Full"), ("gui", "GUI"), ("smartdns", "SmartDNS")):
ttk.Radiobutton(top, text=title, value=m, variable=self.var_trace_mode, command=self.refresh_trace_tab).pack(
side="left", padx=(0, 10)
)
ttk.Button(top, text="Refresh", command=self.refresh_trace_tab).pack(side="right")
self.txt_trace = tk.Text(frm, wrap="none")
self.txt_trace.pack(fill="both", expand=True, pady=(10, 0))
def _wire_events(self) -> None:
self.lst_files.bind("<<ListboxSelect>>", lambda _e: self.on_domains_load())
# ---------------- UI HELPERS ----------------
def _set_text(self, widget: tk.Text, text: str) -> None:
widget.config(state="normal")
widget.delete("1.0", "end")
widget.insert("1.0", text)
widget.config(state="normal")
def _append_text(self, widget: tk.Text, text: str) -> None:
widget.config(state="normal")
widget.insert("end", text)
widget.see("end")
widget.config(state="normal")
def _clean_ui_lines(self, lines) -> str:
# финальная страховка: убираем "Next check" и нормализуем \r
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()
def _get_selected_domains_file(self) -> str:
sel = self.lst_files.curselection()
if not sel:
return "bases"
return str(self.lst_files.get(sel[0]))
def _read_local_file(self, path: str) -> str:
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
except Exception:
return ""
def _safe(self, fn, *, title: str = "Error"):
try:
return fn()
except Exception as e:
messagebox.showerror(title, str(e))
return None
def _set_dot(self, canvas: tk.Canvas, dot_id: int, color: str) -> None:
c = (color or "").strip().lower()
if c in ("green", "ok", "true"):
fill = "green"
elif c in ("red", "error", "false"):
fill = "red"
elif c in ("orange", "yellow", "try", "unknown", "pending", "wait"):
fill = "orange"
else:
fill = "gray"
try:
canvas.itemconfigure(dot_id, fill=fill)
except Exception:
pass
def _show_vpn_page(self, which: Literal["main", "login"]) -> None:
if which == "login":
self.vpn_page_login.tkraise()
else:
self.vpn_page_main.tkraise()
def _parse_login_banner(self, text: str, color: str) -> Tuple[bool, str]:
# считаем "logged" если зеленый
is_logged = (color or "").strip().lower() == "green"
email = ""
t = (text or "")
# пытаемся вытащить email из строки
m = re.search(r"([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})", t)
if m:
email = m.group(1)
return is_logged, email
def _set_auth_button(self, logged: bool) -> None:
self.btn_auth.config(text=("Logout" if logged else "Login"))
# ---------------- REFRESH ----------------
def refresh_everything(self) -> None:
self.refresh_status_tab()
self.refresh_vpn_tab()
self.refresh_routes_tab()
self.refresh_dns_tab()
self.refresh_domains_tab()
self.refresh_trace_tab()
self.refresh_login_banner()
def refresh_login_banner(self) -> None:
def work():
view = self.ctrl.get_login_view()
self.lbl_login.config(text=view.text)
self._set_dot(self.login_dot, self._login_dot_id, view.color)
# НЕ гадаем по цвету: используем нормализованную логику controller-а
self._set_auth_button(bool(view.logged_in))
try:
self.lbl_login.config(foreground=view.color)
except tk.TclError:
pass
self._safe(work, title="Login state error")
def refresh_status_tab(self) -> None:
def work():
view = self.ctrl.get_status_overview()
self.st_timestamp.config(text=view.timestamp)
self.st_counts.config(text=view.counts)
self.st_iface.config(text=view.iface_table_mark)
self.st_route.config(text=view.policy_route)
self.st_routesvc.config(text=view.routes_service)
self.st_smartdns.config(text=view.smartdns_service)
self.st_vpnsvc.config(text=view.vpn_service)
self._safe(work, title="Status error")
def refresh_vpn_tab(self) -> None:
def work():
locs = self.ctrl.vpn_locations_view()
self.cmb_location["values"] = [f"{x.iso}{x.label}" for x in locs]
st = self.ctrl.vpn_status_view()
self.lbl_vpn_desired.config(text=f"Desired: {st.desired_location or ''}")
self._set_text(self.txt_vpn, st.pretty_text)
self.var_autoconnect.set(self.ctrl.vpn_autoconnect_enabled())
self._safe(work, title="VPN error")
def refresh_routes_tab(self) -> None:
def work():
self.var_timer.set(self.ctrl.routes_timer_enabled())
self._safe(work, title="Routes error")
def refresh_dns_tab(self) -> None:
def work():
cfg = self.ctrl.dns_upstreams_view()
self.ent_def1.delete(0, "end"); self.ent_def1.insert(0, cfg.default1)
self.ent_def2.delete(0, "end"); self.ent_def2.insert(0, cfg.default2)
self.ent_meta1.delete(0, "end"); self.ent_meta1.insert(0, cfg.meta1)
self.ent_meta2.delete(0, "end"); self.ent_meta2.insert(0, cfg.meta2)
sd = self.ctrl.smartdns_service_view()
self.lbl_smartdns_state.config(text=f"Service: {sd.state}")
wc = self.ctrl.smartdns_wildcards_view()
self._set_text(self.txt_wildcards, "\n".join(wc.domains).strip() + ("\n" if wc.domains else ""))
self._safe(work, title="DNS error")
def refresh_domains_tab(self) -> None:
def work():
table = self.ctrl.domains_table_view()
self.lbl_domains_info.config(text=f"Table lines: {len(table.lines)}")
self._safe(work, title="Domains error")
def refresh_trace_tab(self) -> None:
def work():
mode = cast(TraceMode, self.var_trace_mode.get())
dump = self.ctrl.trace_view(mode)
self._set_text(self.txt_trace, "\n".join(dump.lines).strip() + ("\n" if dump.lines else ""))
self._safe(work, title="Trace error")
# ---------------- LOGIN FLOW (UI) ----------------
def _login_flow_reset_ui(self) -> None:
self._login_cursor = 0
self._login_url_opened = False
self.var_login_url.set("")
self.lbl_login_flow_status.config(text="Status: —")
self.lbl_login_flow_email.config(text="")
self._set_dot(self.login_flow_dot, self._login_flow_dot_id, "orange")
self._set_text(self.txt_login_flow, "")
def _login_flow_set_buttons(self, *, can_open: bool, can_check: bool, can_cancel: bool) -> None:
def set_state(btn: ttk.Button, enabled: bool) -> None:
try:
btn.config(state=("normal" if enabled else "disabled"))
except Exception:
pass
set_state(self.btn_login_open, can_open)
set_state(self.btn_login_copy, bool(self.var_login_url.get().strip()))
set_state(self.btn_login_check, can_check)
set_state(self.btn_login_close, can_cancel)
# stop — страховка, но если уже success/already_logged, можно тоже выключить не обязательно
try:
self.btn_login_stop.config(state="normal")
except Exception:
pass
def _login_flow_autopoll_start(self) -> None:
self._login_flow_active = True
self._login_poll_tick()
def _login_flow_autopoll_stop(self) -> None:
self._login_flow_active = False
if self._login_poll_after_id is not None:
try:
self.after_cancel(self._login_poll_after_id)
except Exception:
pass
self._login_poll_after_id = None
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)
# indicator + status
self._set_dot(self.login_flow_dot, self._login_flow_dot_id, view.dot_color)
self.lbl_login_flow_status.config(text=f"Status: {view.status_text or ''}")
self.lbl_login_flow_email.config(text=(f"User: {view.email}" if view.email else ""))
if view.url:
self.var_login_url.set(view.url)
# buttons
self._login_flow_set_buttons(can_open=view.can_open, can_check=view.can_check, can_cancel=view.can_cancel)
# append cleaned lines
cleaned = self._clean_ui_lines(view.lines)
if cleaned:
self._append_text(self.txt_login_flow, cleaned + "\n")
# auto-open browser once when url appears
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"):
# Авто-обновляем баннер при успехе/уже залогинен
if phase in ("success", "already_logged"):
self.after(250, self.refresh_login_banner)
# и возвращаемся на main страницу VPN, чтобы UX был как у тебя на примере
self.after(500, lambda: self._show_vpn_page("main"))
# на терминале — стопаем polling
self._login_flow_autopoll_stop()
# в терминале делаем кнопки логина неактивными (как в твоём "идеальном" окне)
self._login_flow_set_buttons(can_open=False, can_check=False, can_cancel=False)
try:
self.btn_login_stop.config(state="disabled")
except Exception:
pass
self._safe(work, title="Login flow error")
if self._login_flow_active:
self._login_poll_after_id = self.after(250, self._login_poll_tick)
# ---------------- TOP AUTH BUTTON ----------------
def on_auth_button(self) -> None:
# decide based on current banner
def work():
view = self.ctrl.get_login_view()
if bool(view.logged_in):
self.on_logout()
else:
self.on_start_login()
self._safe(work, title="Auth error")
# ---------------- ACTIONS ----------------
def on_start_login(self) -> None:
def work():
self.ctrl.log_gui("Top Login clicked")
self._login_flow_reset_ui()
start = self.ctrl.login_flow_start()
# reflect start info
self._set_dot(self.login_flow_dot, self._login_flow_dot_id, start.dot_color)
self.lbl_login_flow_status.config(text=f"Status: {start.status_text or ''}")
self.lbl_login_flow_email.config(text=(f"User: {start.email}" if start.email else ""))
if start.url:
self.var_login_url.set(start.url)
cleaned = self._clean_ui_lines(start.lines)
if cleaned:
self._append_text(self.txt_login_flow, cleaned + "\n")
# already logged: banner update and stop
phase = (start.phase or "").strip().lower()
if phase == "already_logged":
self.refresh_login_banner()
messagebox.showinfo("Login", f"Already logged in{f' as {start.email}' if start.email else ''}.")
return
# show login page and start polling
self._show_vpn_page("login")
self._login_cursor = int(start.cursor or 0)
self._login_flow_set_buttons(can_open=start.can_open, can_check=start.can_check, can_cancel=start.can_cancel)
self._login_flow_autopoll_start()
self._safe(work, title="Login start error")
def on_login_back(self) -> None:
self._login_flow_autopoll_stop()
self._show_vpn_page("main")
self.refresh_login_banner()
def on_login_copy(self) -> None:
u = self.var_login_url.get().strip()
if not u:
return
try:
self.master.clipboard_clear()
self.master.clipboard_append(u)
except Exception:
pass
def on_login_open(self) -> None:
def work():
self.ctrl.login_flow_action("open")
u = self.var_login_url.get().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():
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()
self.after(250, self.refresh_login_banner)
self._safe(work, title="Login stop error")
def on_logout(self) -> None:
def work():
if not messagebox.askyesno("Logout", "Logout from AdGuard VPN account?"):
return
res = self.ctrl.vpn_logout()
self.ctrl.log_gui("VPN logout executed")
messagebox.showinfo("Logout", res.pretty_text.strip() or "Done.")
self.refresh_login_banner()
self.refresh_vpn_tab()
self._safe(work, title="Logout error")
def on_toggle_autoconnect(self) -> None:
def work():
enable = bool(self.var_autoconnect.get())
res = self.ctrl.vpn_set_autoconnect(enable)
self._set_text(self.txt_vpn, res.pretty_text)
self.ctrl.log_gui(f"Auto-connect set to {enable}")
self._safe(work, title="Auto-connect error")
def on_set_location(self) -> None:
def work():
val = self.cmb_location.get().strip()
if not val:
messagebox.showinfo("Location", "Choose a location first.")
return
iso = val.split("", 1)[0].strip()
res = self.ctrl.vpn_set_location(iso)
self._set_text(self.txt_vpn, res.pretty_text)
self.ctrl.log_gui(f"Location set to {iso}")
self.refresh_vpn_tab()
self._safe(work, title="Set location error")
def on_routes_action(self, action: str) -> None:
def work():
res = self.ctrl.routes_service_action(action)
self._set_text(self.txt_routes, res.pretty_text)
self.ctrl.log_gui(f"Routes service: {action}")
self.refresh_status_tab()
self._safe(work, title="Routes service error")
def on_routes_clear(self) -> None:
def work():
res = self.ctrl.routes_clear()
self._set_text(self.txt_routes, res.pretty_text)
self.ctrl.log_gui("Routes cleared")
self.refresh_status_tab()
self._safe(work, title="Clear routes error")
def on_toggle_timer(self) -> None:
def work():
enabled = bool(self.var_timer.get())
res = self.ctrl.routes_timer_set(enabled)
self._set_text(self.txt_routes, res.pretty_text)
self.ctrl.log_gui(f"Routes timer set to {enabled}")
self.refresh_status_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)
self.ctrl.log_gui("Policy route fix executed")
self.refresh_status_tab()
self._safe(work, title="Fix policy route error")
def on_save_upstreams(self) -> None:
def work():
cfg = DnsUpstreams(
default1=self.ent_def1.get().strip(),
default2=self.ent_def2.get().strip(),
meta1=self.ent_meta1.get().strip(),
meta2=self.ent_meta2.get().strip(),
)
self.ctrl.dns_upstreams_save(cfg)
self.ctrl.log_gui("DNS upstreams saved")
messagebox.showinfo("DNS", "Saved.")
self.refresh_dns_tab()
self._safe(work, title="Save upstreams error")
def on_smartdns_action(self, action: str) -> None:
def work():
_res = self.ctrl.smartdns_service_action(action)
self.ctrl.log_gui(f"SmartDNS action: {action}")
self.refresh_dns_tab()
self.refresh_trace_tab()
self._safe(work, title="SmartDNS error")
def on_save_wildcards(self) -> None:
def work():
raw = self.txt_wildcards.get("1.0", "end")
domains = [x.strip() for x in raw.splitlines() if x.strip()]
self.ctrl.smartdns_wildcards_save(domains)
self.ctrl.log_gui(f"Wildcards saved: {len(domains)}")
messagebox.showinfo("SmartDNS", "Wildcards saved.")
self.refresh_dns_tab()
self._safe(work, title="Save wildcards error")
def on_domains_load(self) -> None:
def work():
name = self._get_selected_domains_file()
f = self.ctrl.domains_file_load(name)
content = f.content or ""
source = getattr(f, "source", "") or "file"
if not content:
path = f"/etc/selective-vpn/domains/{name}.txt"
content = self._read_local_file(path)
if content:
source = f"{source}+fallback" if source else "fallback"
self._set_text(self.txt_domains, content)
self.lbl_domains_info.config(text=f"{name} (source: {source})")
self.ctrl.log_gui(f"Domains file loaded: {name} source={source}")
self._safe(work, title="Load domains file error")
def on_domains_save(self) -> None:
def work():
name = self._get_selected_domains_file()
content = self.txt_domains.get("1.0", "end")
self.ctrl.domains_file_save(name, content)
self.ctrl.log_gui(f"Domains file saved: {name}")
messagebox.showinfo("Domains", "Saved.")
self.refresh_status_tab()
self._safe(work, title="Save domains file error")
def on_load_agvpn_table(self) -> None:
path = "/var/lib/selective-vpn/last-ips-map.txt"
data = self._read_local_file(path)
self._set_text(self.txt_domains, data or "(empty)")
self.lbl_domains_info.config(text=f"AGVPN table: {path}")
def on_load_smartdns_table(self) -> None:
path = "/etc/selective-vpn/smartdns.conf"
data = self._read_local_file(path)
self._set_text(self.txt_domains, data or "(empty)")
self.lbl_domains_info.config(text=f"SmartDNS table: {path}")
# ---------------- CLOSE HANDLER ----------------
def _on_close(self) -> None:
def work():
if self._login_flow_active:
try:
self.ctrl.login_flow_action("cancel")
except Exception:
pass
try:
self.ctrl.login_flow_stop()
except Exception:
pass
self._login_flow_autopoll_stop()
try:
work()
finally:
self.master.destroy()
def main() -> int:
client = ApiClient.from_env()
ctrl = DashboardController(client)
root = tk.Tk()
try:
root.minsize(900, 650)
except Exception:
pass
try:
style = ttk.Style()
if "clam" in style.theme_names():
style.theme_use("clam")
except Exception:
pass
_app = App(root, ctrl)
root.mainloop()
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff