gui(routes): show resolve summary + timeout recheck badges

This commit is contained in:
beckline
2026-02-25 09:35:37 +03:00
parent 3c170e5da6
commit 90c4a73473
3 changed files with 379 additions and 0 deletions

265
PLAN_DHSQ_GLOBAL.md Normal file
View File

@@ -0,0 +1,265 @@
# Global Plan: DHSQ (Domain Health Scoring + Quarantine)
## Execution checklist (live)
- [x] ~~1. Implement attempt budget + early stop semantics.~~
- [x] ~~2. Implement domain/resolver scoring and quarantine states.~~
- [x] ~~3. Add stale-keep policy.~~
- [x] ~~4. Wire 24h precheck cycle (soft pruning only).~~
- [x] ~~5. Expose metrics/log clarity in API + GUI (API/trace done; DNS benchmark load-profile UI done; route badges done).~~
- [ ] 6. Tune thresholds with production data.
## 1) Goal
- Stabilize resolver behavior under high domain volume.
- Reduce timeout storms and DNS provider rate-limit effects.
- Keep routing safety (avoid missing required IPs for selective VPN).
- Preserve wildcard flow via SmartDNS without forcing subs brute-force.
## 2) Current pain points
- Too many DNS attempts per domain in wave mode.
- Upstream DNS throttling leads to large timeout counts.
- Mixed signal: hard to separate dead domains vs temporary resolver failure.
- Risk of dropping useful domains when pruning is too aggressive.
## 3) Target model (approved)
- Use **DHSQ** as soft-pruning model, not hard deny.
- Run precheck cycle every 24h (not weekly).
- Maintain separate health for:
- `domain_score` (domain reliability),
- `resolver_score` (upstream DNS reliability).
- Wildcard remains SmartDNS-driven.
- Subs fan-out is optional/aggressive mode only.
## 4) Attempt budget policy
- Per-domain cap:
- direct domains: 2 attempts (max 3),
- wildcard path: 1 attempt (max 2 with controlled fallback).
- Time budget per domain: ~1200 ms.
- Retry policy:
- confirmed NXDOMAIN -> early stop,
- timeout/temporary -> one fallback attempt.
## 5) Domain scoring policy (initial values)
- `A/AAAA OK`: +8
- `runtime observed`: +12
- `NXDOMAIN confirmed by 2 resolvers`: -15
- `NXDOMAIN from 1 resolver`: -7
- `timeout`: -3
- `temporary/servfail`: -2
State thresholds:
- `>= +20`: Active
- `+5..+19`: Stable
- `-10..+4`: Suspect (short retest, e.g. 6h)
- `< -10`: Quarantine (24h)
- `< -30`: Hard quarantine (7d, but 1 probe/day)
Exit rules:
- Any successful resolve or runtime observation removes quarantine.
## 6) Quarantine + stale protection
- Quarantine is soft suppression for noisy/unreliable domains.
- Never treat timeout as long-term dead by default.
- Keep stale known-good IPs for 48-72h on temporary failures.
- If runtime observes a quarantined host, promote it immediately.
## 7) Resolver pool policy
- Active resolver set: 4-6 best DNS at runtime.
- Benchmark can test larger list; resolver uses selected active subset.
- Use resolver cooldown after repeated failures.
- Optional hedged query: second DNS after 100-150 ms delay.
## 8) SmartDNS integration direction
- Keep SmartDNS as managed child process (not embedded library).
- API controls status/start/stop/reload and generated config.
- Wildcard source remains:
- resolver + smartdns runtime (with clear visibility in trace/logs).
## 9) Observability and metrics
- Add/track:
- attempts_total,
- attempts_avg_per_domain,
- budget_exhausted_domains,
- early_stop_nxdomain,
- cache_neg_hits,
- timeouts_by_upstream,
- resolver_score/domain_score summaries.
## 10) Rollout order
1. Implement attempt budget + early stop semantics.
2. Implement domain/resolver scoring and quarantine states.
3. Add stale-keep policy.
4. Wire 24h precheck cycle (soft pruning only).
5. Expose metrics/log clarity in API + GUI.
6. Tune thresholds with production data.
## 11) Non-goals (for now)
- No full architecture switch to FakeDNS/TProxy pipeline.
- No hard dependency on sing-box routing core.
- No removal of existing SmartDNS wildcard path.
## 12) Sing-box integration extension (client side)
- Integrate sing-box as **optional sidecar engine** (managed child process), not as hard replacement of resolver.
- Use sing-box to expand client protocols and node compatibility:
- `vless`, `vmess`, `trojan`, `shadowsocks`, `hysteria2`, `wireguard` (actual enabled set configurable).
- Keep current backend as policy/controller layer:
- profile storage,
- launch/stop/restart,
- health checks,
- status and failover logic.
- Keep DHSQ resolver path independent; sing-box is additive for transport/client capability.
## 13) UI redesign scope for protocol profiles
- Add dedicated UI area for transport/client management (separate from DNS tab):
- `Profiles` (name, protocol, server, creds, tls/reality params),
- `Nodes` (region/endpoint list, priority),
- `Health` (latency, last check, fail state),
- `Binding` (which traffic/app/profile uses which transport profile).
- Add per-profile validation before apply (schema + connection smoke test).
- Keep "basic mode" with defaults and hide advanced fields unless requested.
## 14) Multi-interface model (important)
- Multiple VPN interfaces can run **simultaneously** on Linux (allowed), e.g. `tun0`, `wg0`, `sbx0`.
- Not forbidden, but requires strict policy routing ownership:
- unique route table per interface/profile,
- unique fwmark ranges per engine,
- deterministic rule priority order,
- conflict guard with existing agvpn nft rules.
- Default route should be controlled by selected traffic mode only; other interfaces stay available for policy-based routing.
## 15) Sing-box rollout order (after DHSQ stabilization)
1. Add backend profile schema + secure storage + validation API.
2. Add sing-box process manager (start/stop/reload/status/log tail).
3. Add one protocol first (minimal path), verify policy routing isolation.
4. Add multi-interface binding and per-profile routing table allocation.
5. Extend GUI with protocol profile editor and health dashboard.
6. Add controlled failover and rollback to previous working profile.
---
# Глобальный план: DHSQ (Domain Health Scoring + Quarantine)
## 1) Цель
- Стабилизировать работу резолвера при большом объеме доменов.
- Снизить шторма таймаутов и эффекты rate-limit со стороны DNS-провайдеров.
- Сохранить безопасность маршрутизации (не терять нужные IP для selective VPN).
- Сохранить wildcard-поток через SmartDNS без brute-force по subs.
## 2) Текущие проблемы
- Слишком много DNS-попыток на один домен в wave-режиме.
- Троттлинг апстримов приводит к большому числу таймаутов.
- Смешение сигналов: сложно отделить мертвый домен от временного сбоя резолвера.
- Риск потерять полезные домены при слишком агрессивной фильтрации.
## 3) Целевая модель (подтверждено)
- Использовать **DHSQ** как мягкую фильтрацию, а не жесткий deny.
- Запускать precheck каждые 24 часа (не раз в неделю).
- Вести отдельное здоровье для:
- `domain_score` (надежность домена),
- `resolver_score` (надежность DNS-апстрима).
- Wildcard остается на SmartDNS.
- Расширение по subs остается опциональным (aggressive mode).
## 4) Политика attempt budget
- Лимит попыток на домен:
- direct-домены: 2 попытки (максимум 3),
- wildcard-путь: 1 попытка (максимум 2 с контролируемым fallback).
- Бюджет времени на домен: ~1200 мс.
- Политика повторов:
- подтвержденный NXDOMAIN -> ранняя остановка,
- timeout/temporary -> одна fallback-попытка.
## 5) Политика scoring доменов (начальные значения)
- `A/AAAA OK`: +8
- `runtime observed`: +12
- `NXDOMAIN`, подтвержденный 2 резолверами: -15
- `NXDOMAIN` от 1 резолвера: -7
- `timeout`: -3
- `temporary/servfail`: -2
Пороги состояний:
- `>= +20`: Active
- `+5..+19`: Stable
- `-10..+4`: Suspect (короткий ретест, например 6ч)
- `< -10`: Quarantine (24ч)
- `< -30`: Hard quarantine (7д, но 1 проба/сутки)
Правила выхода:
- Любой успешный резолв или runtime-наблюдение снимает quarantine.
## 6) Quarantine + stale-защита
- Quarantine — это мягкое подавление шумных/ненадежных доменов.
- Таймауты по умолчанию не считаем долгосрочной смертью домена.
- При временных сбоях держим stale known-good IP 48-72ч.
- Если runtime видит quarantined-хост, сразу повышаем его приоритет.
## 7) Политика пула резолверов
- Активный пул в рантайме: 4-6 лучших DNS.
- Benchmark может проверять больший список; резолвер использует выбранный активный поднабор.
- Добавить cooldown для резолвера после повторяющихся ошибок.
- Опционально hedged query: второй DNS через 100-150 мс.
## 8) Направление интеграции SmartDNS
- Оставить SmartDNS как управляемый child-процесс (не embedded library).
- API управляет status/start/stop/reload и генерацией конфига.
- Источник wildcard остается:
- resolver + smartdns runtime (с прозрачностью в trace/logs).
## 9) Наблюдаемость и метрики
- Добавить/отслеживать:
- attempts_total,
- attempts_avg_per_domain,
- budget_exhausted_domains,
- early_stop_nxdomain,
- cache_neg_hits,
- timeouts_by_upstream,
- сводки resolver_score/domain_score.
## 10) Порядок внедрения
1. Реализовать attempt budget + правила ранней остановки.
2. Реализовать scoring доменов/резолверов и состояния quarantine.
3. Добавить stale-keep policy.
4. Подключить 24ч precheck-cycle (только мягкая фильтрация).
5. Вывести метрики и прозрачные логи в API + GUI.
6. Подстроить пороги по данным продакшена.
## 11) Что не делаем сейчас
- Не делаем полный переход архитектуры на FakeDNS/TProxy pipeline.
- Не делаем жесткую зависимость от routing core sing-box.
- Не убираем текущий SmartDNS wildcard path.
## 12) Расширение интеграции sing-box (клиентская часть)
- Интегрировать sing-box как **опциональный sidecar engine** (управляемый child-процесс), а не как жесткую замену резолвера.
- Использовать sing-box для расширения клиентских протоколов и совместимости с нодами:
- `vless`, `vmess`, `trojan`, `shadowsocks`, `hysteria2`, `wireguard` (набор включается конфигом).
- Оставить текущий backend как слой политики/контроля:
- хранение профилей,
- launch/stop/restart,
- health checks,
- status и failover-логика.
- Путь резолва DHSQ оставить независимым; sing-box использовать как дополнение к transport/client части.
## 13) Область редизайна UI под протокольные профили
- Добавить отдельную зону UI для transport/client управления (отдельно от DNS-вкладки):
- `Profiles` (имя, протокол, сервер, креды, tls/reality параметры),
- `Nodes` (список регионов/endpoint, приоритет),
- `Health` (задержка, последняя проверка, fail-state),
- `Binding` (какой трафик/app/profile использует какой transport-profile).
- Добавить валидацию профиля перед применением (schema + connection smoke test).
- Сохранить "basic mode" с дефолтами и скрыть advanced-поля до явного запроса.
## 14) Модель multi-interface (важно)
- Несколько VPN-интерфейсов могут работать **одновременно** на Linux (разрешено), например `tun0`, `wg0`, `sbx0`.
- Это не запрещено, но требует строгого разделения policy routing:
- отдельная route table на интерфейс/профиль,
- отдельные диапазоны fwmark на engine,
- детерминированный порядок приоритетов rule,
- защита от конфликтов с текущими `agvpn` nft-правилами.
- Default route должен управляться только выбранным traffic mode; остальные интерфейсы доступны для policy-based routing.
## 15) Порядок внедрения sing-box (после стабилизации DHSQ)
1. Добавить schema профилей в backend + безопасное хранение + validation API.
2. Добавить process manager для sing-box (start/stop/reload/status/log tail).
3. Сначала включить один протокол (минимальный путь) и проверить изоляцию policy routing.
4. Добавить multi-interface binding и выделение route table на профиль.
5. Расширить GUI редактором протокольных профилей и health-dashboard.
6. Добавить контролируемый failover и rollback на предыдущий рабочий профиль.

