diff --git a/PLAN_DHSQ_GLOBAL.md b/PLAN_DHSQ_GLOBAL.md new file mode 100644 index 0000000..3016473 --- /dev/null +++ b/PLAN_DHSQ_GLOBAL.md @@ -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 на предыдущий рабочий профиль. diff --git a/selective-vpn-gui/dashboard_controller.py b/selective-vpn-gui/dashboard_controller.py index fc5aa45..51aed40 100644 --- a/selective-vpn-gui/dashboard_controller.py +++ b/selective-vpn-gui/dashboard_controller.py @@ -156,6 +156,15 @@ class TrafficModeView: message: str +@dataclass(frozen=True) +class RoutesResolveSummaryView: + available: bool + text: str + recheck_text: str + color: str + recheck_color: str + + # --------------------------- # Controller # --------------------------- @@ -605,6 +614,10 @@ class DashboardController: 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)) @@ -617,6 +630,70 @@ class DashboardController: 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)) + 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: st: TrafficModeStatus = self.client.traffic_mode_get() return TrafficModeView( @@ -884,6 +961,7 @@ class DashboardController: timeout_ms: int = 1800, attempts: int = 1, concurrency: int = 6, + profile: str = "load", ) -> DNSBenchmarkResponse: return self.client.dns_benchmark( upstreams=upstreams, @@ -891,6 +969,7 @@ class DashboardController: timeout_ms=timeout_ms, attempts=attempts, concurrency=concurrency, + profile=profile, ) def dns_status_view(self) -> DNSStatus: diff --git a/selective-vpn-gui/vpn_dashboard_qt.py b/selective-vpn-gui/vpn_dashboard_qt.py index 8967b7f..9b95739 100755 --- a/selective-vpn-gui/vpn_dashboard_qt.py +++ b/selective-vpn-gui/vpn_dashboard_qt.py @@ -361,6 +361,11 @@ class MainWindow(QMainWindow): 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) @@ -383,6 +388,18 @@ RU: Агрессивный режим дополнительно дергает 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) --- @@ -1083,6 +1100,11 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и 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: @@ -1423,6 +1445,19 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и 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()