From a7ec4fe8015b94573d25a07e28ad50210d60de3c Mon Sep 17 00:00:00 2001 From: beckline Date: Sun, 22 Feb 2026 14:40:40 +0300 Subject: [PATCH] dns ui: compact tab + benchmark dialog and api endpoint --- selective-vpn-api/app/dns_settings.go | 329 +++++++++++++++++++ selective-vpn-api/app/server.go | 8 + selective-vpn-api/app/types.go | 57 +++- selective-vpn-gui/api_client.py | 154 +++++++++ selective-vpn-gui/dashboard_controller.py | 75 +++++ selective-vpn-gui/dns_benchmark_dialog.py | 376 ++++++++++++++++++++++ selective-vpn-gui/vpn_dashboard_qt.py | 140 +++++--- 7 files changed, 1089 insertions(+), 50 deletions(-) create mode 100644 selective-vpn-gui/dns_benchmark_dialog.py diff --git a/selective-vpn-api/app/dns_settings.go b/selective-vpn-api/app/dns_settings.go index a954695..6cce8ce 100644 --- a/selective-vpn-api/app/dns_settings.go +++ b/selective-vpn-api/app/dns_settings.go @@ -11,6 +11,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" ) @@ -68,6 +69,334 @@ func handleDNSStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, makeDNSStatusResponse(mode)) } +var dnsBenchmarkDefaultDomains = []string{ + "cloudflare.com", + "google.com", + "telegram.org", + "github.com", + "youtube.com", + "twitter.com", +} + +func handleDNSBenchmark(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req DNSBenchmarkRequest + if r.Body != nil { + defer r.Body.Close() + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil && err != io.EOF { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + } + + upstreams := normalizeBenchmarkUpstreams(req.Upstreams) + 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) + return + } + + domains := normalizeBenchmarkDomains(req.Domains) + if len(domains) == 0 { + domains = append(domains, dnsBenchmarkDefaultDomains...) + } + + timeoutMS := req.TimeoutMS + if timeoutMS <= 0 { + timeoutMS = 1800 + } + if timeoutMS < 300 { + timeoutMS = 300 + } + if timeoutMS > 5000 { + timeoutMS = 5000 + } + + attempts := req.Attempts + if attempts <= 0 { + attempts = 1 + } + if attempts > 3 { + attempts = 3 + } + + concurrency := req.Concurrency + if concurrency <= 0 { + concurrency = 6 + } + if concurrency < 1 { + concurrency = 1 + } + if concurrency > 32 { + concurrency = 32 + } + if concurrency > len(upstreams) { + concurrency = len(upstreams) + } + + results := make([]DNSBenchmarkResult, 0, len(upstreams)) + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, concurrency) + timeout := time.Duration(timeoutMS) * time.Millisecond + + for _, upstream := range upstreams { + wg.Add(1) + sem <- struct{}{} + go func(upstream string) { + defer wg.Done() + defer func() { <-sem }() + result := benchmarkDNSUpstream(upstream, domains, timeout, attempts) + mu.Lock() + results = append(results, result) + mu.Unlock() + }(upstream) + } + wg.Wait() + + sort.Slice(results, func(i, j int) bool { + if results[i].Score == results[j].Score { + if results[i].AvgMS == results[j].AvgMS { + if results[i].OK == results[j].OK { + return results[i].Upstream < results[j].Upstream + } + return results[i].OK > results[j].OK + } + return results[i].AvgMS < results[j].AvgMS + } + return results[i].Score > results[j].Score + }) + + resp := DNSBenchmarkResponse{ + Results: results, + DomainsUsed: domains, + TimeoutMS: timeoutMS, + AttemptsPerDomain: attempts, + } + resp.RecommendedDefault = benchmarkTopN(results, 2, upstreams) + resp.RecommendedMeta = benchmarkTopN(results, 2, upstreams) + writeJSON(w, http.StatusOK, resp) +} + +func normalizeBenchmarkUpstreams(in []DNSBenchmarkUpstream) []string { + if len(in) == 0 { + return nil + } + 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 + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + out = append(out, n) + } + return out +} + +func normalizeBenchmarkUpstreamStrings(in []string) []string { + out := make([]string, 0, len(in)) + seen := map[string]struct{}{} + for _, raw := range in { + n := normalizeDNSUpstream(raw, "53") + if n == "" { + continue + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + out = append(out, n) + } + return out +} + +func normalizeBenchmarkDomains(in []string) []string { + if len(in) == 0 { + return nil + } + out := make([]string, 0, len(in)) + seen := map[string]struct{}{} + for _, raw := range in { + d := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".") + if d == "" || strings.HasPrefix(d, "#") { + continue + } + if _, ok := seen[d]; ok { + continue + } + seen[d] = struct{}{} + out = append(out, d) + } + if len(out) > 100 { + out = out[:100] + } + return out +} + +func benchmarkDNSUpstream(upstream string, domains []string, timeout time.Duration, attempts int) DNSBenchmarkResult { + res := DNSBenchmarkResult{Upstream: upstream} + durations := make([]int, 0, len(domains)*attempts) + + for _, host := range domains { + for i := 0; i < attempts; i++ { + start := time.Now() + _, err := dnsLookupAOnce(host, upstream, timeout) + elapsed := int(time.Since(start).Milliseconds()) + if elapsed < 1 { + elapsed = 1 + } + res.Attempts++ + if err != nil { + res.Fail++ + switch classifyDNSError(err) { + case dnsErrorNXDomain: + res.NXDomain++ + case dnsErrorTimeout: + res.Timeout++ + case dnsErrorTemporary: + res.Temporary++ + default: + res.Other++ + } + continue + } + res.OK++ + durations = append(durations, elapsed) + } + } + + if len(durations) > 0 { + sort.Ints(durations) + sum := 0 + for _, d := range durations { + sum += d + } + res.AvgMS = sum / len(durations) + idx := int(float64(len(durations)-1) * 0.95) + if idx < 0 { + idx = 0 + } + res.P95MS = durations[idx] + } + + total := res.Attempts + if total > 0 { + okRate := float64(res.OK) / float64(total) + timeoutRate := float64(res.Timeout) / float64(total) + nxRate := float64(res.NXDomain) / float64(total) + avg := float64(res.AvgMS) + if avg <= 0 { + avg = float64(timeout.Milliseconds()) + } + res.Score = okRate*100.0 - timeoutRate*45.0 - nxRate*12.0 - (avg / 30.0) + } + + timeoutRate := 0.0 + if res.Attempts > 0 { + timeoutRate = float64(res.Timeout) / float64(res.Attempts) + } + switch { + case res.OK == 0 || timeoutRate >= 0.15 || res.AvgMS > 400: + res.Color = "red" + case res.AvgMS < 200 && timeoutRate == 0: + res.Color = "green" + default: + res.Color = "yellow" + } + + return res +} + +func dnsLookupAOnce(host string, upstream string, timeout time.Duration) ([]string, error) { + server, port := splitDNS(upstream) + if server == "" { + return nil, fmt.Errorf("upstream empty") + } + if port == "" { + port = "53" + } + addr := net.JoinHostPort(server, port) + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{} + return d.DialContext(ctx, "udp", addr) + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + records, err := resolver.LookupHost(ctx, host) + cancel() + if err != nil { + return nil, err + } + seen := map[string]struct{}{} + out := make([]string, 0, len(records)) + for _, ip := range records { + if isPrivateIPv4(ip) { + continue + } + if _, ok := seen[ip]; ok { + continue + } + seen[ip] = struct{}{} + out = append(out, ip) + } + if len(out) == 0 { + return nil, fmt.Errorf("no public ips") + } + return out, nil +} + +func benchmarkTopN(results []DNSBenchmarkResult, n int, fallback []string) []string { + out := make([]string, 0, n) + for _, item := range results { + if item.OK <= 0 { + continue + } + out = append(out, item.Upstream) + if len(out) >= n { + return out + } + } + for _, item := range fallback { + if len(out) >= n { + break + } + dup := false + for _, e := range out { + if e == item { + dup = true + break + } + } + if !dup { + out = append(out, item) + } + } + return out +} + // --------------------------------------------------------------------- // EN: `handleDNSModeSet` is an HTTP handler for dns mode set. // RU: `handleDNSModeSet` - HTTP-обработчик для dns mode set. diff --git a/selective-vpn-api/app/server.go b/selective-vpn-api/app/server.go index 14ffff5..b031143 100644 --- a/selective-vpn-api/app/server.go +++ b/selective-vpn-api/app/server.go @@ -93,6 +93,12 @@ func Run() { defer cancel() ensureSeeds() + if err := ensureAppMarksNft(); err != nil { + log.Printf("traffic appmarks nft init warning: %v", err) + } + if err := restoreAppMarksFromState(); err != nil { + log.Printf("traffic appmarks restore warning: %v", err) + } mux := http.NewServeMux() @@ -144,6 +150,7 @@ func Run() { mux.HandleFunc("/api/v1/routes/fix-policy", handleFixPolicyRoute) mux.HandleFunc("/api/v1/traffic/mode", handleTrafficMode) mux.HandleFunc("/api/v1/traffic/mode/test", handleTrafficModeTest) + mux.HandleFunc("/api/v1/traffic/advanced/reset", handleTrafficAdvancedReset) mux.HandleFunc("/api/v1/traffic/interfaces", handleTrafficInterfaces) mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates) // per-app runtime marks (systemd scope / cgroup -> fwmark) @@ -164,6 +171,7 @@ func Run() { mux.HandleFunc("/api/v1/dns-upstreams", handleDNSUpstreams) mux.HandleFunc("/api/v1/dns/status", handleDNSStatus) mux.HandleFunc("/api/v1/dns/mode", handleDNSModeSet) + mux.HandleFunc("/api/v1/dns/benchmark", handleDNSBenchmark) mux.HandleFunc("/api/v1/dns/smartdns-service", handleDNSSmartdnsService) // SmartDNS service diff --git a/selective-vpn-api/app/types.go b/selective-vpn-api/app/types.go index d941100..1c96bc1 100644 --- a/selective-vpn-api/app/types.go +++ b/selective-vpn-api/app/types.go @@ -77,6 +77,43 @@ type DNSModeRequest struct { Mode DNSResolverMode `json:"mode"` } +type DNSBenchmarkUpstream struct { + Addr string `json:"addr"` + Enabled bool `json:"enabled"` +} + +type DNSBenchmarkRequest struct { + Upstreams []DNSBenchmarkUpstream `json:"upstreams"` + Domains []string `json:"domains"` + TimeoutMS int `json:"timeout_ms"` + Attempts int `json:"attempts"` + Concurrency int `json:"concurrency"` +} + +type DNSBenchmarkResult struct { + Upstream string `json:"upstream"` + Attempts int `json:"attempts"` + OK int `json:"ok"` + Fail int `json:"fail"` + NXDomain int `json:"nxdomain"` + Timeout int `json:"timeout"` + Temporary int `json:"temporary"` + Other int `json:"other"` + AvgMS int `json:"avg_ms"` + P95MS int `json:"p95_ms"` + Score float64 `json:"score"` + Color string `json:"color"` +} + +type DNSBenchmarkResponse struct { + Results []DNSBenchmarkResult `json:"results"` + DomainsUsed []string `json:"domains_used"` + TimeoutMS int `json:"timeout_ms"` + AttemptsPerDomain int `json:"attempts_per_domain"` + RecommendedDefault []string `json:"recommended_default"` + RecommendedMeta []string `json:"recommended_meta"` +} + type SmartDNSRuntimeStatusResponse struct { Enabled bool `json:"enabled"` AppliedEnable bool `json:"applied_enabled"` @@ -105,6 +142,7 @@ type TrafficModeState struct { Mode TrafficMode `json:"mode"` PreferredIface string `json:"preferred_iface,omitempty"` AutoLocalBypass bool `json:"auto_local_bypass"` + IngressReplyBypass bool `json:"ingress_reply_bypass"` ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"` ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"` ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"` @@ -118,6 +156,7 @@ type TrafficModeRequest struct { Mode TrafficMode `json:"mode"` PreferredIface *string `json:"preferred_iface,omitempty"` AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"` + IngressReplyBypass *bool `json:"ingress_reply_bypass,omitempty"` ForceVPNSubnets *[]string `json:"force_vpn_subnets,omitempty"` ForceVPNUIDs *[]string `json:"force_vpn_uids,omitempty"` ForceVPNCGroups *[]string `json:"force_vpn_cgroups,omitempty"` @@ -131,7 +170,11 @@ type TrafficModeStatusResponse struct { DesiredMode TrafficMode `json:"desired_mode"` AppliedMode TrafficMode `json:"applied_mode"` PreferredIface string `json:"preferred_iface,omitempty"` + AdvancedActive bool `json:"advanced_active"` AutoLocalBypass bool `json:"auto_local_bypass"` + AutoLocalActive bool `json:"auto_local_active"` + IngressReplyBypass bool `json:"ingress_reply_bypass"` + IngressReplyActive bool `json:"ingress_reply_active"` BypassCandidates int `json:"bypass_candidates"` ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"` ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"` @@ -146,6 +189,8 @@ type TrafficModeStatusResponse struct { IfaceReason string `json:"iface_reason,omitempty"` RuleMark bool `json:"rule_mark"` RuleFull bool `json:"rule_full"` + IngressRulePresent bool `json:"ingress_rule_present"` + IngressNftActive bool `json:"ingress_nft_active"` TableDefault bool `json:"table_default"` ProbeOK bool `json:"probe_ok"` ProbeMessage string `json:"probe_message,omitempty"` @@ -208,7 +253,7 @@ type TrafficAppMarksRequest struct { Unit string `json:"unit,omitempty"` Command string `json:"command,omitempty"` AppKey string `json:"app_key,omitempty"` - TimeoutSec int `json:"timeout_sec,omitempty"` // only for add + TimeoutSec int `json:"timeout_sec,omitempty"` // only for add; 0 = persistent } type TrafficAppMarksResponse struct { @@ -240,7 +285,7 @@ type TrafficAppMarkItemView struct { AppKey string `json:"app_key,omitempty"` AddedAt string `json:"added_at,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` - RemainingSec int `json:"remaining_sec,omitempty"` + RemainingSec int `json:"remaining_sec,omitempty"` // -1 = persistent } type TrafficAppMarksItemsResponse struct { @@ -259,8 +304,8 @@ type TrafficAppProfile struct { Name string `json:"name,omitempty"` AppKey string `json:"app_key,omitempty"` Command string `json:"command,omitempty"` - Target string `json:"target,omitempty"` // vpn|direct - TTLSec int `json:"ttl_sec,omitempty"` + Target string `json:"target,omitempty"` // vpn|direct + TTLSec int `json:"ttl_sec,omitempty"` // 0 = persistent VPNProfile string `json:"vpn_profile,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` @@ -276,8 +321,8 @@ type TrafficAppProfileUpsertRequest struct { Name string `json:"name,omitempty"` AppKey string `json:"app_key,omitempty"` Command string `json:"command,omitempty"` - Target string `json:"target,omitempty"` // vpn|direct - TTLSec int `json:"ttl_sec,omitempty"` + Target string `json:"target,omitempty"` // vpn|direct + TTLSec int `json:"ttl_sec,omitempty"` // 0 = persistent VPNProfile string `json:"vpn_profile,omitempty"` } diff --git a/selective-vpn-gui/api_client.py b/selective-vpn-gui/api_client.py index 02df939..7c8c095 100644 --- a/selective-vpn-gui/api_client.py +++ b/selective-vpn-gui/api_client.py @@ -90,7 +90,11 @@ class TrafficModeStatus: desired_mode: str applied_mode: str preferred_iface: str + advanced_active: bool auto_local_bypass: bool + auto_local_active: bool + ingress_reply_bypass: bool + ingress_reply_active: bool bypass_candidates: int force_vpn_subnets: List[str] force_vpn_uids: List[str] @@ -105,6 +109,8 @@ class TrafficModeStatus: iface_reason: str rule_mark: bool rule_full: bool + ingress_rule_present: bool + ingress_nft_active: bool table_default: bool probe_ok: bool probe_message: str @@ -221,6 +227,38 @@ class DnsUpstreams: meta2: str +@dataclass(frozen=True) +class DNSBenchmarkUpstream: + addr: str + enabled: bool = True + + +@dataclass(frozen=True) +class DNSBenchmarkResult: + upstream: str + attempts: int + ok: int + fail: int + nxdomain: int + timeout: int + temporary: int + other: int + avg_ms: int + p95_ms: int + score: float + color: str + + +@dataclass(frozen=True) +class DNSBenchmarkResponse: + results: List[DNSBenchmarkResult] + domains_used: List[str] + timeout_ms: int + attempts_per_domain: int + recommended_default: List[str] + recommended_meta: List[str] + + @dataclass(frozen=True) class SmartdnsServiceState: state: str @@ -654,7 +692,11 @@ class ApiClient: desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"), applied_mode=str(data.get("applied_mode") or "direct"), preferred_iface=str(data.get("preferred_iface") or ""), + advanced_active=bool(data.get("advanced_active", False)), auto_local_bypass=bool(data.get("auto_local_bypass", True)), + auto_local_active=bool(data.get("auto_local_active", False)), + ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)), + ingress_reply_active=bool(data.get("ingress_reply_active", False)), bypass_candidates=int(data.get("bypass_candidates", 0) or 0), force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()], force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()], @@ -669,6 +711,8 @@ class ApiClient: iface_reason=str(data.get("iface_reason") or ""), rule_mark=bool(data.get("rule_mark", False)), rule_full=bool(data.get("rule_full", False)), + ingress_rule_present=bool(data.get("ingress_rule_present", False)), + ingress_nft_active=bool(data.get("ingress_nft_active", False)), table_default=bool(data.get("table_default", False)), probe_ok=bool(data.get("probe_ok", False)), probe_message=str(data.get("probe_message") or ""), @@ -681,6 +725,7 @@ class ApiClient: mode: str, preferred_iface: Optional[str] = None, auto_local_bypass: Optional[bool] = None, + ingress_reply_bypass: Optional[bool] = None, force_vpn_subnets: Optional[List[str]] = None, force_vpn_uids: Optional[List[str]] = None, force_vpn_cgroups: Optional[List[str]] = None, @@ -696,6 +741,8 @@ class ApiClient: payload["preferred_iface"] = str(preferred_iface).strip() if auto_local_bypass is not None: payload["auto_local_bypass"] = bool(auto_local_bypass) + if ingress_reply_bypass is not None: + payload["ingress_reply_bypass"] = bool(ingress_reply_bypass) if force_vpn_subnets is not None: payload["force_vpn_subnets"] = [str(x) for x in force_vpn_subnets] if force_vpn_uids is not None: @@ -724,7 +771,11 @@ class ApiClient: desired_mode=str(data.get("desired_mode") or data.get("mode") or m), applied_mode=str(data.get("applied_mode") or "direct"), preferred_iface=str(data.get("preferred_iface") or ""), + advanced_active=bool(data.get("advanced_active", False)), auto_local_bypass=bool(data.get("auto_local_bypass", True)), + auto_local_active=bool(data.get("auto_local_active", False)), + ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)), + ingress_reply_active=bool(data.get("ingress_reply_active", False)), bypass_candidates=int(data.get("bypass_candidates", 0) or 0), force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()], force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()], @@ -739,6 +790,8 @@ class ApiClient: iface_reason=str(data.get("iface_reason") or ""), rule_mark=bool(data.get("rule_mark", False)), rule_full=bool(data.get("rule_full", False)), + ingress_rule_present=bool(data.get("ingress_rule_present", False)), + ingress_nft_active=bool(data.get("ingress_nft_active", False)), table_default=bool(data.get("table_default", False)), probe_ok=bool(data.get("probe_ok", False)), probe_message=str(data.get("probe_message") or ""), @@ -756,7 +809,11 @@ class ApiClient: desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"), applied_mode=str(data.get("applied_mode") or "direct"), preferred_iface=str(data.get("preferred_iface") or ""), + advanced_active=bool(data.get("advanced_active", False)), auto_local_bypass=bool(data.get("auto_local_bypass", True)), + auto_local_active=bool(data.get("auto_local_active", False)), + ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)), + ingress_reply_active=bool(data.get("ingress_reply_active", False)), bypass_candidates=int(data.get("bypass_candidates", 0) or 0), force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()], force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()], @@ -771,6 +828,46 @@ class ApiClient: iface_reason=str(data.get("iface_reason") or ""), rule_mark=bool(data.get("rule_mark", False)), rule_full=bool(data.get("rule_full", False)), + ingress_rule_present=bool(data.get("ingress_rule_present", False)), + ingress_nft_active=bool(data.get("ingress_nft_active", False)), + table_default=bool(data.get("table_default", False)), + probe_ok=bool(data.get("probe_ok", False)), + probe_message=str(data.get("probe_message") or ""), + healthy=bool(data.get("healthy", False)), + message=str(data.get("message") or ""), + ) + + def traffic_advanced_reset(self) -> TrafficModeStatus: + data = cast( + Dict[str, Any], + self._json(self._request("POST", "/api/v1/traffic/advanced/reset")) or {}, + ) + return TrafficModeStatus( + mode=str(data.get("mode") or "selective"), + desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"), + applied_mode=str(data.get("applied_mode") or "direct"), + preferred_iface=str(data.get("preferred_iface") or ""), + advanced_active=bool(data.get("advanced_active", False)), + auto_local_bypass=bool(data.get("auto_local_bypass", True)), + auto_local_active=bool(data.get("auto_local_active", False)), + ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)), + ingress_reply_active=bool(data.get("ingress_reply_active", False)), + bypass_candidates=int(data.get("bypass_candidates", 0) or 0), + force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()], + force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()], + force_vpn_cgroups=[str(x) for x in (data.get("force_vpn_cgroups") or []) if str(x).strip()], + force_direct_subnets=[str(x) for x in (data.get("force_direct_subnets") or []) if str(x).strip()], + force_direct_uids=[str(x) for x in (data.get("force_direct_uids") or []) if str(x).strip()], + force_direct_cgroups=[str(x) for x in (data.get("force_direct_cgroups") or []) if str(x).strip()], + overrides_applied=int(data.get("overrides_applied", 0) or 0), + cgroup_resolved_uids=int(data.get("cgroup_resolved_uids", 0) or 0), + cgroup_warning=str(data.get("cgroup_warning") or ""), + active_iface=str(data.get("active_iface") or ""), + iface_reason=str(data.get("iface_reason") or ""), + rule_mark=bool(data.get("rule_mark", False)), + rule_full=bool(data.get("rule_full", False)), + ingress_rule_present=bool(data.get("ingress_rule_present", False)), + ingress_nft_active=bool(data.get("ingress_nft_active", False)), table_default=bool(data.get("table_default", False)), probe_ok=bool(data.get("probe_ok", False)), probe_message=str(data.get("probe_message") or ""), @@ -1088,6 +1185,63 @@ class ApiClient: }, ) + def dns_benchmark( + self, + upstreams: List[DNSBenchmarkUpstream], + domains: List[str], + timeout_ms: int = 1800, + attempts: int = 1, + concurrency: int = 6, + ) -> DNSBenchmarkResponse: + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/dns/benchmark", + json_body={ + "upstreams": [{"addr": u.addr, "enabled": bool(u.enabled)} for u in (upstreams or [])], + "domains": [str(d or "").strip() for d in (domains or []) if str(d or "").strip()], + "timeout_ms": int(timeout_ms), + "attempts": int(attempts), + "concurrency": int(concurrency), + }, + ) + ) + or {}, + ) + raw_results = data.get("results") or [] + if not isinstance(raw_results, list): + raw_results = [] + results: List[DNSBenchmarkResult] = [] + for row in raw_results: + if not isinstance(row, dict): + continue + results.append( + DNSBenchmarkResult( + upstream=str(row.get("upstream") or "").strip(), + attempts=int(row.get("attempts", 0) or 0), + ok=int(row.get("ok", 0) or 0), + fail=int(row.get("fail", 0) or 0), + nxdomain=int(row.get("nxdomain", 0) or 0), + timeout=int(row.get("timeout", 0) or 0), + temporary=int(row.get("temporary", 0) or 0), + other=int(row.get("other", 0) or 0), + avg_ms=int(row.get("avg_ms", 0) or 0), + p95_ms=int(row.get("p95_ms", 0) or 0), + score=float(row.get("score", 0.0) or 0.0), + color=str(row.get("color") or "").strip().lower(), + ) + ) + return DNSBenchmarkResponse( + results=results, + domains_used=[str(d or "").strip() for d in (data.get("domains_used") or []) if str(d or "").strip()], + timeout_ms=int(data.get("timeout_ms", 0) or 0), + attempts_per_domain=int(data.get("attempts_per_domain", 0) or 0), + recommended_default=[str(d or "").strip() for d in (data.get("recommended_default") or []) if str(d or "").strip()], + recommended_meta=[str(d or "").strip() for d in (data.get("recommended_meta") or []) if str(d or "").strip()], + ) + def dns_status_get(self) -> DNSStatus: data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/status")) or {}) return self._parse_dns_status(data) diff --git a/selective-vpn-gui/dashboard_controller.py b/selective-vpn-gui/dashboard_controller.py index e195111..abd2bd6 100644 --- a/selective-vpn-gui/dashboard_controller.py +++ b/selective-vpn-gui/dashboard_controller.py @@ -24,6 +24,8 @@ _NEXT_CHECK_RE = re.compile( from api_client import ( ApiClient, CmdResult, + DNSBenchmarkResponse, + DNSBenchmarkUpstream, DNSStatus, DnsUpstreams, DomainsFile, @@ -128,7 +130,11 @@ class TrafficModeView: desired_mode: str applied_mode: str preferred_iface: str + advanced_active: bool auto_local_bypass: bool + auto_local_active: bool + ingress_reply_bypass: bool + ingress_reply_active: bool bypass_candidates: int force_vpn_subnets: List[str] force_vpn_uids: List[str] @@ -141,6 +147,8 @@ class TrafficModeView: cgroup_warning: str active_iface: str iface_reason: str + ingress_rule_present: bool + ingress_nft_active: bool probe_ok: bool probe_message: str healthy: bool @@ -614,7 +622,11 @@ class DashboardController: desired_mode=(st.desired_mode or st.mode or "selective"), applied_mode=(st.applied_mode or "direct"), preferred_iface=st.preferred_iface or "", + advanced_active=bool(st.advanced_active), auto_local_bypass=bool(st.auto_local_bypass), + auto_local_active=bool(st.auto_local_active), + ingress_reply_bypass=bool(st.ingress_reply_bypass), + ingress_reply_active=bool(st.ingress_reply_active), bypass_candidates=int(st.bypass_candidates), force_vpn_subnets=list(st.force_vpn_subnets or []), force_vpn_uids=list(st.force_vpn_uids or []), @@ -627,6 +639,8 @@ class DashboardController: cgroup_warning=st.cgroup_warning or "", active_iface=st.active_iface or "", iface_reason=st.iface_reason or "", + ingress_rule_present=bool(st.ingress_rule_present), + ingress_nft_active=bool(st.ingress_nft_active), probe_ok=bool(st.probe_ok), probe_message=st.probe_message or "", healthy=bool(st.healthy), @@ -638,6 +652,7 @@ class DashboardController: mode: str, preferred_iface: Optional[str] = None, auto_local_bypass: Optional[bool] = None, + ingress_reply_bypass: Optional[bool] = None, force_vpn_subnets: Optional[List[str]] = None, force_vpn_uids: Optional[List[str]] = None, force_vpn_cgroups: Optional[List[str]] = None, @@ -649,6 +664,7 @@ class DashboardController: mode, preferred_iface, auto_local_bypass, + ingress_reply_bypass, force_vpn_subnets, force_vpn_uids, force_vpn_cgroups, @@ -660,7 +676,11 @@ class DashboardController: desired_mode=(st.desired_mode or st.mode or mode), applied_mode=(st.applied_mode or "direct"), preferred_iface=st.preferred_iface or "", + advanced_active=bool(st.advanced_active), auto_local_bypass=bool(st.auto_local_bypass), + auto_local_active=bool(st.auto_local_active), + ingress_reply_bypass=bool(st.ingress_reply_bypass), + ingress_reply_active=bool(st.ingress_reply_active), bypass_candidates=int(st.bypass_candidates), force_vpn_subnets=list(st.force_vpn_subnets or []), force_vpn_uids=list(st.force_vpn_uids or []), @@ -673,6 +693,8 @@ class DashboardController: cgroup_warning=st.cgroup_warning or "", active_iface=st.active_iface or "", iface_reason=st.iface_reason or "", + ingress_rule_present=bool(st.ingress_rule_present), + ingress_nft_active=bool(st.ingress_nft_active), probe_ok=bool(st.probe_ok), probe_message=st.probe_message or "", healthy=bool(st.healthy), @@ -685,7 +707,11 @@ class DashboardController: desired_mode=(st.desired_mode or st.mode or "selective"), applied_mode=(st.applied_mode or "direct"), preferred_iface=st.preferred_iface or "", + advanced_active=bool(st.advanced_active), auto_local_bypass=bool(st.auto_local_bypass), + auto_local_active=bool(st.auto_local_active), + ingress_reply_bypass=bool(st.ingress_reply_bypass), + ingress_reply_active=bool(st.ingress_reply_active), bypass_candidates=int(st.bypass_candidates), force_vpn_subnets=list(st.force_vpn_subnets or []), force_vpn_uids=list(st.force_vpn_uids or []), @@ -698,6 +724,39 @@ class DashboardController: cgroup_warning=st.cgroup_warning or "", active_iface=st.active_iface or "", iface_reason=st.iface_reason or "", + ingress_rule_present=bool(st.ingress_rule_present), + ingress_nft_active=bool(st.ingress_nft_active), + probe_ok=bool(st.probe_ok), + probe_message=st.probe_message or "", + healthy=bool(st.healthy), + message=st.message or "", + ) + + def traffic_advanced_reset(self) -> TrafficModeView: + st: TrafficModeStatus = self.client.traffic_advanced_reset() + return TrafficModeView( + desired_mode=(st.desired_mode or st.mode or "selective"), + applied_mode=(st.applied_mode or "direct"), + preferred_iface=st.preferred_iface or "", + advanced_active=bool(st.advanced_active), + auto_local_bypass=bool(st.auto_local_bypass), + auto_local_active=bool(st.auto_local_active), + ingress_reply_bypass=bool(st.ingress_reply_bypass), + ingress_reply_active=bool(st.ingress_reply_active), + bypass_candidates=int(st.bypass_candidates), + force_vpn_subnets=list(st.force_vpn_subnets or []), + force_vpn_uids=list(st.force_vpn_uids or []), + force_vpn_cgroups=list(st.force_vpn_cgroups or []), + force_direct_subnets=list(st.force_direct_subnets or []), + force_direct_uids=list(st.force_direct_uids or []), + force_direct_cgroups=list(st.force_direct_cgroups or []), + overrides_applied=int(st.overrides_applied), + cgroup_resolved_uids=int(st.cgroup_resolved_uids), + cgroup_warning=st.cgroup_warning or "", + active_iface=st.active_iface or "", + iface_reason=st.iface_reason or "", + ingress_rule_present=bool(st.ingress_rule_present), + ingress_nft_active=bool(st.ingress_nft_active), probe_ok=bool(st.probe_ok), probe_message=st.probe_message or "", healthy=bool(st.healthy), @@ -811,6 +870,22 @@ class DashboardController: def dns_upstreams_save(self, cfg: DnsUpstreams) -> None: self.client.dns_upstreams_set(cfg) + def dns_benchmark( + self, + upstreams: List[DNSBenchmarkUpstream], + domains: List[str], + timeout_ms: int = 1800, + attempts: int = 1, + concurrency: int = 6, + ) -> DNSBenchmarkResponse: + return self.client.dns_benchmark( + upstreams=upstreams, + domains=domains, + timeout_ms=timeout_ms, + attempts=attempts, + concurrency=concurrency, + ) + def dns_status_view(self) -> DNSStatus: return self.client.dns_status_get() diff --git a/selective-vpn-gui/dns_benchmark_dialog.py b/selective-vpn-gui/dns_benchmark_dialog.py new file mode 100644 index 0000000..2586bcc --- /dev/null +++ b/selective-vpn-gui/dns_benchmark_dialog.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +from typing import Callable, List + +from PySide6.QtCore import Qt, QSettings +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QPlainTextEdit, + QSpinBox, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from api_client import DNSBenchmarkUpstream, DnsUpstreams +from dashboard_controller import DashboardController + + +DEFAULT_UPSTREAMS = [ + "94.140.14.14", + "94.140.14.15", + "94.140.15.15", + "94.140.15.16", + "1.1.1.1", + "1.0.0.1", + "8.8.8.8", + "8.8.4.4", + "208.67.222.222", + "208.67.220.220", + "76.76.2.0", + "76.76.10.0", +] + +DEFAULT_DOMAINS = [ + "cloudflare.com", + "google.com", + "github.com", + "telegram.org", + "youtube.com", + "twitter.com", +] + + +class DNSBenchmarkDialog(QDialog): + def __init__( + self, + ctrl: DashboardController, + settings: QSettings, + refresh_cb: Callable[[], None] | None = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + 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) + + 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." + ) + 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.horizontalHeader().setStretchLastSection(True) + self.tbl_sources.setSelectionBehavior(QTableWidget.SelectRows) + self.tbl_sources.setSelectionMode(QTableWidget.SingleSelection) + self.tbl_sources.itemChanged.connect(self._on_sources_changed) + root.addWidget(self.tbl_sources, stretch=2) + + row_btns = QHBoxLayout() + self.btn_add = QPushButton("Add DNS") + self.btn_add.clicked.connect(self.on_add_dns) + row_btns.addWidget(self.btn_add) + self.btn_remove = QPushButton("Remove selected") + self.btn_remove.clicked.connect(self.on_remove_selected) + row_btns.addWidget(self.btn_remove) + self.btn_reset = QPushButton("Reset defaults") + self.btn_reset.clicked.connect(self.on_reset_defaults) + row_btns.addWidget(self.btn_reset) + row_btns.addStretch(1) + root.addLayout(row_btns) + + self.txt_domains = QPlainTextEdit() + self.txt_domains.setPlaceholderText( + "Test domains (one per line)\n" + "Example:\n" + "cloudflare.com\n" + "google.com\n" + "telegram.org" + ) + self.txt_domains.textChanged.connect(self._on_domains_changed) + self.txt_domains.setFixedHeight(120) + root.addWidget(self.txt_domains) + + opts = QHBoxLayout() + self.spin_timeout = QSpinBox() + self.spin_timeout.setRange(300, 5000) + self.spin_timeout.setValue(1800) + self.spin_timeout.setSuffix(" ms") + opts.addWidget(QLabel("Timeout:")) + opts.addWidget(self.spin_timeout) + + self.spin_attempts = QSpinBox() + self.spin_attempts.setRange(1, 3) + self.spin_attempts.setValue(1) + opts.addWidget(QLabel("Attempts/domain:")) + opts.addWidget(self.spin_attempts) + + self.spin_concurrency = QSpinBox() + self.spin_concurrency.setRange(1, 32) + self.spin_concurrency.setValue(6) + opts.addWidget(QLabel("Parallel DNS checks:")) + opts.addWidget(self.spin_concurrency) + + self.btn_run = QPushButton("Run benchmark") + self.btn_run.clicked.connect(self.on_run_benchmark) + opts.addWidget(self.btn_run) + opts.addStretch(1) + root.addLayout(opts) + + self.lbl_summary = QLabel("No benchmark yet") + self.lbl_summary.setStyleSheet("color: gray;") + root.addWidget(self.lbl_summary) + + self.tbl_results = QTableWidget(0, 7) + self.tbl_results.setHorizontalHeaderLabels( + ["DNS", "OK/Fail", "Avg/P95", "Timeout", "NX", "Score", "Status"] + ) + self.tbl_results.horizontalHeader().setStretchLastSection(True) + self.tbl_results.setEditTriggers(QTableWidget.NoEditTriggers) + self.tbl_results.setSelectionBehavior(QTableWidget.SelectRows) + 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) + self.btn_close = QPushButton("Close") + self.btn_close.clicked.connect(self.accept) + apply_row.addWidget(self.btn_close) + root.addLayout(apply_row) + + self._load_sources() + self._load_domains() + + def _safe(self, fn, title: str) -> None: + try: + fn() + except Exception as e: + 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 = [] + if not rows: + rows = [(True, item) for item in DEFAULT_UPSTREAMS] + + self.tbl_sources.setRowCount(0) + for enabled, addr in rows: + self._append_source_row(enabled, addr) + + 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.setPlainText(raw) + + def _save_settings(self) -> None: + items = [] + for i in range(self.tbl_sources.rowCount()): + ck = self.tbl_sources.item(i, 0) + addr = self.tbl_sources.item(i, 1) + if not ck or not addr: + continue + val = str(addr.text() or "").strip() + if not val: + continue + items.append({"enabled": ck.checkState() == Qt.Checked, "addr": val}) + self.settings.setValue("dns_benchmark/upstreams", json.dumps(items, ensure_ascii=True)) + self.settings.setValue("dns_benchmark/domains", self.txt_domains.toPlainText().strip()) + + def _on_sources_changed(self, _item: QTableWidgetItem) -> None: + self._save_settings() + + def _on_domains_changed(self) -> None: + self._save_settings() + + def _append_source_row(self, enabled: bool, addr: str) -> None: + row = self.tbl_sources.rowCount() + self.tbl_sources.insertRow(row) + ck = QTableWidgetItem("") + ck.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable) + ck.setCheckState(Qt.Checked if enabled else Qt.Unchecked) + self.tbl_sources.setItem(row, 0, ck) + it = QTableWidgetItem(addr) + it.setFlags(it.flags() | Qt.ItemIsEditable) + self.tbl_sources.setItem(row, 1, it) + + def _source_payload(self) -> List[DNSBenchmarkUpstream]: + out: List[DNSBenchmarkUpstream] = [] + for i in range(self.tbl_sources.rowCount()): + ck = self.tbl_sources.item(i, 0) + addr = self.tbl_sources.item(i, 1) + if not ck or not addr: + continue + val = str(addr.text() or "").strip() + if not val: + continue + out.append(DNSBenchmarkUpstream(addr=val, enabled=(ck.checkState() == Qt.Checked))) + return out + + def _domains_payload(self) -> List[str]: + out: List[str] = [] + seen = set() + for ln in self.txt_domains.toPlainText().splitlines(): + d = str(ln or "").strip().lower().rstrip(".") + if not d or d.startswith("#") or d in seen: + continue + seen.add(d) + out.append(d) + return out + + def on_add_dns(self) -> None: + self._append_source_row(True, "") + self._save_settings() + + def on_remove_selected(self) -> None: + row = self.tbl_sources.currentRow() + if row >= 0: + self.tbl_sources.removeRow(row) + self._save_settings() + + def on_reset_defaults(self) -> None: + self.tbl_sources.setRowCount(0) + for item in DEFAULT_UPSTREAMS: + self._append_source_row(True, item) + self._save_settings() + + def on_run_benchmark(self) -> None: + def work() -> None: + self._save_settings() + payload = self._source_payload() + domains = self._domains_payload() + resp = self.ctrl.dns_benchmark( + upstreams=payload, + domains=domains, + timeout_ms=int(self.spin_timeout.value()), + 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() + + self._safe(work, "DNS benchmark error") + + def _render_results(self, resp) -> None: + self.tbl_results.setRowCount(0) + ok_total = 0 + fail_total = 0 + timeout_total = 0 + for row_data in (resp.results or []): + ok_total += int(row_data.ok or 0) + fail_total += int(row_data.fail or 0) + timeout_total += int(row_data.timeout or 0) + row = self.tbl_results.rowCount() + self.tbl_results.insertRow(row) + self.tbl_results.setItem(row, 0, QTableWidgetItem(row_data.upstream)) + self.tbl_results.setItem(row, 1, QTableWidgetItem(f"{row_data.ok}/{row_data.fail}")) + self.tbl_results.setItem(row, 2, QTableWidgetItem(f"{row_data.avg_ms} / {row_data.p95_ms} ms")) + self.tbl_results.setItem(row, 3, QTableWidgetItem(str(row_data.timeout))) + self.tbl_results.setItem(row, 4, QTableWidgetItem(str(row_data.nxdomain))) + self.tbl_results.setItem(row, 5, QTableWidgetItem(f"{row_data.score:.1f}")) + status = row_data.color or "unknown" + st_item = QTableWidgetItem(status) + low = status.lower() + if low == "green": + st_item.setForeground(QColor("green")) + elif low in ("yellow", "orange"): + st_item.setForeground(QColor("#b58900")) + else: + 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}" + ) + self.lbl_summary.setStyleSheet("color: gray;") + + avg_values = [int(r.avg_ms or 0) for r in (resp.results or []) if int(r.ok or 0) > 0 and int(r.avg_ms or 0) > 0] + avg_all = int(sum(avg_values) / len(avg_values)) if avg_values else 0 + self.settings.setValue("dns_benchmark/last_avg_ms", avg_all) + 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 4fef265..54ecdf9 100755 --- a/selective-vpn-gui/vpn_dashboard_qt.py +++ b/selective-vpn-gui/vpn_dashboard_qt.py @@ -36,6 +36,7 @@ from PySide6.QtWidgets import ( from api_client import ApiClient, DnsUpstreams from dashboard_controller import DashboardController, TraceMode +from dns_benchmark_dialog import DNSBenchmarkDialog from traffic_mode_dialog import TrafficModeDialog _NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s") @@ -420,31 +421,27 @@ RU: Агрессивный режим дополнительно дергает tip.setStyleSheet("color: gray;") main_layout.addWidget(tip) - ups_group = QGroupBox("Upstreams (auto-save)") - ups_group.setToolTip("""EN: DNS upstreams for direct resolver mode (and non-wildcard lists in hybrid mode). -RU: DNS апстримы для direct-резолвера (и для не-wildcard списков в hybrid режиме).""") - ups_form = QFormLayout(ups_group) - self.ent_def1 = QLineEdit() - self.ent_def1.setToolTip("""EN: Upstream default1. You can set an IP (port 53 is assumed). -RU: Апстрим default1. Можно указать IP (порт 53 по умолчанию).""") - self.ent_def2 = QLineEdit() - self.ent_def2.setToolTip("""EN: Upstream default2. You can set an IP (port 53 is assumed). -RU: Апстрим default2. Можно указать IP (порт 53 по умолчанию).""") - self.ent_meta1 = QLineEdit() - self.ent_meta1.setToolTip("""EN: Upstream meta1. You can set an IP (port 53 is assumed). -RU: Апстрим meta1. Можно указать IP (порт 53 по умолчанию).""") - self.ent_meta2 = QLineEdit() - self.ent_meta2.setToolTip("""EN: Upstream meta2. You can set an IP (port 53 is assumed). -RU: Апстрим meta2. Можно указать IP (порт 53 по умолчанию).""") - self.ent_def1.textEdited.connect(self._schedule_dns_autosave) - self.ent_def2.textEdited.connect(self._schedule_dns_autosave) - self.ent_meta1.textEdited.connect(self._schedule_dns_autosave) - self.ent_meta2.textEdited.connect(self._schedule_dns_autosave) - ups_form.addRow("default1", self.ent_def1) - ups_form.addRow("default2", self.ent_def2) - ups_form.addRow("meta1", self.ent_meta1) - ups_form.addRow("meta2", self.ent_meta2) - main_layout.addWidget(ups_group) + resolver_group = QGroupBox("Resolver DNS") + resolver_group.setToolTip("""EN: Compact resolver DNS status. Open benchmark to test/apply upstreams. +RU: Компактный статус DNS резолвера. Открой benchmark для проверки/применения апстримов.""") + resolver_layout = QVBoxLayout(resolver_group) + + row = QHBoxLayout() + self.btn_dns_benchmark = QPushButton("Open DNS benchmark") + self.btn_dns_benchmark.clicked.connect(self.on_open_dns_benchmark) + row.addWidget(self.btn_dns_benchmark) + row.addStretch(1) + resolver_layout.addLayout(row) + + self.lbl_dns_resolver_upstreams = QLabel("Resolver upstreams: default[—, —] meta[—, —]") + self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;") + resolver_layout.addWidget(self.lbl_dns_resolver_upstreams) + + self.lbl_dns_resolver_health = QLabel("Resolver health: —") + self.lbl_dns_resolver_health.setStyleSheet("color: gray;") + resolver_layout.addWidget(self.lbl_dns_resolver_health) + + main_layout.addWidget(resolver_group) smart_group = QGroupBox("SmartDNS") smart_group.setToolTip("""EN: SmartDNS is used for wildcard domains in hybrid mode. @@ -732,17 +729,58 @@ 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}]" + ) + self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;") + + avg_ms = self._ui_settings.value("dns_benchmark/last_avg_ms", None) + ok = self._ui_settings.value("dns_benchmark/last_ok", None) + fail = self._ui_settings.value("dns_benchmark/last_fail", None) + timeout = self._ui_settings.value("dns_benchmark/last_timeout", None) + if avg_ms is None or ok is None or fail is None: + self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet") + self.lbl_dns_resolver_health.setStyleSheet("color: gray;") + return + try: + avg = int(avg_ms) + ok_i = int(ok) + fail_i = int(fail) + timeout_i = int(timeout or 0) + except Exception: + self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet") + self.lbl_dns_resolver_health.setStyleSheet("color: gray;") + return + color = "green" if avg < 200 else ("#b58900" if avg <= 400 else "red") + if timeout_i > 0 and color != "red": + color = "#b58900" + self.lbl_dns_resolver_health.setText( + f"Resolver health: avg={avg}ms ok={ok_i} fail={fail_i} timeout={timeout_i}" + ) + self.lbl_dns_resolver_health.setStyleSheet(f"color: {color};") + def _set_traffic_mode_state( self, desired_mode: str, applied_mode: str, preferred_iface: str, + advanced_active: bool, auto_local_bypass: bool, + auto_local_active: bool, + ingress_reply_bypass: bool, + ingress_reply_active: bool, bypass_candidates: int, overrides_applied: int, cgroup_resolved_uids: int, cgroup_warning: str, healthy: bool, + ingress_rule_present: bool, + ingress_nft_active: bool, probe_ok: bool, probe_message: str, active_iface: str, @@ -763,9 +801,17 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и diag_parts = [] diag_parts.append(f"preferred={preferred_iface or 'auto'}") diag_parts.append( - f"auto_local_bypass={'on' if auto_local_bypass else 'off'}" + f"advanced={'on' if advanced_active else 'off'}" ) - if bypass_candidates > 0: + diag_parts.append( + f"auto_local={'on' if auto_local_bypass else 'off'}" + f"({'active' if auto_local_active else 'saved'})" + ) + diag_parts.append( + f"ingress_reply={'on' if ingress_reply_bypass else 'off'}" + f"({'active' if ingress_reply_active else 'saved'})" + ) + if auto_local_active and bypass_candidates > 0: diag_parts.append(f"bypass_routes={bypass_candidates}") diag_parts.append(f"overrides={overrides_applied}") if cgroup_resolved_uids > 0: @@ -776,6 +822,10 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и diag_parts.append(f"iface={active_iface}") if iface_reason: diag_parts.append(f"source={iface_reason}") + diag_parts.append( + f"ingress_diag=rule:{'ok' if ingress_rule_present else 'off'}" + f"/nft:{'ok' if ingress_nft_active else 'off'}" + ) diag_parts.append(f"probe={'ok' if probe_ok else 'fail'}") if probe_message: diag_parts.append(probe_message) @@ -998,12 +1048,18 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и t.desired_mode, t.applied_mode, t.preferred_iface, + bool(t.advanced_active), bool(t.auto_local_bypass), + bool(t.auto_local_active), + bool(t.ingress_reply_bypass), + bool(t.ingress_reply_active), int(t.bypass_candidates), int(t.overrides_applied), int(t.cgroup_resolved_uids), t.cgroup_warning, bool(t.healthy), + bool(t.ingress_rule_present), + bool(t.ingress_nft_active), bool(t.probe_ok), t.probe_message, t.active_iface, @@ -1017,10 +1073,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и self._dns_ui_refresh = True try: ups = self.ctrl.dns_upstreams_view() - self.ent_def1.setText(ups.default1 or "") - self.ent_def2.setText(ups.default2 or "") - self.ent_meta1.setText(ups.meta1 or "") - self.ent_meta2.setText(ups.meta2 or "") + self._set_dns_resolver_summary(ups) st = self.ctrl.dns_status_view() self.ent_smartdns_addr.setText(st.smartdns_addr or "") @@ -1037,12 +1090,6 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и self.chk_dns_via_smartdns.setChecked(hybrid_enabled) self.chk_dns_via_smartdns.blockSignals(False) - # In direct + hybrid modes upstreams stay editable. - self.ent_def1.setEnabled(True) - self.ent_def2.setEnabled(True) - self.ent_meta1.setEnabled(True) - self.ent_meta2.setEnabled(True) - unit_state = (st.unit_state or "unknown").strip().lower() unit_active = unit_state == "active" self.chk_dns_unit_relay.blockSignals(True) @@ -1386,13 +1433,6 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и def work(): if self._dns_ui_refresh: return - ups = DnsUpstreams( - default1=self.ent_def1.text().strip(), - default2=self.ent_def2.text().strip(), - meta1=self.ent_meta1.text().strip(), - meta2=self.ent_meta2.text().strip(), - ) - self.ctrl.dns_upstreams_save(ups) self.ctrl.dns_mode_set( self.chk_dns_via_smartdns.isChecked(), self.ent_smartdns_addr.text().strip(), @@ -1400,6 +1440,18 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и self.ctrl.log_gui("DNS settings autosaved") self._safe(work, title="DNS save error") + def on_open_dns_benchmark(self) -> None: + def work(): + dlg = DNSBenchmarkDialog( + self.ctrl, + settings=self._ui_settings, + refresh_cb=self.refresh_dns_tab, + parent=self, + ) + dlg.exec() + self.refresh_dns_tab() + self._safe(work, title="DNS benchmark error") + def on_dns_mode_toggle(self) -> None: def work(): via = self.chk_dns_via_smartdns.isChecked()