View File

@@ -156,6 +156,15 @@ class TrafficModeView:
message: str message: str
@dataclass(frozen=True)
class RoutesResolveSummaryView:
available: bool
text: str
recheck_text: str
color: str
recheck_color: str
# --------------------------- # ---------------------------
# Controller # Controller
# --------------------------- # ---------------------------
@@ -605,6 +614,10 @@ class DashboardController:
res = self.client.routes_cache_restore() res = self.client.routes_cache_restore()
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) 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: def routes_fix_policy_route(self) -> ActionView:
res = self.client.routes_fix_policy_route() res = self.client.routes_fix_policy_route()
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res))
@@ -617,6 +630,70 @@ class DashboardController:
res = self.client.routes_timer_set(bool(enabled)) res = self.client.routes_timer_set(bool(enabled))
return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) 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))
q_hits = int(pairs.get("quarantine_hits", 0))
dns_attempts = int(pairs.get("dns_attempts", 0))
dns_timeout = int(pairs.get("dns_timeout", 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"quarantine_hits={q_hits} | dns_timeout={dns_timeout} | attempts={dns_attempts}"
)
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"
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 traffic_mode_view(self) -> TrafficModeView: def traffic_mode_view(self) -> TrafficModeView:
st: TrafficModeStatus = self.client.traffic_mode_get() st: TrafficModeStatus = self.client.traffic_mode_get()
return TrafficModeView( return TrafficModeView(
@@ -884,6 +961,7 @@ class DashboardController:
timeout_ms: int = 1800, timeout_ms: int = 1800,
attempts: int = 1, attempts: int = 1,
concurrency: int = 6, concurrency: int = 6,
profile: str = "load",
) -> DNSBenchmarkResponse: ) -> DNSBenchmarkResponse:
return self.client.dns_benchmark( return self.client.dns_benchmark(
upstreams=upstreams, upstreams=upstreams,
@@ -891,6 +969,7 @@ class DashboardController:
timeout_ms=timeout_ms, timeout_ms=timeout_ms,
attempts=attempts, attempts=attempts,
concurrency=concurrency, concurrency=concurrency,
profile=profile,
) )
def dns_status_view(self) -> DNSStatus: def dns_status_view(self) -> DNSStatus:

View File

@@ -361,6 +361,11 @@ class MainWindow(QMainWindow):
RU: Делает DNS-запросы wildcard-доменов, чтобы заранее наполнить agvpn_dyn4.""") RU: Делает DNS-запросы wildcard-доменов, чтобы заранее наполнить agvpn_dyn4.""")
self.btn_routes_prewarm.clicked.connect(self.on_smartdns_prewarm) self.btn_routes_prewarm.clicked.connect(self.on_smartdns_prewarm)
relay_row.addWidget(self.btn_routes_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) relay_row.addStretch(1)
traffic_layout.addLayout(relay_row) traffic_layout.addLayout(relay_row)
@@ -383,6 +388,18 @@ RU: Агрессивный режим дополнительно дергает
self.lbl_traffic_mode_diag.setStyleSheet("color: gray;") self.lbl_traffic_mode_diag.setStyleSheet("color: gray;")
traffic_layout.addWidget(self.lbl_traffic_mode_diag) 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) layout.addWidget(traffic_group)
# --- NFT progress (agvpn4) --- # --- NFT progress (agvpn4) ---
@@ -1083,6 +1100,11 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
t.iface_reason, t.iface_reason,
t.message, 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") self._safe(work, title="Routes error")
def refresh_dns_tab(self) -> None: def refresh_dns_tab(self) -> None:
@@ -1423,6 +1445,19 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
self.refresh_status_tab() self.refresh_status_tab()
self._safe(work, title="Traffic mode test error") 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 on_toggle_timer(self) -> None:
def work(): def work():
enabled = self.chk_timer.isChecked() enabled = self.chk_timer.isChecked()