From 0f88cfeeaa35b68ffe328ae732714db60ecd2dea Mon Sep 17 00:00:00 2001 From: beckline Date: Sun, 22 Feb 2026 19:15:37 +0300 Subject: [PATCH] dns: switch to active upstream pool and wave fallback behavior --- selective-vpn-api/app/config.go | 4 + selective-vpn-api/app/dns_settings.go | 219 +++++++++++++++++++--- selective-vpn-api/app/resolver.go | 48 ++--- selective-vpn-api/app/server.go | 1 + selective-vpn-api/app/types.go | 9 + selective-vpn-gui/api_client.py | 47 +++++ selective-vpn-gui/dashboard_controller.py | 7 + selective-vpn-gui/dns_benchmark_dialog.py | 145 +++++++------- selective-vpn-gui/vpn_dashboard_qt.py | 32 ++-- 9 files changed, 382 insertions(+), 130 deletions(-) diff --git a/selective-vpn-api/app/config.go b/selective-vpn-api/app/config.go index b2aff41..8825cf4 100644 --- a/selective-vpn-api/app/config.go +++ b/selective-vpn-api/app/config.go @@ -31,11 +31,14 @@ const ( routesCacheIPs = stateDir + "/routes-clear-cache-ips.txt" routesCacheDyn = stateDir + "/routes-clear-cache-ips-dyn.txt" routesCacheMap = stateDir + "/routes-clear-cache-ips-map.txt" + routesCacheMapD = stateDir + "/routes-clear-cache-ips-map-direct.txt" + routesCacheMapW = stateDir + "/routes-clear-cache-ips-map-wildcard.txt" routesCacheRT = stateDir + "/routes-clear-cache-routes.txt" autoloopLogPath = stateDir + "/adguard-autoloop.log" loginStatePath = stateDir + "/adguard-login.json" dnsUpstreamsPath = stateDir + "/dns-upstreams.json" + dnsUpstreamPool = stateDir + "/dns-upstream-pool.json" smartdnsWLPath = stateDir + "/smartdns-wildcards.json" smartdnsRTPath = stateDir + "/smartdns-runtime.json" desiredLocation = stateDir + "/adguard-location.txt" @@ -64,6 +67,7 @@ const ( // RU: Дополнительные метки для per-app маршрутизации (systemd scope / cgroup). MARK_DIRECT = "0x67" // force direct (bypass VPN table even in full tunnel) MARK_APP = "0x68" // force VPN for app-scoped traffic (works even in traffic-mode=direct) + MARK_INGRESS = "0x69" // keep ingress reply-path direct in full tunnel (server-safe) defaultDNS1 = "94.140.14.14" defaultDNS2 = "94.140.15.15" defaultMeta1 = "46.243.231.30" diff --git a/selective-vpn-api/app/dns_settings.go b/selective-vpn-api/app/dns_settings.go index 6cce8ce..6430252 100644 --- a/selective-vpn-api/app/dns_settings.go +++ b/selective-vpn-api/app/dns_settings.go @@ -56,6 +56,30 @@ func handleDNSUpstreams(w http.ResponseWriter, r *http.Request) { } } +func handleDNSUpstreamPool(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + items := loadDNSUpstreamPoolState() + writeJSON(w, http.StatusOK, DNSUpstreamPoolState{Items: items}) + case http.MethodPost: + var body DNSUpstreamPoolState + if r.Body != nil { + defer r.Body.Close() + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + } + if err := saveDNSUpstreamPoolState(body.Items); err != nil { + http.Error(w, "write error", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, DNSUpstreamPoolState{Items: loadDNSUpstreamPoolState()}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + // --------------------------------------------------------------------- // EN: `handleDNSStatus` is an HTTP handler for dns status. // RU: `handleDNSStatus` - HTTP-обработчик для dns status. @@ -95,13 +119,23 @@ func handleDNSBenchmark(w http.ResponseWriter, r *http.Request) { upstreams := normalizeBenchmarkUpstreams(req.Upstreams) if len(upstreams) == 0 { - cfg := loadDNSUpstreamsConf() - upstreams = normalizeBenchmarkUpstreamStrings([]string{ - cfg.Default1, - cfg.Default2, - cfg.Meta1, - cfg.Meta2, - }) + pool := loadDNSUpstreamPoolState() + if len(pool) > 0 { + tmp := make([]DNSBenchmarkUpstream, 0, len(pool)) + for _, item := range pool { + tmp = append(tmp, DNSBenchmarkUpstream{Addr: item.Addr, Enabled: item.Enabled}) + } + upstreams = normalizeBenchmarkUpstreams(tmp) + } + if len(upstreams) == 0 { + cfg := loadDNSUpstreamsConf() + upstreams = normalizeBenchmarkUpstreamStrings([]string{ + cfg.Default1, + cfg.Default2, + cfg.Meta1, + cfg.Meta2, + }) + } } if len(upstreams) == 0 { http.Error(w, "no upstreams", http.StatusBadRequest) @@ -197,9 +231,6 @@ func normalizeBenchmarkUpstreams(in []DNSBenchmarkUpstream) []string { out := make([]string, 0, len(in)) seen := map[string]struct{}{} for _, item := range in { - if !item.Enabled { - continue - } n := normalizeDNSUpstream(item.Addr, "53") if n == "" { continue @@ -842,11 +873,7 @@ func prewarmAggressiveFromEnv() bool { } } -// --------------------------------------------------------------------- -// EN: `loadDNSUpstreamsConf` loads dns upstreams conf from storage or config. -// RU: `loadDNSUpstreamsConf` - загружает dns upstreams conf из хранилища или конфига. -// --------------------------------------------------------------------- -func loadDNSUpstreamsConf() DNSUpstreams { +func loadDNSUpstreamsConfFile() DNSUpstreams { cfg := DNSUpstreams{ Default1: defaultDNS1, Default2: defaultDNS2, @@ -903,11 +930,139 @@ func loadDNSUpstreamsConf() DNSUpstreams { return cfg } -// --------------------------------------------------------------------- -// EN: `saveDNSUpstreamsConf` saves dns upstreams conf to persistent storage. -// RU: `saveDNSUpstreamsConf` - сохраняет dns upstreams conf в постоянное хранилище. -// --------------------------------------------------------------------- -func saveDNSUpstreamsConf(cfg DNSUpstreams) error { +func normalizeDNSUpstreamPoolItems(items []DNSUpstreamPoolItem) []DNSUpstreamPoolItem { + out := make([]DNSUpstreamPoolItem, 0, len(items)) + seen := map[string]struct{}{} + for _, item := range items { + addr := normalizeDNSUpstream(item.Addr, "53") + if addr == "" { + continue + } + if _, ok := seen[addr]; ok { + continue + } + seen[addr] = struct{}{} + out = append(out, DNSUpstreamPoolItem{ + Addr: addr, + Enabled: item.Enabled, + }) + } + return out +} + +func dnsUpstreamPoolFromLegacy(cfg DNSUpstreams) []DNSUpstreamPoolItem { + raw := []string{cfg.Default1, cfg.Default2, cfg.Meta1, cfg.Meta2} + out := make([]DNSUpstreamPoolItem, 0, len(raw)) + for _, item := range raw { + n := normalizeDNSUpstream(item, "53") + if n == "" { + continue + } + out = append(out, DNSUpstreamPoolItem{Addr: n, Enabled: true}) + } + return normalizeDNSUpstreamPoolItems(out) +} + +func dnsUpstreamPoolToLegacy(items []DNSUpstreamPoolItem) DNSUpstreams { + enabled := make([]string, 0, len(items)) + all := make([]string, 0, len(items)) + for _, item := range items { + n := normalizeDNSUpstream(item.Addr, "53") + if n == "" { + continue + } + all = append(all, n) + if item.Enabled { + enabled = append(enabled, n) + } + } + list := enabled + if len(list) == 0 { + list = all + } + if len(list) == 0 { + list = []string{defaultDNS1, defaultDNS2, defaultMeta1, defaultMeta2} + } + pick := func(idx int, fallback string) string { + if len(list) == 0 { + return fallback + } + if idx < len(list) { + return list[idx] + } + return list[idx%len(list)] + } + return DNSUpstreams{ + Default1: pick(0, defaultDNS1), + Default2: pick(1, defaultDNS2), + Meta1: pick(2, defaultMeta1), + Meta2: pick(3, defaultMeta2), + } +} + +func saveDNSUpstreamPoolFile(items []DNSUpstreamPoolItem) error { + state := DNSUpstreamPoolState{Items: normalizeDNSUpstreamPoolItems(items)} + if err := os.MkdirAll(filepath.Dir(dnsUpstreamPool), 0o755); err != nil { + return err + } + tmp := dnsUpstreamPool + ".tmp" + b, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, dnsUpstreamPool) +} + +func loadDNSUpstreamPoolState() []DNSUpstreamPoolItem { + data, err := os.ReadFile(dnsUpstreamPool) + if err == nil { + var st DNSUpstreamPoolState + if json.Unmarshal(data, &st) == nil { + items := normalizeDNSUpstreamPoolItems(st.Items) + if len(items) > 0 { + return items + } + } + } + legacy := loadDNSUpstreamsConfFile() + items := dnsUpstreamPoolFromLegacy(legacy) + if len(items) > 0 { + _ = saveDNSUpstreamPoolFile(items) + } + return items +} + +func saveDNSUpstreamPoolState(items []DNSUpstreamPoolItem) error { + items = normalizeDNSUpstreamPoolItems(items) + if len(items) == 0 { + items = dnsUpstreamPoolFromLegacy(loadDNSUpstreamsConfFile()) + } + if err := saveDNSUpstreamPoolFile(items); err != nil { + return err + } + return saveDNSUpstreamsConfFile(dnsUpstreamPoolToLegacy(items)) +} + +func loadEnabledDNSUpstreamPool() []string { + items := loadDNSUpstreamPoolState() + out := make([]string, 0, len(items)) + for _, item := range items { + if !item.Enabled { + continue + } + n := normalizeDNSUpstream(item.Addr, "53") + if n == "" { + continue + } + out = append(out, n) + } + return uniqueStrings(out) +} + +func saveDNSUpstreamsConfFile(cfg DNSUpstreams) error { cfg.Default1 = normalizeDNSUpstream(cfg.Default1, "53") cfg.Default2 = normalizeDNSUpstream(cfg.Default2, "53") cfg.Meta1 = normalizeDNSUpstream(cfg.Meta1, "53") @@ -947,10 +1102,32 @@ func saveDNSUpstreamsConf(cfg DNSUpstreams) error { if b, err := json.MarshalIndent(cfg, "", " "); err == nil { _ = os.WriteFile(dnsUpstreamsPath, b, 0o644) } - return nil } +// --------------------------------------------------------------------- +// EN: `loadDNSUpstreamsConf` loads dns upstreams conf from storage or config. +// RU: `loadDNSUpstreamsConf` - загружает dns upstreams conf из хранилища или конфига. +// --------------------------------------------------------------------- +func loadDNSUpstreamsConf() DNSUpstreams { + pool := loadDNSUpstreamPoolState() + if len(pool) > 0 { + return dnsUpstreamPoolToLegacy(pool) + } + return loadDNSUpstreamsConfFile() +} + +// --------------------------------------------------------------------- +// EN: `saveDNSUpstreamsConf` saves dns upstreams conf to persistent storage. +// RU: `saveDNSUpstreamsConf` - сохраняет dns upstreams conf в постоянное хранилище. +// --------------------------------------------------------------------- +func saveDNSUpstreamsConf(cfg DNSUpstreams) error { + if err := saveDNSUpstreamsConfFile(cfg); err != nil { + return err + } + return saveDNSUpstreamPoolFile(dnsUpstreamPoolFromLegacy(cfg)) +} + // --------------------------------------------------------------------- // EN: `loadDNSMode` loads dns mode from storage or config. // RU: `loadDNSMode` - загружает dns mode из хранилища или конфига. diff --git a/selective-vpn-api/app/resolver.go b/selective-vpn-api/app/resolver.go index 816797d..b01f20a 100644 --- a/selective-vpn-api/app/resolver.go +++ b/selective-vpn-api/app/resolver.go @@ -141,14 +141,9 @@ type wildcardMatcher struct { suffix []string } -var resolverFallbackDNS = []string{ - "1.1.1.1#53", - "1.0.0.1#53", - "9.9.9.9#53", - "149.112.112.112#53", - "8.8.8.8#53", - "8.8.4.4#53", -} +// Empty by default: primary resolver pool comes from DNS upstream pool state. +// Optional fallback list can still be provided via RESOLVE_DNS_FALLBACKS env. +var resolverFallbackDNS []string func normalizeWildcardDomain(raw string) string { d := strings.TrimSpace(strings.SplitN(raw, "#", 2)[0]) @@ -562,14 +557,8 @@ func digA(host string, dnsList []string, timeout time.Duration, logf func(string if logf != nil { logf("dns warn %s via %s: kind=%s err=%v", host, addr, kind, err) } - // NXDOMAIN usually means authoritative negative answer. - // Do not fan out further retries for this host. - if kind == dnsErrorNXDomain { - break - } continue } - stats.addSuccess(addr) var ips []string for _, ip := range records { if isPrivateIPv4(ip) { @@ -577,6 +566,14 @@ func digA(host string, dnsList []string, timeout time.Duration, logf func(string } ips = append(ips, ip) } + if len(ips) == 0 { + stats.addError(addr, dnsErrorOther) + if logf != nil { + logf("dns warn %s via %s: kind=other err=no_public_ips", host, addr) + } + continue + } + stats.addSuccess(addr) return uniqueStrings(ips), stats } return nil, stats @@ -1036,6 +1033,11 @@ func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig { SmartDNS: smartDNSAddr(), Mode: DNSModeDirect, } + activePool := loadEnabledDNSUpstreamPool() + if len(activePool) > 0 { + cfg.Default = activePool + cfg.Meta = activePool + } // 1) Если форсируем SmartDNS — вообще игнорим файл и ходим только через локальный резолвер. if smartDNSForced() { @@ -1051,12 +1053,14 @@ func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig { return cfg } - // 2) Иначе пытаемся прочитать dns-upstreams.conf, как и раньше. + // 2) Читаем dns-upstreams.conf для legacy-совместимости и smartdns/mode значений. data, err := os.ReadFile(path) if err != nil { if logf != nil { - logf("dns-config: use built-in defaults, can't read %s: %v", path, err) + logf("dns-config: can't read %s: %v", path, err) } + cfg.Default = mergeDNSUpstreamPools(cfg.Default, resolverFallbackPool()) + cfg.Meta = mergeDNSUpstreamPools(cfg.Meta, resolverFallbackPool()) return cfg } @@ -1098,11 +1102,13 @@ func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig { } } } - if len(def) > 0 { - cfg.Default = def - } - if len(meta) > 0 { - cfg.Meta = meta + if len(activePool) == 0 { + if len(def) > 0 { + cfg.Default = def + } + if len(meta) > 0 { + cfg.Meta = meta + } } cfg.Default = mergeDNSUpstreamPools(cfg.Default, resolverFallbackPool()) cfg.Meta = mergeDNSUpstreamPools(cfg.Meta, resolverFallbackPool()) diff --git a/selective-vpn-api/app/server.go b/selective-vpn-api/app/server.go index b031143..c671da2 100644 --- a/selective-vpn-api/app/server.go +++ b/selective-vpn-api/app/server.go @@ -169,6 +169,7 @@ func Run() { // DNS upstreams mux.HandleFunc("/api/v1/dns-upstreams", handleDNSUpstreams) + mux.HandleFunc("/api/v1/dns/upstream-pool", handleDNSUpstreamPool) mux.HandleFunc("/api/v1/dns/status", handleDNSStatus) mux.HandleFunc("/api/v1/dns/mode", handleDNSModeSet) mux.HandleFunc("/api/v1/dns/benchmark", handleDNSBenchmark) diff --git a/selective-vpn-api/app/types.go b/selective-vpn-api/app/types.go index 1c96bc1..72bba2c 100644 --- a/selective-vpn-api/app/types.go +++ b/selective-vpn-api/app/types.go @@ -46,6 +46,15 @@ type DNSUpstreams struct { Meta2 string `json:"meta2"` } +type DNSUpstreamPoolItem struct { + Addr string `json:"addr"` + Enabled bool `json:"enabled"` +} + +type DNSUpstreamPoolState struct { + Items []DNSUpstreamPoolItem `json:"items"` +} + type DNSResolverMode string const ( diff --git a/selective-vpn-gui/api_client.py b/selective-vpn-gui/api_client.py index 7c8c095..789c9a1 100644 --- a/selective-vpn-gui/api_client.py +++ b/selective-vpn-gui/api_client.py @@ -259,6 +259,11 @@ class DNSBenchmarkResponse: recommended_meta: List[str] +@dataclass(frozen=True) +class DNSUpstreamPoolState: + items: List[DNSBenchmarkUpstream] + + @dataclass(frozen=True) class SmartdnsServiceState: state: str @@ -1185,6 +1190,48 @@ class ApiClient: }, ) + def dns_upstream_pool_get(self) -> DNSUpstreamPoolState: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/upstream-pool")) or {}) + raw = data.get("items") or [] + if not isinstance(raw, list): + raw = [] + items: List[DNSBenchmarkUpstream] = [] + for row in raw: + if not isinstance(row, dict): + continue + addr = str(row.get("addr") or "").strip() + if not addr: + continue + items.append(DNSBenchmarkUpstream(addr=addr, enabled=bool(row.get("enabled", True)))) + return DNSUpstreamPoolState(items=items) + + def dns_upstream_pool_set(self, items: List[DNSBenchmarkUpstream]) -> DNSUpstreamPoolState: + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/dns/upstream-pool", + json_body={ + "items": [{"addr": u.addr, "enabled": bool(u.enabled)} for u in (items or [])], + }, + ) + ) + or {}, + ) + raw = data.get("items") or [] + if not isinstance(raw, list): + raw = [] + out: List[DNSBenchmarkUpstream] = [] + for row in raw: + if not isinstance(row, dict): + continue + addr = str(row.get("addr") or "").strip() + if not addr: + continue + out.append(DNSBenchmarkUpstream(addr=addr, enabled=bool(row.get("enabled", True)))) + return DNSUpstreamPoolState(items=out) + def dns_benchmark( self, upstreams: List[DNSBenchmarkUpstream], diff --git a/selective-vpn-gui/dashboard_controller.py b/selective-vpn-gui/dashboard_controller.py index abd2bd6..7853078 100644 --- a/selective-vpn-gui/dashboard_controller.py +++ b/selective-vpn-gui/dashboard_controller.py @@ -26,6 +26,7 @@ from api_client import ( CmdResult, DNSBenchmarkResponse, DNSBenchmarkUpstream, + DNSUpstreamPoolState, DNSStatus, DnsUpstreams, DomainsFile, @@ -870,6 +871,12 @@ class DashboardController: def dns_upstreams_save(self, cfg: DnsUpstreams) -> None: self.client.dns_upstreams_set(cfg) + def dns_upstream_pool_view(self) -> DNSUpstreamPoolState: + return self.client.dns_upstream_pool_get() + + def dns_upstream_pool_save(self, items: List[DNSBenchmarkUpstream]) -> DNSUpstreamPoolState: + return self.client.dns_upstream_pool_set(items) + def dns_benchmark( self, upstreams: List[DNSBenchmarkUpstream], diff --git a/selective-vpn-gui/dns_benchmark_dialog.py b/selective-vpn-gui/dns_benchmark_dialog.py index 2586bcc..f1cde6e 100644 --- a/selective-vpn-gui/dns_benchmark_dialog.py +++ b/selective-vpn-gui/dns_benchmark_dialog.py @@ -20,7 +20,7 @@ from PySide6.QtWidgets import ( QWidget, ) -from api_client import DNSBenchmarkUpstream, DnsUpstreams +from api_client import DNSBenchmarkUpstream from dashboard_controller import DashboardController @@ -61,24 +61,22 @@ class DNSBenchmarkDialog(QDialog): self.ctrl = ctrl self.settings = settings self.refresh_cb = refresh_cb - self._last_recommended_default: List[str] = [] - self._last_recommended_meta: List[str] = [] self.setWindowTitle("DNS benchmark") - self.resize(980, 650) + self.resize(980, 660) root = QVBoxLayout(self) hint = QLabel( - "List format: one DNS per row. Toggle checkbox to include in test. " - "Then run benchmark and apply best DNS to resolver." + "One DNS per row. Checkbox means ACTIVE for resolver wave mode. " + "Benchmark checks all rows and shows health." ) hint.setWordWrap(True) hint.setStyleSheet("color: gray;") root.addWidget(hint) self.tbl_sources = QTableWidget(0, 2) - self.tbl_sources.setHorizontalHeaderLabels(["Use", "DNS upstream"]) + self.tbl_sources.setHorizontalHeaderLabels(["Active", "DNS upstream"]) self.tbl_sources.horizontalHeader().setStretchLastSection(True) self.tbl_sources.setSelectionBehavior(QTableWidget.SelectRows) self.tbl_sources.setSelectionMode(QTableWidget.SingleSelection) @@ -95,6 +93,12 @@ class DNSBenchmarkDialog(QDialog): self.btn_reset = QPushButton("Reset defaults") self.btn_reset.clicked.connect(self.on_reset_defaults) row_btns.addWidget(self.btn_reset) + self.btn_reload = QPushButton("Reload active set") + self.btn_reload.clicked.connect(self.on_reload_pool) + row_btns.addWidget(self.btn_reload) + self.btn_save_pool = QPushButton("Save active set") + self.btn_save_pool.clicked.connect(self.on_save_pool) + row_btns.addWidget(self.btn_save_pool) row_btns.addStretch(1) root.addLayout(row_btns) @@ -150,18 +154,12 @@ class DNSBenchmarkDialog(QDialog): self.tbl_results.setSelectionMode(QTableWidget.SingleSelection) root.addWidget(self.tbl_results, stretch=3) - apply_row = QHBoxLayout() - self.btn_apply_default = QPushButton("Apply top-2 to Default") - self.btn_apply_default.clicked.connect(self.on_apply_default) - apply_row.addWidget(self.btn_apply_default) - self.btn_apply_meta = QPushButton("Apply top-2 to Meta") - self.btn_apply_meta.clicked.connect(self.on_apply_meta) - apply_row.addWidget(self.btn_apply_meta) - apply_row.addStretch(1) + close_row = QHBoxLayout() + close_row.addStretch(1) self.btn_close = QPushButton("Close") self.btn_close.clicked.connect(self.accept) - apply_row.addWidget(self.btn_close) - root.addLayout(apply_row) + close_row.addWidget(self.btn_close) + root.addLayout(close_row) self._load_sources() self._load_domains() @@ -173,33 +171,51 @@ class DNSBenchmarkDialog(QDialog): QMessageBox.critical(self, title, str(e)) def _load_sources(self) -> None: - raw = str(self.settings.value("dns_benchmark/upstreams", "") or "").strip() rows: List[tuple[bool, str]] = [] - if raw: - try: - data = json.loads(raw) - if isinstance(data, list): - for item in data: - if not isinstance(item, dict): - continue - addr = str(item.get("addr") or "").strip() - if not addr: - continue - rows.append((bool(item.get("enabled", True)), addr)) - except Exception: - rows = [] + + try: + st = self.ctrl.dns_upstream_pool_view() + for item in st.items: + addr = str(item.addr or "").strip() + if not addr: + continue + rows.append((bool(item.enabled), addr)) + except Exception: + rows = [] + + if not rows: + raw = str(self.settings.value("dns_benchmark/upstreams", "") or "").strip() + if raw: + try: + data = json.loads(raw) + if isinstance(data, list): + for item in data: + if not isinstance(item, dict): + continue + addr = str(item.get("addr") or "").strip() + if not addr: + continue + rows.append((bool(item.get("enabled", True)), addr)) + except Exception: + rows = [] + if not rows: rows = [(True, item) for item in DEFAULT_UPSTREAMS] + self.tbl_sources.blockSignals(True) self.tbl_sources.setRowCount(0) for enabled, addr in rows: self._append_source_row(enabled, addr) + self.tbl_sources.blockSignals(False) + self._save_settings() def _load_domains(self) -> None: raw = str(self.settings.value("dns_benchmark/domains", "") or "").strip() if not raw: raw = "\n".join(DEFAULT_DOMAINS) + self.txt_domains.blockSignals(True) self.txt_domains.setPlainText(raw) + self.txt_domains.blockSignals(False) def _save_settings(self) -> None: items = [] @@ -264,14 +280,33 @@ class DNSBenchmarkDialog(QDialog): row = self.tbl_sources.currentRow() if row >= 0: self.tbl_sources.removeRow(row) - self._save_settings() + self._save_settings() def on_reset_defaults(self) -> None: + self.tbl_sources.blockSignals(True) self.tbl_sources.setRowCount(0) for item in DEFAULT_UPSTREAMS: self._append_source_row(True, item) + self.tbl_sources.blockSignals(False) self._save_settings() + def on_reload_pool(self) -> None: + self._safe(self._load_sources, "Reload DNS active set error") + + def on_save_pool(self) -> None: + def work() -> None: + payload = self._source_payload() + st = self.ctrl.dns_upstream_pool_save(payload) + active = sum(1 for x in st.items if x.enabled) + total = len(st.items) + self._save_settings() + self.lbl_summary.setText(f"Saved active DNS set: active={active}/{total}") + self.lbl_summary.setStyleSheet("color: green;") + if self.refresh_cb: + self.refresh_cb() + + self._safe(work, "Save DNS active set error") + def on_run_benchmark(self) -> None: def work() -> None: self._save_settings() @@ -284,8 +319,6 @@ class DNSBenchmarkDialog(QDialog): attempts=int(self.spin_attempts.value()), concurrency=int(self.spin_concurrency.value()), ) - self._last_recommended_default = list(resp.recommended_default or []) - self._last_recommended_meta = list(resp.recommended_meta or []) self._render_results(resp) if self.refresh_cb: self.refresh_cb() @@ -320,11 +353,9 @@ class DNSBenchmarkDialog(QDialog): st_item.setForeground(QColor("red")) self.tbl_results.setItem(row, 6, st_item) - dflt = ", ".join(resp.recommended_default or []) or "—" - meta = ", ".join(resp.recommended_meta or []) or "—" self.lbl_summary.setText( f"Checked: {len(resp.results)} DNS | domains={len(resp.domains_used)} " - f"| timeout={resp.timeout_ms}ms | rec default: {dflt} | rec meta: {meta}" + f"| timeout={resp.timeout_ms}ms" ) self.lbl_summary.setStyleSheet("color: gray;") @@ -334,43 +365,3 @@ class DNSBenchmarkDialog(QDialog): self.settings.setValue("dns_benchmark/last_ok", ok_total) self.settings.setValue("dns_benchmark/last_fail", fail_total) self.settings.setValue("dns_benchmark/last_timeout", timeout_total) - - def on_apply_default(self) -> None: - def work() -> None: - picks = list(self._last_recommended_default or []) - if len(picks) < 2: - raise ValueError("run benchmark first (need at least 2 recommended DNS)") - cur = self.ctrl.dns_upstreams_view() - cfg = DnsUpstreams( - default1=picks[0], - default2=picks[1], - meta1=cur.meta1, - meta2=cur.meta2, - ) - self.ctrl.dns_upstreams_save(cfg) - if self.refresh_cb: - self.refresh_cb() - self.lbl_summary.setText(f"Applied default DNS: {picks[0]}, {picks[1]}") - self.lbl_summary.setStyleSheet("color: green;") - - self._safe(work, "Apply default DNS error") - - def on_apply_meta(self) -> None: - def work() -> None: - picks = list(self._last_recommended_meta or []) - if len(picks) < 2: - raise ValueError("run benchmark first (need at least 2 recommended DNS)") - cur = self.ctrl.dns_upstreams_view() - cfg = DnsUpstreams( - default1=cur.default1, - default2=cur.default2, - meta1=picks[0], - meta2=picks[1], - ) - self.ctrl.dns_upstreams_save(cfg) - if self.refresh_cb: - self.refresh_cb() - self.lbl_summary.setText(f"Applied meta DNS: {picks[0]}, {picks[1]}") - self.lbl_summary.setStyleSheet("color: green;") - - self._safe(work, "Apply meta DNS error") diff --git a/selective-vpn-gui/vpn_dashboard_qt.py b/selective-vpn-gui/vpn_dashboard_qt.py index 54ecdf9..bca7311 100755 --- a/selective-vpn-gui/vpn_dashboard_qt.py +++ b/selective-vpn-gui/vpn_dashboard_qt.py @@ -34,7 +34,7 @@ from PySide6.QtWidgets import ( QProgressBar, ) -from api_client import ApiClient, DnsUpstreams +from api_client import ApiClient from dashboard_controller import DashboardController, TraceMode from dns_benchmark_dialog import DNSBenchmarkDialog from traffic_mode_dialog import TrafficModeDialog @@ -729,14 +729,24 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и self.lbl_dns_mode_state.setText(txt) self.lbl_dns_mode_state.setStyleSheet(f"color: {color};") - def _set_dns_resolver_summary(self, ups: DnsUpstreams) -> None: - d1 = (ups.default1 or "—").strip() or "—" - d2 = (ups.default2 or "—").strip() or "—" - m1 = (ups.meta1 or "—").strip() or "—" - m2 = (ups.meta2 or "—").strip() or "—" - self.lbl_dns_resolver_upstreams.setText( - f"Resolver upstreams: default[{d1}, {d2}] meta[{m1}, {m2}]" - ) + def _set_dns_resolver_summary(self, pool_items) -> None: + active = [] + total = 0 + for item in pool_items or []: + addr = str(getattr(item, "addr", "") or "").strip() + if not addr: + continue + total += 1 + if bool(getattr(item, "enabled", False)): + active.append(addr) + if not active: + text = f"Resolver upstreams: active=0/{total} (empty set)" + else: + preview = ", ".join(active[:4]) + if len(active) > 4: + preview += f", +{len(active)-4} more" + text = f"Resolver upstreams: active={len(active)}/{total} [{preview}]" + self.lbl_dns_resolver_upstreams.setText(text) self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;") avg_ms = self._ui_settings.value("dns_benchmark/last_avg_ms", None) @@ -1072,8 +1082,8 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и def work(): self._dns_ui_refresh = True try: - ups = self.ctrl.dns_upstreams_view() - self._set_dns_resolver_summary(ups) + pool = self.ctrl.dns_upstream_pool_view() + self._set_dns_resolver_summary(getattr(pool, "items", [])) st = self.ctrl.dns_status_view() self.ent_smartdns_addr.setText(st.smartdns_addr or "")