From 50518a641d16708d4ca4ace34a603c021514571f Mon Sep 17 00:00:00 2001 From: beckline Date: Tue, 24 Feb 2026 00:17:46 +0300 Subject: [PATCH] Harden resolver and expand traffic runtime controls --- .gitignore | 1 + README.md | 8 + selective-vpn-api/app/dns_settings.go | 10 +- selective-vpn-api/app/domains_handlers.go | 64 ++- selective-vpn-api/app/resolver.go | 284 ++++++++-- selective-vpn-api/app/routes_cache.go | 17 + selective-vpn-api/app/routes_handlers.go | 54 ++ selective-vpn-api/app/routes_update.go | 34 +- selective-vpn-api/app/traffic_app_profiles.go | 90 +++- selective-vpn-api/app/traffic_appkey.go | 74 ++- selective-vpn-api/app/traffic_appkey_test.go | 135 +++++ selective-vpn-api/app/traffic_appmarks.go | 496 ++++++++++++++++-- selective-vpn-api/app/traffic_mode.go | 320 ++++++++++- selective-vpn-gui/api_client.py | 46 +- selective-vpn-gui/dashboard_controller.py | 55 +- selective-vpn-gui/svpn_run_profile.py | 113 +++- selective-vpn-gui/traffic_mode_dialog.py | 422 +++++++++++++-- selective-vpn-gui/vpn_dashboard_qt.py | 6 +- 18 files changed, 2048 insertions(+), 181 deletions(-) create mode 100644 selective-vpn-api/app/traffic_appkey_test.go diff --git a/.gitignore b/.gitignore index 549922e..128cc26 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ selective-vpn-gui/main.go *.bak.* *.tmp selective-vpn-api/works +selective-vpn-api/_backups/ # Local archive / old copies (kept out of repo root) _legacy/ diff --git a/README.md b/README.md index 22de8e6..5d12185 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,11 @@ Requirements (high level): - Linux with `systemd`, `nftables`, `iproute2`, cgroup v2. - Python 3 + PySide6 + `requests` (GUI). - Root privileges for routing/nftables changes (run API as a privileged service). + +Quick traffic checklist (production-safe): +- Start from `Selective` mode for mixed host/server workloads. +- For `Full tunnel`, open **Advanced bypass** in Traffic basics and usually enable: + - `Auto-local bypass` (LAN/container reachability), + - `Ingress-reply bypass` (keep inbound/public services reachable). +- Verify mode health is `OK` and ingress diagnostics are active when ingress bypass is enabled. +- If something breaks, use **Reset bypass** (advanced bypass dialog) or temporarily switch back to `Selective`. diff --git a/selective-vpn-api/app/dns_settings.go b/selective-vpn-api/app/dns_settings.go index 6430252..1239ded 100644 --- a/selective-vpn-api/app/dns_settings.go +++ b/selective-vpn-api/app/dns_settings.go @@ -836,7 +836,15 @@ func runSmartdnsPrewarm(limit, workers, timeoutMS int, aggressiveSubs bool) cmdR ) } if len(domains) > loggedHosts { - appendTraceLineTo(smartdnsLogPath, "smartdns", fmt.Sprintf("prewarm add: +%d domains omitted", len(domains)-loggedHosts)) + appendTraceLineTo( + smartdnsLogPath, + "smartdns", + fmt.Sprintf( + "prewarm add: trace truncated, omitted=%d hosts (full wildcard map: %s)", + len(domains)-loggedHosts, + lastIPsMapDyn, + ), + ) } msg := fmt.Sprintf( diff --git a/selective-vpn-api/app/domains_handlers.go b/selective-vpn-api/app/domains_handlers.go index c6f87e0..8448777 100644 --- a/selective-vpn-api/app/domains_handlers.go +++ b/selective-vpn-api/app/domains_handlers.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "sort" "strings" ) @@ -42,9 +43,24 @@ func handleDomainsTable(w http.ResponseWriter, r *http.Request) { return } - stdout, _, _, err := runCommand("ipset", "list", "agvpn4") lines := []string{} - if err == nil { + for _, setName := range []string{"agvpn4", "agvpn_dyn4"} { + stdout, _, code, _ := runCommand("nft", "list", "set", "inet", "agvpn", setName) + if code == 0 { + for _, l := range strings.Split(stdout, "\n") { + l = strings.TrimRight(l, "\r") + if l != "" { + lines = append(lines, l) + } + } + continue + } + + // Backward-compatible fallback for legacy hosts that still have ipset. + stdout, _, code, _ = runCommand("ipset", "list", setName) + if code != 0 { + continue + } for _, l := range strings.Split(stdout, "\n") { l = strings.TrimRight(l, "\r") if l != "" { @@ -59,7 +75,7 @@ func handleDomainsTable(w http.ResponseWriter, r *http.Request) { // domains file // --------------------------------------------------------------------- -// GET /api/v1/domains/file?name=bases|meta|subs|static|smartdns|last-ips-map|last-ips-map-direct|last-ips-map-wildcard +// GET /api/v1/domains/file?name=bases|meta|subs|static|smartdns|last-ips-map|last-ips-map-direct|last-ips-map-wildcard|wildcard-observed-hosts // POST /api/v1/domains/file { "name": "...", "content": "..." } func handleDomainsFile(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -73,6 +89,13 @@ func handleDomainsFile(w http.ResponseWriter, r *http.Request) { }) return } + if name == "wildcard-observed-hosts" { + writeJSON(w, http.StatusOK, map[string]string{ + "content": readWildcardObservedHostsContent(), + "source": "derived", + }) + return + } path, ok := domainFiles[name] if !ok { http.Error(w, "unknown file name", http.StatusBadRequest) @@ -126,7 +149,7 @@ func handleDomainsFile(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) return } - if body.Name == "last-ips-map-direct" || body.Name == "last-ips-map-wildcard" { + if body.Name == "last-ips-map-direct" || body.Name == "last-ips-map-wildcard" || body.Name == "wildcard-observed-hosts" { http.Error(w, "read-only file name", http.StatusBadRequest) return } @@ -146,6 +169,39 @@ func handleDomainsFile(w http.ResponseWriter, r *http.Request) { } } +func readWildcardObservedHostsContent() string { + data, err := os.ReadFile(lastIPsMapDyn) + if err != nil { + return "" + } + seen := make(map[string]struct{}) + out := make([]string, 0, 256) + for _, ln := range strings.Split(string(data), "\n") { + ln = strings.TrimSpace(ln) + if ln == "" || strings.HasPrefix(ln, "#") { + continue + } + fields := strings.Fields(ln) + if len(fields) < 2 { + continue + } + host := strings.TrimSpace(fields[1]) + if host == "" || strings.HasPrefix(host, "[") { + continue + } + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + out = append(out, host) + } + sort.Strings(out) + if len(out) == 0 { + return "" + } + return strings.Join(out, "\n") + "\n" +} + // --------------------------------------------------------------------- // smartdns wildcards // --------------------------------------------------------------------- diff --git a/selective-vpn-api/app/resolver.go b/selective-vpn-api/app/resolver.go index b01f20a..9aead3b 100644 --- a/selective-vpn-api/app/resolver.go +++ b/selective-vpn-api/app/resolver.go @@ -265,6 +265,23 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul domainCache := loadDomainCacheState(opts.CachePath, logf) ptrCache := loadJSONMap(opts.PtrCachePath) now := int(time.Now().Unix()) + negTTLNX := envInt("RESOLVE_NEGATIVE_TTL_NX", 6*3600) + negTTLTimeout := envInt("RESOLVE_NEGATIVE_TTL_TIMEOUT", 15*60) + negTTLTemporary := envInt("RESOLVE_NEGATIVE_TTL_TEMPORARY", 10*60) + negTTLOther := envInt("RESOLVE_NEGATIVE_TTL_OTHER", 10*60) + clampTTL := func(v int) int { + if v < 0 { + return 0 + } + if v > 24*3600 { + return 24 * 3600 + } + return v + } + negTTLNX = clampTTL(negTTLNX) + negTTLTimeout = clampTTL(negTTLTimeout) + negTTLTemporary = clampTTL(negTTLTemporary) + negTTLOther = clampTTL(negTTLOther) cacheSourceForHost := func(host string) domainCacheSource { switch cfg.Mode { @@ -284,6 +301,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul start := time.Now() fresh := map[string][]string{} + cacheNegativeHits := 0 var toResolve []string for _, d := range domains { source := cacheSourceForHost(d) @@ -294,6 +312,13 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul } continue } + if kind, age, ok := domainCache.getNegative(d, source, now, negTTLNX, negTTLTimeout, negTTLTemporary, negTTLOther); ok { + cacheNegativeHits++ + if logf != nil { + logf("cache neg hit[%s/%s age=%ds]: %s", source, kind, age, d) + } + continue + } toResolve = append(toResolve, d) } @@ -303,7 +328,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul } if logf != nil { - logf("resolve: domains=%d cache_hits=%d to_resolve=%d", len(domains), len(fresh), len(toResolve)) + logf("resolve: domains=%d cache_hits=%d cache_neg_hits=%d to_resolve=%d", len(domains), len(fresh), cacheNegativeHits, len(toResolve)) } dnsStats := dnsMetrics{} @@ -349,8 +374,16 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul if logf != nil { logf("%s -> %v", r.host, r.ips) } - } else if logf != nil { - logf("%s: no IPs", r.host) + } else { + if hostErrors > 0 { + source := cacheSourceForHost(r.host) + if kind, ok := classifyHostErrorKind(r.stats); ok { + domainCache.setError(r.host, source, kind, now) + } + } + if logf != nil { + logf("%s: no IPs", r.host) + } } } } @@ -443,9 +476,10 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul if logf != nil { dnsErrors := dnsStats.totalErrors() logf( - "resolve summary: domains=%d cache_hits=%d resolved_now=%d unresolved=%d static_entries=%d static_skipped=%d unique_ips=%d direct_ips=%d wildcard_ips=%d ptr_lookups=%d ptr_errors=%d dns_attempts=%d dns_ok=%d dns_nxdomain=%d dns_timeout=%d dns_temporary=%d dns_other=%d dns_errors=%d duration_ms=%d", + "resolve summary: domains=%d cache_hits=%d cache_neg_hits=%d resolved_now=%d unresolved=%d static_entries=%d static_skipped=%d unique_ips=%d direct_ips=%d wildcard_ips=%d ptr_lookups=%d ptr_errors=%d dns_attempts=%d dns_ok=%d dns_nxdomain=%d dns_timeout=%d dns_temporary=%d dns_other=%d dns_errors=%d duration_ms=%d", len(domains), len(fresh), + cacheNegativeHits, len(resolved)-len(fresh), len(domains)-len(resolved), len(staticEntries), @@ -487,17 +521,45 @@ func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards w if useMeta { dnsList = cfg.Meta } + primaryViaSmartDNS := false switch cfg.Mode { case DNSModeSmartDNS: if cfg.SmartDNS != "" { dnsList = []string{cfg.SmartDNS} + primaryViaSmartDNS = true } case DNSModeHybridWildcard: if cfg.SmartDNS != "" && wildcards.match(host) { dnsList = []string{cfg.SmartDNS} + primaryViaSmartDNS = true } } ips, stats := digA(host, dnsList, timeout, logf) + if len(ips) == 0 && + !primaryViaSmartDNS && + cfg.SmartDNS != "" && + smartDNSFallbackForTimeoutEnabled() && + shouldFallbackToSmartDNS(stats) { + if logf != nil { + logf( + "dns fallback %s: trying smartdns=%s after errors nxdomain=%d timeout=%d temporary=%d other=%d", + host, + cfg.SmartDNS, + stats.NXDomain, + stats.Timeout, + stats.Temporary, + stats.Other, + ) + } + fallbackIPs, fallbackStats := digA(host, []string{cfg.SmartDNS}, timeout, logf) + stats.merge(fallbackStats) + if len(fallbackIPs) > 0 { + ips = fallbackIPs + if logf != nil { + logf("dns fallback %s: resolved via smartdns (%d ips)", host, len(fallbackIPs)) + } + } + } out := []string{} seen := map[string]struct{}{} for _, ip := range ips { @@ -512,6 +574,52 @@ func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards w return out, stats } +// smartDNSFallbackForTimeoutEnabled controls direct->SmartDNS fallback behavior. +// Default is disabled to avoid overloading SmartDNS on large unresolved batches. +// Set RESOLVE_SMARTDNS_TIMEOUT_FALLBACK=1 to enable. +func smartDNSFallbackForTimeoutEnabled() bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_SMARTDNS_TIMEOUT_FALLBACK"))) + switch v { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return false + } +} + +// Fallback is useful only for transport-like errors. If we already got NXDOMAIN, +// SmartDNS fallback is unlikely to change result and only adds latency/noise. +func shouldFallbackToSmartDNS(stats dnsMetrics) bool { + if stats.OK > 0 { + return false + } + if stats.NXDomain > 0 { + return false + } + if stats.Timeout > 0 || stats.Temporary > 0 { + return true + } + return stats.Other > 0 +} + +func classifyHostErrorKind(stats dnsMetrics) (dnsErrorKind, bool) { + if stats.Timeout > 0 { + return dnsErrorTimeout, true + } + if stats.Temporary > 0 { + return dnsErrorTemporary, true + } + if stats.Other > 0 { + return dnsErrorOther, true + } + if stats.NXDomain > 0 { + return dnsErrorNXDomain, true + } + return "", false +} + // --------------------------------------------------------------------- // EN: `digA` contains core logic for dig a. // RU: `digA` - содержит основную логику для dig a. @@ -742,8 +850,10 @@ const ( ) type domainCacheEntry struct { - IPs []string `json:"ips"` - LastResolved int `json:"last_resolved"` + IPs []string `json:"ips,omitempty"` + LastResolved int `json:"last_resolved,omitempty"` + LastErrorKind string `json:"last_error_kind,omitempty"` + LastErrorAt int `json:"last_error_at,omitempty"` } type domainCacheRecord struct { @@ -758,7 +868,7 @@ type domainCacheState struct { func newDomainCacheState() domainCacheState { return domainCacheState{ - Version: 2, + Version: 3, Domains: map[string]domainCacheRecord{}, } } @@ -781,6 +891,41 @@ func normalizeCacheIPs(raw []string) []string { return out } +func normalizeCacheErrorKind(raw string) (dnsErrorKind, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case string(dnsErrorNXDomain): + return dnsErrorNXDomain, true + case string(dnsErrorTimeout): + return dnsErrorTimeout, true + case string(dnsErrorTemporary): + return dnsErrorTemporary, true + case string(dnsErrorOther): + return dnsErrorOther, true + default: + return "", false + } +} + +func normalizeDomainCacheEntry(in *domainCacheEntry) *domainCacheEntry { + if in == nil { + return nil + } + out := &domainCacheEntry{} + ips := normalizeCacheIPs(in.IPs) + if len(ips) > 0 && in.LastResolved > 0 { + out.IPs = ips + out.LastResolved = in.LastResolved + } + if kind, ok := normalizeCacheErrorKind(in.LastErrorKind); ok && in.LastErrorAt > 0 { + out.LastErrorKind = string(kind) + out.LastErrorAt = in.LastErrorAt + } + if out.LastResolved <= 0 && out.LastErrorAt <= 0 { + return nil + } + return out +} + func parseAnyStringSlice(raw any) []string { switch v := raw.(type) { case []string: @@ -842,7 +987,7 @@ func loadDomainCacheState(path string, logf func(string, ...any)) domainCacheSta var st domainCacheState if err := json.Unmarshal(data, &st); err == nil && st.Domains != nil { if st.Version <= 0 { - st.Version = 2 + st.Version = 3 } normalized := newDomainCacheState() for host, rec := range st.Domains { @@ -851,18 +996,8 @@ func loadDomainCacheState(path string, logf func(string, ...any)) domainCacheSta continue } nrec := domainCacheRecord{} - if rec.Direct != nil { - ips := normalizeCacheIPs(rec.Direct.IPs) - if len(ips) > 0 && rec.Direct.LastResolved > 0 { - nrec.Direct = &domainCacheEntry{IPs: ips, LastResolved: rec.Direct.LastResolved} - } - } - if rec.Wildcard != nil { - ips := normalizeCacheIPs(rec.Wildcard.IPs) - if len(ips) > 0 && rec.Wildcard.LastResolved > 0 { - nrec.Wildcard = &domainCacheEntry{IPs: ips, LastResolved: rec.Wildcard.LastResolved} - } - } + nrec.Direct = normalizeDomainCacheEntry(rec.Direct) + nrec.Wildcard = normalizeDomainCacheEntry(rec.Wildcard) if nrec.Direct != nil || nrec.Wildcard != nil { normalized.Domains[host] = nrec } @@ -926,6 +1061,46 @@ func (s domainCacheState) get(domain string, source domainCacheSource, now, ttl return ips, true } +func (s domainCacheState) getNegative(domain string, source domainCacheSource, now, nxTTL, timeoutTTL, temporaryTTL, otherTTL int) (dnsErrorKind, int, bool) { + rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] + if !ok { + return "", 0, false + } + var entry *domainCacheEntry + switch source { + case domainCacheSourceWildcard: + entry = rec.Wildcard + default: + entry = rec.Direct + } + if entry == nil || entry.LastErrorAt <= 0 { + return "", 0, false + } + kind, ok := normalizeCacheErrorKind(entry.LastErrorKind) + if !ok { + return "", 0, false + } + age := now - entry.LastErrorAt + if age < 0 { + return "", 0, false + } + cacheTTL := 0 + switch kind { + case dnsErrorNXDomain: + cacheTTL = nxTTL + case dnsErrorTimeout: + cacheTTL = timeoutTTL + case dnsErrorTemporary: + cacheTTL = temporaryTTL + case dnsErrorOther: + cacheTTL = otherTTL + } + if cacheTTL <= 0 || age > cacheTTL { + return "", 0, false + } + return kind, age, true +} + func (s *domainCacheState) set(domain string, source domainCacheSource, ips []string, now int) { host := strings.TrimSpace(strings.ToLower(domain)) if host == "" || now <= 0 { @@ -939,7 +1114,10 @@ func (s *domainCacheState) set(domain string, source domainCacheSource, ips []st s.Domains = map[string]domainCacheRecord{} } rec := s.Domains[host] - entry := &domainCacheEntry{IPs: norm, LastResolved: now} + entry := &domainCacheEntry{ + IPs: norm, + LastResolved: now, + } switch source { case domainCacheSourceWildcard: rec.Wildcard = entry @@ -949,9 +1127,39 @@ func (s *domainCacheState) set(domain string, source domainCacheSource, ips []st s.Domains[host] = rec } +func (s *domainCacheState) setError(domain string, source domainCacheSource, kind dnsErrorKind, now int) { + host := strings.TrimSpace(strings.ToLower(domain)) + if host == "" || now <= 0 { + return + } + normKind, ok := normalizeCacheErrorKind(string(kind)) + if !ok { + return + } + if s.Domains == nil { + s.Domains = map[string]domainCacheRecord{} + } + rec := s.Domains[host] + switch source { + case domainCacheSourceWildcard: + if rec.Wildcard == nil { + rec.Wildcard = &domainCacheEntry{} + } + rec.Wildcard.LastErrorKind = string(normKind) + rec.Wildcard.LastErrorAt = now + default: + if rec.Direct == nil { + rec.Direct = &domainCacheEntry{} + } + rec.Direct.LastErrorKind = string(normKind) + rec.Direct.LastErrorAt = now + } + s.Domains[host] = rec +} + func (s domainCacheState) toMap() map[string]any { out := map[string]any{ - "version": 2, + "version": 3, "domains": map[string]any{}, } domainsAny := out["domains"].(map[string]any) @@ -963,16 +1171,32 @@ func (s domainCacheState) toMap() map[string]any { for _, host := range hosts { rec := s.Domains[host] recOut := map[string]any{} - if rec.Direct != nil && len(rec.Direct.IPs) > 0 && rec.Direct.LastResolved > 0 { - recOut["direct"] = map[string]any{ - "ips": rec.Direct.IPs, - "last_resolved": rec.Direct.LastResolved, + if rec.Direct != nil { + directOut := map[string]any{} + if len(rec.Direct.IPs) > 0 && rec.Direct.LastResolved > 0 { + directOut["ips"] = rec.Direct.IPs + directOut["last_resolved"] = rec.Direct.LastResolved + } + if kind, ok := normalizeCacheErrorKind(rec.Direct.LastErrorKind); ok && rec.Direct.LastErrorAt > 0 { + directOut["last_error_kind"] = string(kind) + directOut["last_error_at"] = rec.Direct.LastErrorAt + } + if len(directOut) > 0 { + recOut["direct"] = directOut } } - if rec.Wildcard != nil && len(rec.Wildcard.IPs) > 0 && rec.Wildcard.LastResolved > 0 { - recOut["wildcard"] = map[string]any{ - "ips": rec.Wildcard.IPs, - "last_resolved": rec.Wildcard.LastResolved, + if rec.Wildcard != nil { + wildOut := map[string]any{} + if len(rec.Wildcard.IPs) > 0 && rec.Wildcard.LastResolved > 0 { + wildOut["ips"] = rec.Wildcard.IPs + wildOut["last_resolved"] = rec.Wildcard.LastResolved + } + if kind, ok := normalizeCacheErrorKind(rec.Wildcard.LastErrorKind); ok && rec.Wildcard.LastErrorAt > 0 { + wildOut["last_error_kind"] = string(kind) + wildOut["last_error_at"] = rec.Wildcard.LastErrorAt + } + if len(wildOut) > 0 { + recOut["wildcard"] = wildOut } } if len(recOut) > 0 { diff --git a/selective-vpn-api/app/routes_cache.go b/selective-vpn-api/app/routes_cache.go index 7d757ad..7c57423 100644 --- a/selective-vpn-api/app/routes_cache.go +++ b/selective-vpn-api/app/routes_cache.go @@ -59,6 +59,12 @@ func saveRoutesClearCache() (routesClearCacheMeta, error) { if err := cacheCopyOrEmpty(stateDir+"/last-ips-map.txt", routesCacheMap); err != nil { warns = append(warns, fmt.Sprintf("last-ips-map cache copy failed: %v", err)) } + if err := cacheCopyOrEmpty(lastIPsMapDirect, routesCacheMapD); err != nil { + warns = append(warns, fmt.Sprintf("last-ips-map-direct cache copy failed: %v", err)) + } + if err := cacheCopyOrEmpty(lastIPsMapDyn, routesCacheMapW); err != nil { + warns = append(warns, fmt.Sprintf("last-ips-map-wildcard cache copy failed: %v", err)) + } meta := routesClearCacheMeta{ CreatedAt: time.Now().UTC().Format(time.RFC3339), @@ -83,6 +89,10 @@ func saveRoutesClearCache() (routesClearCacheMeta, error) { } func restoreRoutesFromCache() cmdResult { + return withRoutesOpLock("routes restore", restoreRoutesFromCacheUnlocked) +} + +func restoreRoutesFromCacheUnlocked() cmdResult { meta, err := loadRoutesClearCacheMeta() if err != nil { return cmdResult{ @@ -174,6 +184,13 @@ func restoreRoutesFromCache() cmdResult { if fileExists(routesCacheMap) { _ = cacheCopyOrEmpty(routesCacheMap, stateDir+"/last-ips-map.txt") } + if fileExists(routesCacheMapD) { + _ = cacheCopyOrEmpty(routesCacheMapD, lastIPsMapDirect) + } + if fileExists(routesCacheMapW) { + _ = cacheCopyOrEmpty(routesCacheMapW, lastIPsMapDyn) + } + _ = writeStatusSnapshot(len(ips)+len(dynIPs), iface) return cmdResult{ OK: true, diff --git a/selective-vpn-api/app/routes_handlers.go b/selective-vpn-api/app/routes_handlers.go index 601f732..94d3690 100644 --- a/selective-vpn-api/app/routes_handlers.go +++ b/selective-vpn-api/app/routes_handlers.go @@ -9,6 +9,7 @@ import ( "os" "strings" "syscall" + "time" ) // --------------------------------------------------------------------- @@ -263,6 +264,10 @@ func handleRoutesCacheRestore(w http.ResponseWriter, r *http.Request) { // RU: `routesClear` - содержит основную логику для routes clear. // --------------------------------------------------------------------- func routesClear() cmdResult { + return withRoutesOpLock("routes clear", routesClearUnlocked) +} + +func routesClearUnlocked() cmdResult { cacheMeta, cacheErr := saveRoutesClearCache() stdout, stderr, _, err := runCommand("ip", "rule", "show") @@ -273,6 +278,11 @@ func routesClear() cmdResult { _, _, _, _ = runCommand("ip", "route", "flush", "table", routesTableName()) _, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn4") _, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn_dyn4") + iface := strings.TrimSpace(cacheMeta.Iface) + if iface == "" { + iface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface) + } + _ = writeStatusSnapshot(0, iface) res := cmdResult{ OK: true, @@ -297,6 +307,50 @@ func routesClear() cmdResult { return res } +func withRoutesOpLock(opName string, fn func() cmdResult) cmdResult { + lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return cmdResult{ + OK: false, + Message: fmt.Sprintf("%s lock open error: %v", opName, err), + } + } + defer lock.Close() + + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return cmdResult{ + OK: false, + Message: fmt.Sprintf("%s skipped: routes operation already running", opName), + } + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + + return fn() +} + +func writeStatusSnapshot(ipCount int, iface string) error { + if ipCount < 0 { + ipCount = 0 + } + iface = strings.TrimSpace(iface) + if iface == "" { + iface = "-" + } + st := Status{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + IPCount: ipCount, + DomainCount: countDomainsFromMap(lastIPsMapPath), + Iface: iface, + Table: routesTableName(), + Mark: MARK, + } + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + return os.WriteFile(statusFilePath, data, 0o644) +} + // --------------------------------------------------------------------- // policy route // --------------------------------------------------------------------- diff --git a/selective-vpn-api/app/routes_update.go b/selective-vpn-api/app/routes_update.go index c63240d..7a0c846 100644 --- a/selective-vpn-api/app/routes_update.go +++ b/selective-vpn-api/app/routes_update.go @@ -189,6 +189,13 @@ func routesUpdate(iface string) cmdResult { bases := loadList(domainDir + "/bases.txt") subs := loadList(domainDir + "/subs.txt") wildcards := loadSmartDNSWildcardDomains(logp) + wildcardBaseSet := make(map[string]struct{}, len(wildcards)) + for _, d := range wildcards { + d = strings.TrimSpace(d) + if d != "" { + wildcardBaseSet[d] = struct{}{} + } + } wildcardBasesAdded := 0 for _, d := range wildcards { d = strings.TrimSpace(d) @@ -212,7 +219,10 @@ func routesUpdate(iface string) cmdResult { twitterAdded := 0 for _, d := range bases { domainSet[d] = struct{}{} - if !isGoogleLike(d) { + _, wildcardBase := wildcardBaseSet[d] + // Wildcard bases are now resolved "as-is" (no subs fan-out) to keep + // SmartDNS wildcard behavior transparent and avoid synthetic host noise. + if !wildcardBase && !isGoogleLike(d) { limit := len(subs) if subsPerBaseLimit > 0 && subsPerBaseLimit < limit { limit = subsPerBaseLimit @@ -258,6 +268,14 @@ func routesUpdate(iface string) cmdResult { ) if wildcardBasesAdded > 0 { logp("domains wildcard seed added: %d base domains from smartdns.conf state", wildcardBasesAdded) + appendTraceLineTo( + smartdnsLogPath, + "smartdns", + fmt.Sprintf( + "wildcard plan: base_domains=%d sub_expanded=0 (routes update uses pure wildcard bases; subs fan-out only in aggressive prewarm)", + wildcardBasesAdded, + ), + ) } domTmp, _ := os.CreateTemp(stateDir, "domains-*.txt") @@ -612,19 +630,27 @@ func logWildcardSmartDNSTrace(mode DNSMode, source string, pairs [][2]string, wi } sort.Strings(hosts) + const maxHostsLog = 200 + omitted := 0 + if len(hosts) > maxHostsLog { + omitted = len(hosts) - maxHostsLog + } + appendTraceLineTo( smartdnsLogPath, "smartdns", - fmt.Sprintf("wildcard sync: mode=%s source=%s domains=%d ips=%d", mode.Mode, source, len(hosts), wildcardIPCount), + fmt.Sprintf( + "wildcard sync: mode=%s source=%s domains=%d ips=%d logged=%d omitted=%d map=%s", + mode.Mode, source, len(hosts), wildcardIPCount, len(hosts)-omitted, omitted, lastIPsMapDyn, + ), ) - const maxHostsLog = 200 for i, host := range hosts { if i >= maxHostsLog { appendTraceLineTo( smartdnsLogPath, "smartdns", - fmt.Sprintf("wildcard sync: +%d domains omitted", len(hosts)-maxHostsLog), + fmt.Sprintf("wildcard sync: trace truncated, %d domains not shown (see %s)", omitted, lastIPsMapDyn), ) return } diff --git a/selective-vpn-api/app/traffic_app_profiles.go b/selective-vpn-api/app/traffic_app_profiles.go index 5045841..f51b51f 100644 --- a/selective-vpn-api/app/traffic_app_profiles.go +++ b/selective-vpn-api/app/traffic_app_profiles.go @@ -25,7 +25,7 @@ import ( // RU: привязаны к конкретному systemd unit/cgroup. const ( - trafficAppProfilesDefaultTTLSec = 24 * 60 * 60 + trafficAppProfilesDefaultTTLSec = 0 // 0 = persistent runtime mark policy ) var trafficAppProfilesMu sync.Mutex @@ -295,6 +295,11 @@ func loadTrafficAppProfilesState() trafficAppProfilesState { st.Profiles[i].AppKey = canon changed = true } + st.Profiles[i].Target = strings.ToLower(strings.TrimSpace(st.Profiles[i].Target)) + } + if deduped, dedupChanged := dedupeTrafficAppProfiles(st.Profiles); dedupChanged { + st.Profiles = deduped + changed = true } if changed { _ = saveTrafficAppProfilesState(st) @@ -302,6 +307,89 @@ func loadTrafficAppProfilesState() trafficAppProfilesState { return st } +func dedupeTrafficAppProfiles(in []TrafficAppProfile) ([]TrafficAppProfile, bool) { + if len(in) <= 1 { + return in, false + } + + out := make([]TrafficAppProfile, 0, len(in)) + byID := map[string]int{} + byAppTarget := map[string]int{} + changed := false + + for _, raw := range in { + p := raw + p.ID = strings.TrimSpace(p.ID) + p.Target = strings.ToLower(strings.TrimSpace(p.Target)) + p.AppKey = canonicalizeAppKey(p.AppKey, p.Command) + + if p.ID == "" { + changed = true + continue + } + if p.Target != "vpn" && p.Target != "direct" { + p.Target = "vpn" + changed = true + } + + if idx, ok := byID[p.ID]; ok { + if preferTrafficProfile(p, out[idx]) { + out[idx] = p + } + changed = true + continue + } + + if p.AppKey != "" { + key := p.Target + "|" + p.AppKey + if idx, ok := byAppTarget[key]; ok { + if preferTrafficProfile(p, out[idx]) { + byID[p.ID] = idx + out[idx] = p + } + changed = true + continue + } + byAppTarget[key] = len(out) + } + + byID[p.ID] = len(out) + out = append(out, p) + } + return out, changed +} + +func preferTrafficProfile(cand, cur TrafficAppProfile) bool { + cu := strings.TrimSpace(cand.UpdatedAt) + ou := strings.TrimSpace(cur.UpdatedAt) + if cu != ou { + if cu == "" { + return false + } + if ou == "" { + return true + } + return cu > ou + } + + cc := strings.TrimSpace(cand.CreatedAt) + oc := strings.TrimSpace(cur.CreatedAt) + if cc != oc { + if cc == "" { + return false + } + if oc == "" { + return true + } + return cc > oc + } + + if strings.TrimSpace(cand.Command) != "" && strings.TrimSpace(cur.Command) == "" { + return true + } + return false +} + func saveTrafficAppProfilesState(st trafficAppProfilesState) error { st.Version = 1 st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) diff --git a/selective-vpn-api/app/traffic_appkey.go b/selective-vpn-api/app/traffic_appkey.go index 00fb997..3779790 100644 --- a/selective-vpn-api/app/traffic_appkey.go +++ b/selective-vpn-api/app/traffic_appkey.go @@ -32,7 +32,7 @@ func canonicalizeAppKey(appKey string, command string) string { key := strings.TrimSpace(appKey) cmd := strings.TrimSpace(command) - fields := strings.Fields(cmd) + fields := splitCommandTokens(cmd) if len(fields) == 0 && key != "" { fields = []string{key} } @@ -61,12 +61,12 @@ func canonicalizeAppKey(appKey string, command string) string { switch base { case "flatpak": if id := extractRunTarget(clean); id != "" { - return "flatpak:" + id + return "flatpak:" + strings.ToLower(strings.TrimSpace(id)) } return "flatpak" case "snap": if name := extractRunTarget(clean); name != "" { - return "snap:" + name + return "snap:" + strings.ToLower(strings.TrimSpace(name)) } return "snap" case "gtk-launch": @@ -74,7 +74,7 @@ func canonicalizeAppKey(appKey string, command string) string { if len(clean) >= 2 { id := strings.TrimSpace(clean[1]) if id != "" && !strings.HasPrefix(id, "-") { - return "desktop:" + id + return "desktop:" + strings.ToLower(id) } } case "env": @@ -102,11 +102,11 @@ func canonicalizeAppKey(appKey string, command string) string { if strings.Contains(primary, "/") { b := filepath.Base(primary) if b != "" && b != "." && b != "/" { - return b + return strings.ToLower(strings.TrimSpace(b)) } } - return primary + return strings.ToLower(strings.TrimSpace(primary)) } func stripOuterQuotes(s string) string { @@ -151,3 +151,65 @@ func extractRunTarget(fields []string) string { } return "" } + +// splitCommandTokens performs lightweight shell-style tokenization. +// It supports single/double quotes and backslash escaping which is enough +// for canonical app key extraction. +func splitCommandTokens(raw string) []string { + s := strings.TrimSpace(raw) + if s == "" { + return nil + } + + out := make([]string, 0, 8) + var cur strings.Builder + inSingle := false + inDouble := false + escaped := false + + flush := func() { + if cur.Len() == 0 { + return + } + out = append(out, cur.String()) + cur.Reset() + } + + for _, r := range s { + if escaped { + cur.WriteRune(r) + escaped = false + continue + } + switch r { + case '\\': + if inSingle { + cur.WriteRune(r) + } else { + escaped = true + } + case '\'': + if inDouble { + cur.WriteRune(r) + } else { + inSingle = !inSingle + } + case '"': + if inSingle { + cur.WriteRune(r) + } else { + inDouble = !inDouble + } + case ' ', '\t', '\n', '\r': + if inSingle || inDouble { + cur.WriteRune(r) + } else { + flush() + } + default: + cur.WriteRune(r) + } + } + flush() + return out +} diff --git a/selective-vpn-api/app/traffic_appkey_test.go b/selective-vpn-api/app/traffic_appkey_test.go new file mode 100644 index 0000000..2002016 --- /dev/null +++ b/selective-vpn-api/app/traffic_appkey_test.go @@ -0,0 +1,135 @@ +package app + +import "testing" + +func TestCanonicalizeAppKey(t *testing.T) { + tests := []struct { + name string + appKey string + command string + want string + }{ + { + name: "path vs bare command normalized to lowercase basename", + command: "/usr/bin/Google-Chrome-Stable --new-window", + want: "google-chrome-stable", + }, + { + name: "quoted path with spaces", + command: "'/opt/My Apps/Opera' --private", + want: "opera", + }, + { + name: "env wrapper skips assignments", + command: "env GTK_THEME=Adwaita /usr/bin/Brave-Browser --incognito", + want: "brave-browser", + }, + { + name: "flatpak run app id", + command: "flatpak run org.mozilla.Firefox", + want: "flatpak:org.mozilla.firefox", + }, + { + name: "snap run app id", + command: "snap run --experimental foo.Bar", + want: "snap:foo.bar", + }, + { + name: "gtk-launch desktop id", + command: "gtk-launch Org.Gnome.Nautilus.desktop", + want: "desktop:org.gnome.nautilus.desktop", + }, + { + name: "explicit app key fallback", + appKey: "Opera", + want: "opera", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := canonicalizeAppKey(tc.appKey, tc.command) + if got != tc.want { + t.Fatalf("canonicalizeAppKey(%q,%q) = %q, want %q", tc.appKey, tc.command, got, tc.want) + } + }) + } +} + +func TestSplitCommandTokens(t *testing.T) { + in := `env A=1 "/opt/My App/bin/App" --flag="x y"` + got := splitCommandTokens(in) + want := []string{"env", "A=1", "/opt/My App/bin/App", "--flag=x y"} + if len(got) != len(want) { + t.Fatalf("tokens len=%d want=%d tokens=%v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("token[%d]=%q want=%q all=%v", i, got[i], want[i], got) + } + } +} + +func TestDedupeTrafficAppProfilesByCanonicalAppKey(t *testing.T) { + in := []TrafficAppProfile{ + { + ID: "chrome-old", + Target: "VPN", + AppKey: "Google-Chrome-Stable", + Command: "/usr/bin/Google-Chrome-Stable --new-window", + UpdatedAt: "2026-02-20T10:00:00Z", + }, + { + ID: "chrome-new", + Target: "vpn", + AppKey: "google-chrome-stable", + Command: "google-chrome-stable --incognito", + UpdatedAt: "2026-02-20T11:00:00Z", + }, + } + out, changed := dedupeTrafficAppProfiles(in) + if !changed { + t.Fatalf("expected changed=true") + } + if len(out) != 1 { + t.Fatalf("expected 1 profile, got %d", len(out)) + } + if out[0].ID != "chrome-new" { + t.Fatalf("expected newest profile to win, got id=%q", out[0].ID) + } + if out[0].AppKey != "google-chrome-stable" { + t.Fatalf("expected canonical app key, got %q", out[0].AppKey) + } +} + +func TestDedupeAppMarkItemsByCanonicalAppKey(t *testing.T) { + in := []appMarkItem{ + { + ID: 101, + Target: "VPN", + AppKey: "Opera", + Command: "/usr/bin/Opera --private", + AddedAt: "2026-02-20T10:00:00Z", + }, + { + ID: 202, + Target: "vpn", + AppKey: "opera", + Command: "opera --new-window", + AddedAt: "2026-02-20T11:00:00Z", + }, + } + out, changed := dedupeAppMarkItems(in) + if !changed { + t.Fatalf("expected changed=true") + } + if len(out) != 1 { + t.Fatalf("expected 1 app mark item, got %d", len(out)) + } + if out[0].ID != 202 { + t.Fatalf("expected newest item to win, got id=%d", out[0].ID) + } + if out[0].AppKey != "opera" { + t.Fatalf("expected canonical app key, got %q", out[0].AppKey) + } +} diff --git a/selective-vpn-api/app/traffic_appmarks.go b/selective-vpn-api/app/traffic_appmarks.go index 49e5ed9..86d5e0c 100644 --- a/selective-vpn-api/app/traffic_appmarks.go +++ b/selective-vpn-api/app/traffic_appmarks.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "net/netip" "os" "path/filepath" "sort" @@ -31,8 +32,11 @@ import ( const ( appMarksTable = "agvpn" appMarksChain = "output_apps" + appMarksGuardChain = "output_guard" + appMarksLocalBypassSet = "svpn_local4" appMarkCommentPrefix = "svpn_appmark" - defaultAppMarkTTLSeconds = 24 * 60 * 60 + appGuardCommentPrefix = "svpn_appguard" + defaultAppMarkTTLSeconds = 0 // 0 = persistent until explicit unmark/clear ) var appMarksMu sync.Mutex @@ -129,9 +133,6 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) { } ttl := timeoutSec - if ttl == 0 { - ttl = defaultAppMarkTTLSeconds - } rel, level, inodeID, cgAbs, err := resolveCgroupV2PathForNft(cgroup) if err != nil { @@ -145,6 +146,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) { return } + vpnIface := "" if target == "vpn" { traffic := loadTrafficModeState() iface, _ := resolveTrafficIface(traffic.PreferredIface) @@ -159,6 +161,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) { }) return } + vpnIface = strings.TrimSpace(iface) if err := ensureTrafficRouteBase(iface, traffic.AutoLocalBypass); err != nil { writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: false, @@ -172,7 +175,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) { } } - if err := appMarksAdd(target, inodeID, cgAbs, rel, level, unit, command, appKey, ttl); err != nil { + if err := appMarksAdd(target, inodeID, cgAbs, rel, level, unit, command, appKey, ttl, vpnIface); err != nil { writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: false, Op: string(op), @@ -253,11 +256,16 @@ func handleTrafficAppMarksItems(w http.ResponseWriter, r *http.Request) { now := time.Now().UTC() items := make([]TrafficAppMarkItemView, 0, len(st.Items)) for _, it := range st.Items { - rem := 0 - exp, err := time.Parse(time.RFC3339, strings.TrimSpace(it.ExpiresAt)) - if err == nil { - rem = int(exp.Sub(now).Seconds()) - if rem < 0 { + rem := -1 // persistent by default + expRaw := strings.TrimSpace(it.ExpiresAt) + if expRaw != "" { + exp, err := time.Parse(time.RFC3339, expRaw) + if err == nil { + rem = int(exp.Sub(now).Seconds()) + if rem < 0 { + rem = 0 + } + } else { rem = 0 } } @@ -308,7 +316,7 @@ func appMarksGetStatus() (vpnCount int, directCount int) { return vpnCount, directCount } -func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit string, command string, appKey string, ttlSec int) error { +func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit string, command string, appKey string, ttlSec int, vpnIface string) error { target = strings.ToLower(strings.TrimSpace(target)) if target != "vpn" && target != "direct" { return fmt.Errorf("invalid target") @@ -333,30 +341,51 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, command = strings.TrimSpace(command) appKey = canonicalizeAppKey(appKey, command) - // EN: Avoid unbounded growth of marks for the same app. - // RU: Не даём бесконечно плодить метки на одно и то же приложение. - if appKey != "" { - kept := st.Items[:0] - for _, it := range st.Items { - if strings.ToLower(strings.TrimSpace(it.Target)) == target && - strings.TrimSpace(it.AppKey) == appKey && - it.ID != id { - _ = nftDeleteAppMarkRule(target, it.ID) - changed = true - continue - } - kept = append(kept, it) + // EN: Keep only one effective mark per app and avoid cross-target conflicts. + // EN: If the same app_key is re-marked with another target, old mark is removed first. + // RU: Держим только одну эффективную метку на приложение и убираем конфликты между target. + // RU: Если тот же app_key перемечается на другой target — старая метка удаляется. + kept := st.Items[:0] + for _, it := range st.Items { + itTarget := strings.ToLower(strings.TrimSpace(it.Target)) + itKey := strings.TrimSpace(it.AppKey) + remove := false + + // Same cgroup id but different target => conflicting rules (mark+guard). + if it.ID == id && it.ID != 0 && itTarget != target { + remove = true } - st.Items = kept + // Same app_key (if known) should not keep multiple active runtime routes. + if !remove && appKey != "" && itKey != "" && itKey == appKey { + if it.ID != id || itTarget != target { + remove = true + } + } + + if remove { + _ = nftDeleteAppMarkRule(itTarget, it.ID) + changed = true + continue + } + kept = append(kept, it) } + st.Items = kept // Replace any existing rule/state for this (target,id). _ = nftDeleteAppMarkRule(target, id) - if err := nftInsertAppMarkRule(target, rel, level, id); err != nil { + if err := nftInsertAppMarkRule(target, rel, level, id, vpnIface); err != nil { return err } + if !nftHasAppMarkRule(target, id) { + _ = nftDeleteAppMarkRule(target, id) + return fmt.Errorf("appmark rule not active after insert (target=%s id=%d)", target, id) + } now := time.Now().UTC() + expiresAt := "" + if ttlSec > 0 { + expiresAt = now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339) + } item := appMarkItem{ ID: id, Target: target, @@ -367,13 +396,15 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, Command: command, AppKey: appKey, AddedAt: now.Format(time.RFC3339), - ExpiresAt: now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339), + ExpiresAt: expiresAt, } st.Items = upsertAppMarkItem(st.Items, item) changed = true if changed { if err := saveAppMarksState(st); err != nil { + // Keep runtime state and nft in sync on disk write errors. + _ = nftDeleteAppMarkRule(target, id) return err } } @@ -479,7 +510,9 @@ func ensureAppMarksNft() error { // Best-effort "ensure": ignore "exists" errors and proceed. _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", appMarksTable) _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", appMarksTable, "output", "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", appMarksTable, appMarksGuardChain, "{", "type", "filter", "hook", "output", "priority", "filter;", "policy", "accept;", "}") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", appMarksTable, appMarksChain) + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", appMarksTable, appMarksLocalBypassSet, "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "list", "chain", "inet", appMarksTable, "output") if !strings.Contains(out, "jump "+appMarksChain) { @@ -514,7 +547,102 @@ func appMarkComment(target string, id uint64) string { return fmt.Sprintf("%s:%s:%d", appMarkCommentPrefix, target, id) } -func nftInsertAppMarkRule(target string, rel string, level int, id uint64) error { +func appGuardComment(target string, id uint64) string { + return fmt.Sprintf("%s:%s:%d", appGuardCommentPrefix, target, id) +} + +func appGuardEnabled() bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv("SVPN_APP_GUARD"))) + return v == "1" || v == "true" || v == "yes" || v == "on" +} + +func updateAppMarkLocalBypassSet(vpnIface string) error { + // EN: Keep a small allowlist for local/LAN/container destinations so VPN app kill-switch + // EN: does not break host-local access. + // RU: Держим небольшой allowlist локальных/LAN/container направлений, чтобы VPN kill-switch + // RU: не ломал локальный доступ хоста. + vpnIface = strings.TrimSpace(vpnIface) + _ = ensureAppMarksNft() + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "set", "inet", appMarksTable, appMarksLocalBypassSet) + + elems := []string{"127.0.0.0/8"} + for _, rt := range detectAutoLocalBypassRoutes(vpnIface) { + dst := strings.TrimSpace(rt.Dst) + if dst == "" || dst == "default" { + continue + } + elems = append(elems, dst) + } + elems = compactIPv4IntervalElements(elems) + for _, e := range elems { + _, out, code, err := runCommandTimeout( + 5*time.Second, + "nft", "add", "element", "inet", appMarksTable, appMarksLocalBypassSet, + "{", e, "}", + ) + if err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("nft add element exited with %d", code) + } + return fmt.Errorf("failed to update %s: %w (%s)", appMarksLocalBypassSet, err, strings.TrimSpace(out)) + } + } + return nil +} + +func compactIPv4IntervalElements(raw []string) []string { + pfxs := make([]netip.Prefix, 0, len(raw)) + for _, v := range raw { + s := strings.TrimSpace(v) + if s == "" { + continue + } + if strings.Contains(s, "/") { + p, err := netip.ParsePrefix(s) + if err != nil || !p.Addr().Is4() { + continue + } + pfxs = append(pfxs, p.Masked()) + continue + } + a, err := netip.ParseAddr(s) + if err != nil || !a.Is4() { + continue + } + pfxs = append(pfxs, netip.PrefixFrom(a, 32)) + } + + sort.Slice(pfxs, func(i, j int) bool { + ib, jb := pfxs[i].Bits(), pfxs[j].Bits() + if ib != jb { + return ib < jb // broader first + } + return pfxs[i].Addr().Less(pfxs[j].Addr()) + }) + + out := make([]netip.Prefix, 0, len(pfxs)) + for _, p := range pfxs { + covered := false + for _, ex := range out { + if ex.Contains(p.Addr()) { + covered = true + break + } + } + if covered { + continue + } + out = append(out, p) + } + + res := make([]string, 0, len(out)) + for _, p := range out { + res = append(res, p.String()) + } + return res +} + +func nftInsertAppMarkRule(target string, rel string, level int, id uint64, vpnIface string) error { mark := MARK_DIRECT if target == "vpn" { mark = MARK_APP @@ -527,6 +655,58 @@ func nftInsertAppMarkRule(target string, rel string, level int, id uint64) error pathLit := fmt.Sprintf("\"%s\"", rel) commentLit := fmt.Sprintf("\"%s\"", comment) + if target == "vpn" { + if !appGuardEnabled() { + goto insertMark + } + iface := strings.TrimSpace(vpnIface) + if iface == "" { + return fmt.Errorf("vpn interface required for app guard") + } + if err := updateAppMarkLocalBypassSet(iface); err != nil { + return err + } + + guardComment := appGuardComment(target, id) + guardCommentLit := fmt.Sprintf("\"%s\"", guardComment) + // IPv4: drop non-tun egress except local bypass ranges. + _, out, code, err := runCommandTimeout( + 5*time.Second, + "nft", "insert", "rule", "inet", appMarksTable, appMarksGuardChain, + "socket", "cgroupv2", "level", strconv.Itoa(level), pathLit, + "meta", "mark", MARK_APP, + "oifname", "!=", iface, + "ip", "daddr", "!=", "@"+appMarksLocalBypassSet, + "drop", + "comment", guardCommentLit, + ) + if err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("nft insert guard(v4) exited with %d", code) + } + return fmt.Errorf("nft insert app guard(v4) failed: %w (%s)", err, strings.TrimSpace(out)) + } + + // IPv6: default deny outside VPN iface to prevent WebRTC/STUN leaks on dual-stack hosts. + _, out, code, err = runCommandTimeout( + 5*time.Second, + "nft", "insert", "rule", "inet", appMarksTable, appMarksGuardChain, + "socket", "cgroupv2", "level", strconv.Itoa(level), pathLit, + "meta", "mark", MARK_APP, + "oifname", "!=", iface, + "meta", "nfproto", "ipv6", + "drop", + "comment", guardCommentLit, + ) + if err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("nft insert guard(v6) exited with %d", code) + } + return fmt.Errorf("nft insert app guard(v6) failed: %w (%s)", err, strings.TrimSpace(out)) + } + } + +insertMark: _, out, code, err := runCommandTimeout( 5*time.Second, "nft", "insert", "rule", "inet", appMarksTable, appMarksChain, @@ -539,27 +719,71 @@ func nftInsertAppMarkRule(target string, rel string, level int, id uint64) error if err == nil { err = fmt.Errorf("nft insert rule exited with %d", code) } + _ = nftDeleteAppMarkRule(target, id) return fmt.Errorf("nft insert appmark rule failed: %w (%s)", err, strings.TrimSpace(out)) } return nil } func nftDeleteAppMarkRule(target string, id uint64) error { - comment := appMarkComment(target, id) - out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain) - for _, line := range strings.Split(out, "\n") { - if !strings.Contains(line, comment) { - continue + comments := []string{ + appMarkComment(target, id), + appGuardComment(target, id), + } + chains := []string{appMarksChain, appMarksGuardChain} + for _, chain := range chains { + out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, chain) + for _, line := range strings.Split(out, "\n") { + match := false + for _, comment := range comments { + if strings.Contains(line, comment) { + match = true + break + } + } + if !match { + continue + } + h := parseNftHandle(line) + if h <= 0 { + continue + } + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, chain, "handle", strconv.Itoa(h)) } - h := parseNftHandle(line) - if h <= 0 { - continue - } - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, appMarksChain, "handle", strconv.Itoa(h)) } return nil } +func nftHasAppMarkRule(target string, id uint64) bool { + markComment := appMarkComment(target, id) + guardComment := appGuardComment(target, id) + + hasMark := false + out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain) + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, markComment) { + hasMark = true + break + } + } + if !hasMark { + return false + } + if strings.EqualFold(strings.TrimSpace(target), "vpn") { + if !appGuardEnabled() { + return true + } + out, _, _, _ = runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksGuardChain) + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, guardComment) { + return true + } + } + return false + } + return true +} + func parseNftHandle(line string) int { fields := strings.Fields(line) for i := 0; i < len(fields)-1; i++ { @@ -638,8 +862,20 @@ func pruneExpiredAppMarksLocked(st *appMarksState, now time.Time) (changed bool) } kept := st.Items[:0] for _, it := range st.Items { - exp, err := time.Parse(time.RFC3339, strings.TrimSpace(it.ExpiresAt)) - if err != nil || !exp.After(now) { + expRaw := strings.TrimSpace(it.ExpiresAt) + if expRaw == "" { + kept = append(kept, it) + continue + } + exp, err := time.Parse(time.RFC3339, expRaw) + if err != nil { + // Corrupted timestamp: keep mark as persistent to avoid accidental route leak. + it.ExpiresAt = "" + kept = append(kept, it) + changed = true + continue + } + if !exp.After(now) { _ = nftDeleteAppMarkRule(strings.ToLower(strings.TrimSpace(it.Target)), it.ID) changed = true continue @@ -662,6 +898,116 @@ func upsertAppMarkItem(items []appMarkItem, next appMarkItem) []appMarkItem { return out } +func clearManagedAppMarkRules(chain string) { + out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, chain) + for _, line := range strings.Split(out, "\n") { + l := strings.ToLower(line) + if !strings.Contains(l, strings.ToLower(appMarkCommentPrefix)) && + !strings.Contains(l, strings.ToLower(appGuardCommentPrefix)) { + continue + } + h := parseNftHandle(line) + if h <= 0 { + continue + } + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, chain, "handle", strconv.Itoa(h)) + } +} + +func restoreAppMarksFromState() error { + appMarksMu.Lock() + defer appMarksMu.Unlock() + + if err := ensureAppMarksNft(); err != nil { + return err + } + + st := loadAppMarksState() + now := time.Now().UTC() + changed := pruneExpiredAppMarksLocked(&st, now) + + clearManagedAppMarkRules(appMarksChain) + clearManagedAppMarkRules(appMarksGuardChain) + + traffic := loadTrafficModeState() + vpnIface, _ := resolveTrafficIface(traffic.PreferredIface) + vpnIface = strings.TrimSpace(vpnIface) + + kept := make([]appMarkItem, 0, len(st.Items)) + for _, it := range st.Items { + target := strings.ToLower(strings.TrimSpace(it.Target)) + if target != "vpn" && target != "direct" { + changed = true + continue + } + + rel := normalizeCgroupRelOnly(it.CgroupRel) + if rel == "" { + rel = normalizeCgroupRelOnly(it.Cgroup) + } + if rel == "" { + changed = true + continue + } + + id := it.ID + if id == 0 { + inode, err := cgroupDirInode(rel) + if err != nil { + changed = true + continue + } + id = inode + it.ID = inode + changed = true + } + + level := it.Level + if level <= 0 { + level = strings.Count(strings.Trim(rel, "/"), "/") + 1 + it.Level = level + changed = true + } + + abs := "/" + strings.TrimPrefix(rel, "/") + it.CgroupRel = rel + it.Cgroup = abs + + if _, err := cgroupDirInode(rel); err != nil { + changed = true + continue + } + + iface := "" + if target == "vpn" { + if vpnIface == "" { + // Keep state for later retry when VPN interface appears. + kept = append(kept, it) + continue + } + iface = vpnIface + } + + if err := nftInsertAppMarkRule(target, rel, level, id, iface); err != nil { + appendTraceLine("traffic", fmt.Sprintf("appmarks restore failed target=%s id=%d err=%v", target, id, err)) + kept = append(kept, it) + continue + } + if !nftHasAppMarkRule(target, id) { + appendTraceLine("traffic", fmt.Sprintf("appmarks restore post-check failed target=%s id=%d", target, id)) + kept = append(kept, it) + continue + } + kept = append(kept, it) + } + st.Items = kept + + if changed { + return saveAppMarksState(st) + } + return nil +} + func loadAppMarksState() appMarksState { st := appMarksState{Version: 1} data, err := os.ReadFile(trafficAppMarksPath) @@ -679,18 +1025,88 @@ func loadAppMarksState() appMarksState { // RU: Best-effort миграция: нормализуем app_key в канонический вид. changed := false for i := range st.Items { + st.Items[i].Target = strings.ToLower(strings.TrimSpace(st.Items[i].Target)) canon := canonicalizeAppKey(st.Items[i].AppKey, st.Items[i].Command) if canon != "" && strings.TrimSpace(st.Items[i].AppKey) != canon { st.Items[i].AppKey = canon changed = true } } + if deduped, dedupChanged := dedupeAppMarkItems(st.Items); dedupChanged { + st.Items = deduped + changed = true + } if changed { _ = saveAppMarksState(st) } return st } +func dedupeAppMarkItems(in []appMarkItem) ([]appMarkItem, bool) { + if len(in) <= 1 { + return in, false + } + out := make([]appMarkItem, 0, len(in)) + byTargetID := map[string]int{} + byTargetApp := map[string]int{} + changed := false + + for _, raw := range in { + it := raw + it.Target = strings.ToLower(strings.TrimSpace(it.Target)) + if it.Target != "vpn" && it.Target != "direct" { + changed = true + continue + } + it.AppKey = canonicalizeAppKey(it.AppKey, it.Command) + + if it.ID > 0 { + idKey := fmt.Sprintf("%s:%d", it.Target, it.ID) + if idx, ok := byTargetID[idKey]; ok { + if preferAppMarkItem(it, out[idx]) { + out[idx] = it + } + changed = true + continue + } + byTargetID[idKey] = len(out) + } + + if it.AppKey != "" { + appKey := it.Target + "|" + it.AppKey + if idx, ok := byTargetApp[appKey]; ok { + if preferAppMarkItem(it, out[idx]) { + out[idx] = it + } + changed = true + continue + } + byTargetApp[appKey] = len(out) + } + + out = append(out, it) + } + return out, changed +} + +func preferAppMarkItem(cand, cur appMarkItem) bool { + ca := strings.TrimSpace(cand.AddedAt) + oa := strings.TrimSpace(cur.AddedAt) + if ca != oa { + if ca == "" { + return false + } + if oa == "" { + return true + } + return ca > oa + } + if strings.TrimSpace(cand.Command) != "" && strings.TrimSpace(cur.Command) == "" { + return true + } + return false +} + func saveAppMarksState(st appMarksState) error { st.Version = 1 st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) diff --git a/selective-vpn-api/app/traffic_mode.go b/selective-vpn-api/app/traffic_mode.go index 88f42df..febca75 100644 --- a/selective-vpn-api/app/traffic_mode.go +++ b/selective-vpn-api/app/traffic_mode.go @@ -11,11 +11,13 @@ import ( "sort" "strconv" "strings" + "syscall" "time" ) const ( trafficRulePrefMarkDirect = 11500 + trafficRulePrefMarkIngressReply = 11505 trafficRulePrefMarkAppVPN = 11510 trafficRulePrefDirectSubnetStart = 11600 trafficRulePrefDirectUIDStart = 11680 @@ -27,6 +29,13 @@ const ( trafficRulePrefManagedMax = 12099 trafficRulePerKindLimit = 70 trafficAutoLocalDefault = true + trafficIngressReplyDefault = false + + trafficIngressPreroutingChain = "prerouting_ingress_reply" + trafficIngressOutputChain = "output_ingress_reply" + + trafficIngressCaptureComment = "svpn_ingress_reply_capture" + trafficIngressRestoreComment = "svpn_ingress_reply_restore" ) var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10") @@ -199,6 +208,7 @@ func loadTrafficModeState() TrafficModeState { 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"` @@ -214,6 +224,7 @@ func loadTrafficModeState() TrafficModeState { Mode: raw.Mode, PreferredIface: raw.PreferredIface, AutoLocalBypass: trafficAutoLocalDefault, + IngressReplyBypass: trafficIngressReplyDefault, ForceVPNSubnets: append([]string(nil), raw.ForceVPNSubnets...), ForceVPNUIDs: append([]string(nil), raw.ForceVPNUIDs...), ForceVPNCGroups: append([]string(nil), raw.ForceVPNCGroups...), @@ -224,6 +235,9 @@ func loadTrafficModeState() TrafficModeState { if raw.AutoLocalBypass != nil { st.AutoLocalBypass = *raw.AutoLocalBypass } + if raw.IngressReplyBypass != nil { + st.IngressReplyBypass = *raw.IngressReplyBypass + } return normalizeTrafficModeState(st) } @@ -253,6 +267,7 @@ func inferTrafficModeState() TrafficModeState { Mode: mode, PreferredIface: iface, AutoLocalBypass: trafficAutoLocalDefault, + IngressReplyBypass: trafficIngressReplyDefault, ForceVPNSubnets: nil, ForceVPNUIDs: nil, ForceVPNCGroups: nil, @@ -529,6 +544,116 @@ func applyAutoLocalBypass(vpnIface string) { } } +func nftObjectMissing(stdout, stderr string) bool { + text := strings.ToLower(strings.TrimSpace(stdout + " " + stderr)) + return strings.Contains(text, "no such file") || strings.Contains(text, "not found") +} + +func ensureIngressReplyBypassChains() { + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", routesTableName()) + _, _, _, _ = runCommandTimeout( + 5*time.Second, + "nft", "add", "chain", "inet", routesTableName(), trafficIngressPreroutingChain, + "{", "type", "filter", "hook", "prerouting", "priority", "mangle;", "policy", "accept;", "}", + ) + _, _, _, _ = runCommandTimeout( + 5*time.Second, + "nft", "add", "chain", "inet", routesTableName(), trafficIngressOutputChain, + "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}", + ) +} + +func flushIngressReplyBypassChains() error { + for _, chain := range []string{trafficIngressPreroutingChain, trafficIngressOutputChain} { + out, errOut, code, err := runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", routesTableName(), chain) + if err == nil && code == 0 { + continue + } + if nftObjectMissing(out, errOut) { + continue + } + if err == nil { + err = fmt.Errorf("nft flush chain exited with %d", code) + } + return fmt.Errorf("flush %s failed: %w (%s %s)", chain, err, strings.TrimSpace(out), strings.TrimSpace(errOut)) + } + return nil +} + +func enableIngressReplyBypass(vpnIface string) error { + vpnIface = strings.TrimSpace(vpnIface) + if vpnIface == "" { + return fmt.Errorf("empty vpn iface for ingress bypass") + } + + ensureIngressReplyBypassChains() + if err := flushIngressReplyBypassChains(); err != nil { + return err + } + + addRule := func(chain string, args ...string) error { + out, errOut, code, err := runCommandTimeout(5*time.Second, "nft", append([]string{"add", "rule", "inet", routesTableName(), chain}, args...)...) + if err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("nft add rule exited with %d", code) + } + return fmt.Errorf("nft add rule %s failed: %w (%s %s)", chain, err, strings.TrimSpace(out), strings.TrimSpace(errOut)) + } + return nil + } + + // EN: Mark inbound NEW connections (except loopback/VPN iface) so reply path can stay direct in full tunnel. + // RU: Помечаем входящие NEW-соединения (кроме loopback/VPN iface), чтобы ответ шел напрямую в full tunnel. + if err := addRule( + trafficIngressPreroutingChain, + "iifname", "!=", "lo", + "iifname", "!=", vpnIface, + "fib", "daddr", "type", "local", + "ct", "state", "new", + "ct", "mark", "set", MARK_INGRESS, + "comment", trafficIngressCaptureComment, + ); err != nil { + return err + } + // EN: Restore fwmark from ct mark in prerouting for forwarded reply traffic. + // RU: Восстанавливаем fwmark из ct mark в prerouting для forwarded-ответов. + if err := addRule( + trafficIngressPreroutingChain, + "ct", "mark", MARK_INGRESS, + "meta", "mark", "set", MARK_INGRESS, + "comment", trafficIngressRestoreComment, + ); err != nil { + return err + } + // EN: Restore fwmark from ct mark in output for local-process replies. + // RU: Восстанавливаем fwmark из ct mark в output для ответов локальных процессов. + if err := addRule( + trafficIngressOutputChain, + "ct", "mark", MARK_INGRESS, + "meta", "mark", "set", MARK_INGRESS, + "comment", trafficIngressRestoreComment, + ); err != nil { + return err + } + return nil +} + +func disableIngressReplyBypass() error { + ensureIngressReplyBypassChains() + return flushIngressReplyBypassChains() +} + +func ingressReplyNftActive() bool { + outPre, _, codePre, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", routesTableName(), trafficIngressPreroutingChain) + outOut, _, codeOut, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", routesTableName(), trafficIngressOutputChain) + if codePre != 0 || codeOut != 0 { + return false + } + return strings.Contains(outPre, trafficIngressCaptureComment) && + strings.Contains(outPre, trafficIngressRestoreComment) && + strings.Contains(outOut, trafficIngressRestoreComment) +} + func prefStr(v int) string { return strconv.Itoa(v) } @@ -827,16 +952,22 @@ func ensureTrafficRouteBase(iface string, autoLocalBypass bool) error { func applyTrafficMode(st TrafficModeState, iface string) error { st = normalizeTrafficModeState(st) eff := buildEffectiveOverrides(st) + advancedActive := st.Mode == TrafficModeFullTunnel + autoLocalActive := advancedActive && st.AutoLocalBypass + ingressReplyActive := advancedActive && st.IngressReplyBypass removeTrafficRulesForTable() // EN: Ensure the policy table name exists even in direct mode so mark-based rules can be installed. // RU: Гарантируем наличие имени policy-table даже в direct режиме, чтобы можно было ставить mark-правила. ensureRoutesTableEntry() + if err := disableIngressReplyBypass(); err != nil { + return err + } needVPNTable := st.Mode != TrafficModeDirect || len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0 if needVPNTable { - if err := ensureTrafficRouteBase(iface, st.AutoLocalBypass); err != nil { + if err := ensureTrafficRouteBase(iface, autoLocalActive); err != nil { return err } } @@ -852,6 +983,11 @@ func applyTrafficMode(st TrafficModeState, iface string) error { if err := applyRule(trafficRulePrefMarkDirect, "fwmark", MARK_DIRECT, "lookup", "main"); err != nil { return err } + if ingressReplyActive { + if err := applyRule(trafficRulePrefMarkIngressReply, "fwmark", MARK_INGRESS, "lookup", "main"); err != nil { + return err + } + } if err := applyRule(trafficRulePrefMarkAppVPN, "fwmark", MARK_APP, "lookup", routesTableName()); err != nil { return err } @@ -870,13 +1006,23 @@ func applyTrafficMode(st TrafficModeState, iface string) error { default: return fmt.Errorf("unknown traffic mode: %s", st.Mode) } + if ingressReplyActive { + if err := enableIngressReplyBypass(iface); err != nil { + return err + } + } + + if err := restoreAppMarksFromState(); err != nil { + appendTraceLine("traffic", fmt.Sprintf("appmarks restore warning: %v", err)) + } return nil } type trafficRulesState struct { - Mark bool - Full bool + Mark bool + Full bool + IngressReply bool } func readTrafficRules() trafficRulesState { @@ -884,7 +1030,7 @@ func readTrafficRules() trafficRulesState { var st trafficRulesState for _, line := range strings.Split(out, "\n") { l := strings.ToLower(strings.TrimSpace(line)) - if l == "" || !strings.Contains(l, "lookup "+routesTableName()) { + if l == "" { continue } fields := strings.Fields(l) @@ -895,9 +1041,17 @@ func readTrafficRules() trafficRulesState { pref, _ := strconv.Atoi(prefRaw) switch pref { case trafficRulePrefSelective: - st.Mark = true + if strings.Contains(l, "lookup "+routesTableName()) { + st.Mark = true + } case trafficRulePrefFull: - st.Full = true + if strings.Contains(l, "lookup "+routesTableName()) { + st.Full = true + } + case trafficRulePrefMarkIngressReply: + if strings.Contains(l, "fwmark "+strings.ToLower(MARK_INGRESS)) && strings.Contains(l, "lookup main") { + st.IngressReply = true + } } } return st @@ -954,12 +1108,20 @@ func probeTrafficMode(mode TrafficMode, iface string) (bool, string) { func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse { st = normalizeTrafficModeState(st) eff := buildEffectiveOverrides(st) + advancedActive := st.Mode == TrafficModeFullTunnel + autoLocalActive := advancedActive && st.AutoLocalBypass + ingressDesired := st.IngressReplyBypass + ingressExpected := advancedActive && ingressDesired hasVPN := len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0 iface, reason := resolveTrafficIface(st.PreferredIface) rules := readTrafficRules() applied := detectAppliedTrafficMode(rules) + ingressNft := false + if rules.IngressReply || st.Mode == TrafficModeFullTunnel || st.IngressReplyBypass { + ingressNft = ingressReplyNftActive() + } bypassCandidates := 0 - if st.AutoLocalBypass && (st.Mode != TrafficModeDirect || hasVPN) { + if autoLocalActive && (st.Mode != TrafficModeDirect || hasVPN) { bypassCandidates = len(detectAutoLocalBypassRoutes(iface)) } @@ -976,7 +1138,11 @@ func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse { DesiredMode: st.Mode, AppliedMode: applied, PreferredIface: st.PreferredIface, + AdvancedActive: advancedActive, AutoLocalBypass: st.AutoLocalBypass, + AutoLocalActive: autoLocalActive, + IngressReplyBypass: ingressDesired, + IngressReplyActive: rules.IngressReply && ingressNft, BypassCandidates: bypassCandidates, ForceVPNSubnets: append([]string(nil), st.ForceVPNSubnets...), ForceVPNUIDs: append([]string(nil), st.ForceVPNUIDs...), @@ -991,6 +1157,8 @@ func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse { IfaceReason: reason, RuleMark: rules.Mark, RuleFull: rules.Full, + IngressRulePresent: rules.IngressReply, + IngressNftActive: ingressNft, TableDefault: tableDefault, } @@ -1001,14 +1169,18 @@ func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse { // direct mode can still be healthy when vpn overrides exist // (base full/selective rules must be absent). if hasVPN { - res.Healthy = !rules.Mark && !rules.Full && tableDefault && iface != "" && res.ProbeOK + res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK } else { - res.Healthy = !rules.Mark && !rules.Full && res.ProbeOK + res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && res.ProbeOK } case TrafficModeFullTunnel: - res.Healthy = rules.Full && !rules.Mark && tableDefault && iface != "" && res.ProbeOK + if ingressExpected { + res.Healthy = rules.Full && !rules.Mark && rules.IngressReply && ingressNft && tableDefault && iface != "" && res.ProbeOK + } else { + res.Healthy = rules.Full && !rules.Mark && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK + } case TrafficModeSelective: - res.Healthy = rules.Mark && !rules.Full && tableDefault && iface != "" && res.ProbeOK + res.Healthy = rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK default: res.Healthy = false } @@ -1037,6 +1209,14 @@ func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse { res.Message = "conflicting traffic rules detected" return res } + if ingressExpected && (!rules.IngressReply || !ingressNft) { + res.Message = "ingress-reply bypass rule is not active" + return res + } + if !ingressExpected && (rules.IngressReply || ingressNft) { + res.Message = "stale ingress-reply bypass rule is active" + return res + } res.Message = "traffic mode check failed" return res } @@ -1067,12 +1247,102 @@ func handleTrafficModeTest(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, evaluateTrafficMode(st)) } +func acquireTrafficApplyLock() (*os.File, *TrafficModeStatusResponse) { + lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + msg := evaluateTrafficMode(loadTrafficModeState()) + msg.Message = "traffic lock open failed: " + err.Error() + return nil, &msg + } + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = lock.Close() + msg := evaluateTrafficMode(loadTrafficModeState()) + msg.Message = "traffic apply skipped: routes operation already running" + return nil, &msg + } + return lock, nil +} + +func handleTrafficAdvancedReset(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + lock, lockMsg := acquireTrafficApplyLock() + if lockMsg != nil { + writeJSON(w, http.StatusOK, *lockMsg) + return + } + defer func() { + _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + _ = lock.Close() + }() + + prev := normalizeTrafficModeState(loadTrafficModeState()) + next := prev + next.AutoLocalBypass = false + next.IngressReplyBypass = false + + nextIface, _ := resolveTrafficIface(next.PreferredIface) + if err := applyTrafficMode(next, nextIface); err != nil { + prevIface, _ := resolveTrafficIface(prev.PreferredIface) + _ = applyTrafficMode(prev, prevIface) + msg := evaluateTrafficMode(prev) + msg.Message = "advanced reset failed, rolled back: " + err.Error() + writeJSON(w, http.StatusOK, msg) + return + } + + if err := saveTrafficModeState(next); err != nil { + prevIface, _ := resolveTrafficIface(prev.PreferredIface) + _ = applyTrafficMode(prev, prevIface) + _ = saveTrafficModeState(prev) + msg := evaluateTrafficMode(prev) + msg.Message = "advanced reset save failed, rolled back: " + err.Error() + writeJSON(w, http.StatusOK, msg) + return + } + + res := evaluateTrafficMode(next) + if !res.Healthy { + prevIface, _ := resolveTrafficIface(prev.PreferredIface) + _ = applyTrafficMode(prev, prevIface) + _ = saveTrafficModeState(prev) + rolled := evaluateTrafficMode(prev) + rolled.Message = "advanced reset verification failed, rolled back: " + res.Message + writeJSON(w, http.StatusOK, rolled) + return + } + + events.push("traffic_advanced_reset", map[string]any{ + "mode": res.Mode, + "applied": res.AppliedMode, + "active_iface": res.ActiveIface, + "healthy": res.Healthy, + "auto_local": res.AutoLocalBypass, + "ingress_reply": res.IngressReplyBypass, + "advanced_active": res.AdvancedActive, + }) + res.Message = "advanced bypass reset" + writeJSON(w, http.StatusOK, res) +} + func handleTrafficMode(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: st := loadTrafficModeState() writeJSON(w, http.StatusOK, evaluateTrafficMode(st)) case http.MethodPost: + lock, lockMsg := acquireTrafficApplyLock() + if lockMsg != nil { + writeJSON(w, http.StatusOK, *lockMsg) + return + } + defer func() { + _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + _ = lock.Close() + }() + prev := loadTrafficModeState() next := prev @@ -1094,6 +1364,9 @@ func handleTrafficMode(w http.ResponseWriter, r *http.Request) { if body.AutoLocalBypass != nil { next.AutoLocalBypass = *body.AutoLocalBypass } + if body.IngressReplyBypass != nil { + next.IngressReplyBypass = *body.IngressReplyBypass + } if body.ForceVPNSubnets != nil { next.ForceVPNSubnets = append([]string(nil), (*body.ForceVPNSubnets)...) } @@ -1127,21 +1400,12 @@ func handleTrafficMode(w http.ResponseWriter, r *http.Request) { } if err := saveTrafficModeState(next); err != nil { - writeJSON(w, http.StatusOK, TrafficModeStatusResponse{ - Mode: next.Mode, - DesiredMode: next.Mode, - PreferredIface: next.PreferredIface, - AutoLocalBypass: next.AutoLocalBypass, - ForceVPNSubnets: append([]string(nil), next.ForceVPNSubnets...), - ForceVPNUIDs: append([]string(nil), next.ForceVPNUIDs...), - ForceVPNCGroups: append([]string(nil), next.ForceVPNCGroups...), - ForceDirectSubnets: append([]string(nil), next.ForceDirectSubnets...), - ForceDirectUIDs: append([]string(nil), next.ForceDirectUIDs...), - ForceDirectCGroups: append([]string(nil), next.ForceDirectCGroups...), - OverridesApplied: len(next.ForceVPNSubnets) + len(next.ForceVPNUIDs) + len(next.ForceDirectSubnets) + len(next.ForceDirectUIDs), - Healthy: false, - Message: "state save failed: " + err.Error(), - }) + prevIface, _ := resolveTrafficIface(prev.PreferredIface) + _ = applyTrafficMode(prev, prevIface) + _ = saveTrafficModeState(prev) + rolled := evaluateTrafficMode(prev) + rolled.Message = "state save failed, rolled back: " + err.Error() + writeJSON(w, http.StatusOK, rolled) return } @@ -1161,7 +1425,11 @@ func handleTrafficMode(w http.ResponseWriter, r *http.Request) { "applied": res.AppliedMode, "active_iface": res.ActiveIface, "healthy": res.Healthy, + "advanced_active": res.AdvancedActive, "auto_local_bypass": res.AutoLocalBypass, + "auto_local_active": res.AutoLocalActive, + "ingress_reply": res.IngressReplyBypass, + "ingress_active": res.IngressReplyActive, "overrides_applied": res.OverridesApplied, }) writeJSON(w, http.StatusOK, res) diff --git a/selective-vpn-gui/api_client.py b/selective-vpn-gui/api_client.py index 789c9a1..c9cf263 100644 --- a/selective-vpn-gui/api_client.py +++ b/selective-vpn-gui/api_client.py @@ -1240,6 +1240,20 @@ class ApiClient: attempts: int = 1, concurrency: int = 6, ) -> DNSBenchmarkResponse: + # Benchmark can legitimately run much longer than the default 5s API timeout. + # Estimate a safe read timeout from payload size and cap it to keep UI responsive. + upstream_count = len(upstreams or []) + domain_count = len(domains or []) + if domain_count <= 0: + domain_count = 6 # backend default domains + clamped_attempts = max(1, min(int(attempts), 3)) + clamped_concurrency = max(1, min(int(concurrency), 32)) + if upstream_count <= 0: + upstream_count = 1 + waves = (upstream_count + clamped_concurrency - 1) // clamped_concurrency + per_wave_sec = domain_count * clamped_attempts * (max(300, int(timeout_ms)) / 1000.0) + bench_timeout = min(180.0, max(15.0, waves*per_wave_sec*1.2+5.0)) + data = cast( Dict[str, Any], self._json( @@ -1253,6 +1267,7 @@ class ApiClient: "attempts": int(attempts), "concurrency": int(concurrency), }, + timeout=bench_timeout, ) ) or {}, @@ -1412,13 +1427,40 @@ class ApiClient: lines = [] return DomainsTable(lines=[str(x) for x in lines]) - def domains_file_get(self, name: Literal["bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"]) -> DomainsFile: + def domains_file_get( + self, + name: Literal[ + "bases", + "meta", + "subs", + "static", + "smartdns", + "last-ips-map", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + ], + ) -> DomainsFile: data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/domains/file", params={"name": name})) or {}) content = str(data.get("content") or "") source = str(data.get("source") or "") return DomainsFile(name=name, content=content, source=source) - def domains_file_set(self, name: Literal["bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"], content: str) -> None: + def domains_file_set( + self, + name: Literal[ + "bases", + "meta", + "subs", + "static", + "smartdns", + "last-ips-map", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + ], + content: str, + ) -> None: self._request("POST", "/api/v1/domains/file", json_body={"name": name, "content": content}) # VPN diff --git a/selective-vpn-gui/dashboard_controller.py b/selective-vpn-gui/dashboard_controller.py index 7853078..fc5aa45 100644 --- a/selective-vpn-gui/dashboard_controller.py +++ b/selective-vpn-gui/dashboard_controller.py @@ -922,18 +922,65 @@ class DashboardController: def domains_file_load(self, name: str) -> DomainsFile: nm = name.strip().lower() - if nm not in ("bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"): + if nm not in ( + "bases", + "meta", + "subs", + "static", + "smartdns", + "last-ips-map", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + ): raise ValueError(f"Invalid domains file name: {name}") return self.client.domains_file_get( - cast(Literal["bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"], nm) + cast( + Literal[ + "bases", + "meta", + "subs", + "static", + "smartdns", + "last-ips-map", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + ], + nm, + ) ) def domains_file_save(self, name: str, content: str) -> None: nm = name.strip().lower() - if nm not in ("bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"): + if nm not in ( + "bases", + "meta", + "subs", + "static", + "smartdns", + "last-ips-map", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + ): raise ValueError(f"Invalid domains file name: {name}") self.client.domains_file_set( - cast(Literal["bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"], nm), content + cast( + Literal[ + "bases", + "meta", + "subs", + "static", + "smartdns", + "last-ips-map", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + ], + nm, + ), + content, ) # -------- Trace -------- diff --git a/selective-vpn-gui/svpn_run_profile.py b/selective-vpn-gui/svpn_run_profile.py index 6715fe1..36a4d4b 100644 --- a/selective-vpn-gui/svpn_run_profile.py +++ b/selective-vpn-gui/svpn_run_profile.py @@ -105,6 +105,74 @@ def infer_app_key(cmdline: str) -> str: return canonicalize_app_key("", cmdline) +def browser_harden_enabled() -> bool: + raw = str(os.environ.get("SVPN_BROWSER_HARDEN", "1") or "1").strip().lower() + return raw not in ("0", "false", "no", "off") + + +def is_chromium_like_cmd(tokens: list[str]) -> bool: + toks = [str(x or "").strip() for x in (tokens or []) if str(x or "").strip()] + if not toks: + return False + exe = os.path.basename(toks[0]).lower() + known = { + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "microsoft-edge", + "microsoft-edge-stable", + "brave", + "brave-browser", + "opera", + "opera-beta", + "opera-developer", + "vivaldi", + "vivaldi-stable", + } + if exe in known: + return True + if any(x in exe for x in ("chrome", "chromium", "edge", "brave", "opera", "vivaldi")): + return True + if exe == "flatpak": + for i, t in enumerate(toks): + if t == "run": + for cand in toks[i + 1:]: + c = cand.strip().lower() + if not c or c.startswith("-") or c == "--": + continue + return any(x in c for x in ("chrome", "chromium", "edge", "brave", "opera", "vivaldi")) + break + return False + + +def maybe_harden_browser_cmdline(cmdline: str) -> str: + raw = (cmdline or "").strip() + if not raw or not browser_harden_enabled(): + return raw + try: + toks = shlex.split(raw) + except Exception: + return raw + if not is_chromium_like_cmd(toks): + return raw + flags = [ + "--disable-quic", + "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", + ] + low = [t.lower() for t in toks] + changed = False + for fl in flags: + fl_low = fl.lower() + if any(t == fl_low or t.startswith(fl_low + "=") for t in low): + continue + toks.append(fl) + changed = True + if not changed: + return raw + return " ".join(shlex.quote(t) for t in toks) + + def canonicalize_app_key(app_key: str, cmdline: str) -> str: key = (app_key or "").strip() cmd = (cmdline or "").strip() @@ -181,6 +249,19 @@ def systemctl_user(args: list[str], *, timeout: float = 4.0) -> tuple[int, str]: out = ((p.stdout or "") + (p.stderr or "")).strip() return int(p.returncode or 0), out +def stop_user_unit_best_effort(unit: str) -> tuple[bool, str]: + u = (unit or "").strip() + if not u: + return False, "empty unit" + code, out = systemctl_user(["stop", u], timeout=4.0) + if code == 0: + return True, out + code2, out2 = systemctl_user(["kill", u], timeout=4.0) + if code2 == 0: + return True, out2 + msg = (out2 or out or f"stop/kill failed for {u}").strip() + return False, msg + def cgroup_path_from_pid(pid: int) -> str: p = int(pid or 0) @@ -246,7 +327,13 @@ def run_systemd_unit(cmdline: str, *, unit: str) -> str: if p.returncode != 0: raise RuntimeError(f"systemd-run failed: rc={p.returncode}\n{out}".strip()) - cg = effective_cgroup_for_unit(unit, timeout_sec=3.0) + try: + cg = effective_cgroup_for_unit(unit, timeout_sec=3.0) + except Exception as e: + stopped, stop_msg = stop_user_unit_best_effort(unit) + if stopped: + raise RuntimeError(f"{e}\n\nUnit was stopped (fail-closed): {unit}") from e + raise RuntimeError(f"{e}\n\nWARNING: failed to stop unit {unit}: {stop_msg}") from e return cg @@ -307,7 +394,8 @@ def apply_mark(*, target: str, cgroup: str, unit: str, command: str, app_key: st res = api_request("POST", "/api/v1/traffic/appmarks", json_body=payload, timeout=4.0) if not bool(res.get("ok", False)): raise RuntimeError(f"appmark failed: {res.get('message')}") - log(f"mark added: target={target} app={app_key} unit={unit} cgroup_id={res.get('cgroup_id')} ttl={res.get('timeout_sec')}") + ttl_txt = "persistent" if int(res.get("timeout_sec", 0) or 0) <= 0 else f"{int(res.get('timeout_sec', 0) or 0)}s" + log(f"mark added: target={target} app={app_key} unit={unit} cgroup_id={res.get('cgroup_id')} ttl={ttl_txt}") def main(argv: list[str]) -> int: @@ -322,27 +410,36 @@ def main(argv: list[str]) -> int: cmd = str(prof.get("command") or "").strip() if not cmd: raise RuntimeError("profile command is empty") + run_cmd = maybe_harden_browser_cmdline(cmd) + if run_cmd != cmd: + log("browser hardening: added anti-leak flags") target = str(prof.get("target") or "vpn").strip().lower() if target not in ("vpn", "direct"): target = "vpn" app_key_raw = str(prof.get("app_key") or "").strip() - app_key = canonicalize_app_key(app_key_raw, cmd) or canonicalize_app_key("", cmd) + app_key = canonicalize_app_key(app_key_raw, run_cmd) or canonicalize_app_key("", run_cmd) ttl = int(prof.get("ttl_sec", 0) or 0) - if ttl <= 0: - ttl = 24 * 60 * 60 + if ttl < 0: + ttl = 0 # Try refresh first if already running. - if refresh_if_running(target=target, app_key=app_key, command=cmd, ttl_sec=ttl): + if refresh_if_running(target=target, app_key=app_key, command=run_cmd, ttl_sec=ttl): if args.json: print(json.dumps({"ok": True, "op": "refresh", "id": pid, "target": target, "app_key": app_key})) return 0 unit = f"svpn-{target}-{int(time.time())}.service" log(f"launching profile id={pid} target={target} app={app_key} unit={unit}") - cg = run_systemd_unit(cmd, unit=unit) + cg = run_systemd_unit(run_cmd, unit=unit) log(f"ControlGroup: {cg}") - apply_mark(target=target, cgroup=cg, unit=unit, command=cmd, app_key=app_key, ttl_sec=ttl) + try: + apply_mark(target=target, cgroup=cg, unit=unit, command=run_cmd, app_key=app_key, ttl_sec=ttl) + except Exception as e: + stopped, stop_msg = stop_user_unit_best_effort(unit) + if stopped: + raise RuntimeError(f"{e}\n\nUnit was stopped (fail-closed): {unit}") from e + raise RuntimeError(f"{e}\n\nWARNING: failed to stop unit {unit}: {stop_msg}") from e if args.json: print(json.dumps({"ok": True, "op": "run", "id": pid, "target": target, "app_key": app_key, "unit": unit})) return 0 diff --git a/selective-vpn-gui/traffic_mode_dialog.py b/selective-vpn-gui/traffic_mode_dialog.py index 2f60de0..60a3fcf 100644 --- a/selective-vpn-gui/traffic_mode_dialog.py +++ b/selective-vpn-gui/traffic_mode_dialog.py @@ -100,6 +100,8 @@ class TrafficModeDialog(QDialog): self._last_app_cgroup_id: int = int(self._settings.value("traffic_app_last_cgroup_id", 0) or 0) except Exception: self._last_app_cgroup_id = 0 + self._adv_auto_local_bypass: bool = True + self._adv_ingress_reply_bypass: bool = False hint_group = QGroupBox("Mode behavior") hint_layout = QVBoxLayout(hint_group) @@ -172,13 +174,32 @@ RU: Обновить список доступных интерфейсов (UP) row_iface.addStretch(1) mode_layout.addLayout(row_iface) - self.chk_auto_local = QCheckBox("Auto-local bypass (LAN/container subnets)") - self.chk_auto_local.setToolTip("""EN: Mirrors local/LAN/docker routes from main into agvpn table to prevent breakage in full tunnel. -EN: This does NOT force containers to use direct internet; use Force Direct subnets for that. -RU: Копирует локальные/LAN/docker маршруты из main в agvpn, чтобы не ломалась локалка в full tunnel. -RU: Это НЕ делает контейнеры direct в интернет; для этого используй Force Direct subnets.""") - self.chk_auto_local.stateChanged.connect(lambda _state: self.on_auto_local_toggle()) - mode_layout.addWidget(self.chk_auto_local) + row_adv_button = QHBoxLayout() + self.btn_adv_bypass = QPushButton("Advanced bypass...") + self.btn_adv_bypass.setToolTip( + "EN: Open compact Full tunnel advanced bypass settings (auto-local + ingress-reply).\n" + "RU: Открыть компактные расширенные bypass-настройки Full tunnel (auto-local + ingress-reply)." + ) + self.btn_adv_bypass.clicked.connect(self.on_open_advanced_bypass_dialog) + row_adv_button.addWidget(self.btn_adv_bypass) + + self.btn_mode_checklist = QPushButton("Checklist...") + self.btn_mode_checklist.setToolTip( + "EN: Quick production checklist for traffic mode/full tunnel safety.\n" + "RU: Короткий боевой чеклист по режимам трафика и безопасному full tunnel." + ) + self.btn_mode_checklist.clicked.connect(self.on_show_mode_checklist) + row_adv_button.addWidget(self.btn_mode_checklist) + + self.lbl_adv_quick = QLabel("Advanced bypass: —") + self.lbl_adv_quick.setToolTip( + "EN: Saved and active state for Full tunnel advanced bypass.\n" + "RU: Сохраненное и активное состояние advanced bypass для Full tunnel." + ) + self.lbl_adv_quick.setStyleSheet("color: gray;") + row_adv_button.addWidget(self.lbl_adv_quick, stretch=1) + row_adv_button.addStretch(1) + mode_layout.addLayout(row_adv_button) self.lbl_state = QLabel("Traffic mode: —") self.lbl_state.setStyleSheet("color: gray;") @@ -371,6 +392,17 @@ RU: Восстанавливает маршруты/nft из последнег row_cmd.addWidget(self.btn_app_pick) run_layout.addLayout(row_cmd) + row_harden = QHBoxLayout() + self.chk_app_browser_harden = QCheckBox("Browser anti-leak flags (WebRTC/QUIC)") + self.chk_app_browser_harden.setChecked(True) + self.chk_app_browser_harden.setToolTip( + "EN: For Chromium-family browsers, auto-add flags to reduce WebRTC/STUN and QUIC leaks.\n" + "RU: Для Chromium-подобных браузеров автоматически добавляет флаги против утечек WebRTC/STUN и QUIC." + ) + row_harden.addWidget(self.chk_app_browser_harden) + row_harden.addStretch(1) + run_layout.addLayout(row_harden) + row_target = QHBoxLayout() row_target.addWidget(QLabel("Route via")) self.rad_app_vpn = QRadioButton("VPN") @@ -393,10 +425,20 @@ RU: Восстанавливает маршруты/nft из последнег run_layout.addLayout(row_target) row_ttl = QHBoxLayout() + self.chk_app_temporary = QCheckBox("Temporary mark (TTL)") + self.chk_app_temporary.setToolTip( + "EN: Off (default): mark is persistent until manual unmark/clear.\n" + "EN: On: mark expires after TTL hours.\n" + "RU: Выкл (по умолчанию): метка постоянная до ручного удаления.\n" + "RU: Вкл: метка истекает через TTL часов." + ) + self.chk_app_temporary.setChecked(False) + row_ttl.addWidget(self.chk_app_temporary) row_ttl.addWidget(QLabel("TTL (hours)")) self.spn_app_ttl = QSpinBox() self.spn_app_ttl.setRange(1, 24 * 30) # up to ~30 days self.spn_app_ttl.setValue(24) + self.spn_app_ttl.setEnabled(False) self.spn_app_ttl.setToolTip( "EN: How long the runtime mark stays active (backend nftset element timeout).\n" "RU: Сколько живет runtime-метка (timeout элемента в nftset)." @@ -404,6 +446,7 @@ RU: Восстанавливает маршруты/nft из последнег row_ttl.addWidget(self.spn_app_ttl) row_ttl.addStretch(1) run_layout.addLayout(row_ttl) + self.chk_app_temporary.toggled.connect(self.spn_app_ttl.setEnabled) pid_group = QGroupBox("Mark existing PID (no launch)") pid_layout = QHBoxLayout(pid_group) @@ -483,7 +526,7 @@ RU: Восстанавливает маршруты/nft из последнег tab_run_layout.addStretch(1) self.apps_tabs.addTab(tab_run, "Run") - marks_group = QGroupBox("Active runtime marks (TTL)") + marks_group = QGroupBox("Active runtime marks") marks_layout = QVBoxLayout(marks_group) marks_row = QHBoxLayout() @@ -509,8 +552,8 @@ RU: Восстанавливает маршруты/nft из последнег self.lst_marks = QListWidget() self.lst_marks.setSelectionMode(QAbstractItemView.ExtendedSelection) self.lst_marks.setToolTip( - "EN: Active runtime marks. Stored by backend with TTL.\n" - "RU: Активные runtime-метки. Хранятся backend с TTL." + "EN: Active runtime marks. Can be persistent or temporary (TTL).\n" + "RU: Активные runtime-метки. Могут быть постоянными или временными (TTL)." ) self.lst_marks.setFixedHeight(140) marks_layout.addWidget(self.lst_marks) @@ -598,6 +641,14 @@ RU: Восстанавливает маршруты/nft из последнег tab_adv = QWidget() tab_adv_layout = QVBoxLayout(tab_adv) + adv_hint = QLabel( + "Policy overrides are source-based rules (subnet/UID/cgroup).\n" + "Full tunnel advanced bypass (auto-local + ingress-reply) is configured from Traffic basics." + ) + adv_hint.setWordWrap(True) + adv_hint.setStyleSheet("color: gray;") + tab_adv_layout.addWidget(adv_hint) + self.ed_vpn_subnets = QPlainTextEdit() self.ed_vpn_subnets.setToolTip("""EN: Force VPN by source subnet. Useful for docker subnets when you want containers via VPN. RU: Принудительно через VPN по source subnet. Полезно для docker-подсетей, если хочешь контейнеры через VPN.""") @@ -878,12 +929,18 @@ RU: Применяет policy-rules и проверяет health. При оши 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, @@ -903,10 +960,16 @@ RU: Применяет policy-rules и проверяет health. При оши text = f"Traffic mode: {desired} (applied: {applied}) [{health_txt}]" diag_parts = [] diag_parts.append(f"preferred={preferred_iface or 'auto'}") + diag_parts.append(f"advanced={'on' if advanced_active else 'off'}") diag_parts.append( - f"auto_local_bypass={'on' if auto_local_bypass else 'off'}" + f"auto_local={'on' if auto_local_bypass else 'off'}" + f"({'active' if auto_local_active else 'saved'})" ) - if bypass_candidates > 0: + 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: @@ -917,6 +980,10 @@ RU: Применяет policy-rules и проверяет health. При оши 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) @@ -929,6 +996,20 @@ RU: Применяет policy-rules и проверяет health. При оши self.lbl_diag.setText(diag) self.lbl_diag.setStyleSheet("color: gray;") + quick = ( + f"Advanced bypass: auto-local={'on' if auto_local_bypass else 'off'} " + f"({('active' if auto_local_active else 'saved')}), " + f"ingress-reply={'on' if ingress_reply_bypass else 'off'} " + f"({('active' if ingress_reply_active else 'saved')})" + ) + if advanced_active: + adv_color = "green" if (ingress_reply_active or auto_local_active) else "gray" + self.lbl_adv_quick.setText(quick) + self.lbl_adv_quick.setStyleSheet(f"color: {adv_color};") + else: + self.lbl_adv_quick.setText(f"{quick} | applies only in Full tunnel") + self.lbl_adv_quick.setStyleSheet("color: gray;") + def refresh_state(self) -> None: def work() -> None: view = self.ctrl.traffic_mode_view() @@ -946,9 +1027,9 @@ RU: Применяет policy-rules и проверяет health. При оши opts = self.ctrl.traffic_interfaces() self._set_preferred_iface_options(opts, view.preferred_iface) - self.chk_auto_local.blockSignals(True) - self.chk_auto_local.setChecked(bool(view.auto_local_bypass)) - self.chk_auto_local.blockSignals(False) + self._set_full_tunnel_advanced_enabled(mode) + self._adv_auto_local_bypass = bool(view.auto_local_bypass) + self._adv_ingress_reply_bypass = bool(view.ingress_reply_bypass) self._set_lines(self.ed_vpn_subnets, list(view.force_vpn_subnets or [])) self._set_lines(self.ed_vpn_uids, list(view.force_vpn_uids or [])) self._set_lines(self.ed_vpn_cgroups, list(view.force_vpn_cgroups or [])) @@ -960,12 +1041,18 @@ RU: Применяет policy-rules и проверяет health. При оши view.desired_mode, view.applied_mode, view.preferred_iface, + bool(view.advanced_active), bool(view.auto_local_bypass), + bool(view.auto_local_active), + bool(view.ingress_reply_bypass), + bool(view.ingress_reply_active), int(view.bypass_candidates), int(view.overrides_applied), int(view.cgroup_resolved_uids), view.cgroup_warning, bool(view.healthy), + bool(view.ingress_rule_present), + bool(view.ingress_nft_active), bool(view.probe_ok), view.probe_message, view.active_iface, @@ -981,13 +1068,15 @@ RU: Применяет policy-rules и проверяет health. При оши def work() -> None: preferred = self._preferred_iface_value() - auto_local = self.chk_auto_local.isChecked() - view = self.ctrl.traffic_mode_set(mode, preferred, auto_local) + auto_local = bool(self._adv_auto_local_bypass) + ingress_reply = bool(self._adv_ingress_reply_bypass) + view = self.ctrl.traffic_mode_set(mode, preferred, auto_local, ingress_reply) msg = ( f"Traffic mode set: desired={view.desired_mode}, " f"applied={view.applied_mode}, iface={view.active_iface or '-'}, " f"preferred={preferred or 'auto'}, probe_ok={view.probe_ok}, " f"healthy={view.healthy}, auto_local_bypass={view.auto_local_bypass}, " + f"ingress_reply_bypass={view.ingress_reply_bypass}, ingress_reply_active={view.ingress_reply_active}, " f"bypass_routes={view.bypass_candidates}, overrides={view.overrides_applied}, " f"cgroup_uids={view.cgroup_resolved_uids}, message={view.message}" ) @@ -1027,35 +1116,158 @@ RU: Применяет policy-rules и проверяет health. При оши return "direct" return "selective" - def on_auto_local_toggle(self) -> None: + def _set_full_tunnel_advanced_enabled(self, mode: str) -> None: + is_full = (mode or "").strip().lower() == "full_tunnel" + self.btn_adv_bypass.setEnabled(True) + if is_full: + self.btn_adv_bypass.setText("Advanced bypass...") + self.btn_adv_bypass.setStyleSheet("") + else: + self.btn_adv_bypass.setText("Advanced bypass... (saved only)") + self.btn_adv_bypass.setStyleSheet("color: gray;") + + def on_show_mode_checklist(self) -> None: + text = ( + "Quick checklist\n\n" + "1) Select mode:\n" + "- Selective: safest default for mixed host/server workloads.\n" + "- Full tunnel: all traffic via VPN (then review advanced bypass).\n" + "- Direct: VPN policy rules disabled.\n\n" + "2) For Full tunnel:\n" + "- Open Advanced bypass.\n" + "- Enable Auto-local bypass for LAN/container reachability.\n" + "- Enable Ingress-reply bypass to keep public services reachable.\n\n" + "3) Verify status line:\n" + "- health must be [OK].\n" + "- ingress_diag should be rule:ok/nft:ok when ingress-reply is ON.\n\n" + "4) If something breaks:\n" + "- Use Advanced bypass -> Reset bypass.\n" + "- Or switch back to Selective and re-test." + ) + QMessageBox.information(self, "Traffic mode checklist", text) + + def on_open_advanced_bypass_dialog(self) -> None: + mode = self._selected_mode() + + dlg = QDialog(self) + dlg.setWindowTitle("Advanced bypass (Full tunnel)") + dlg.setModal(True) + layout = QVBoxLayout(dlg) + + hint = QLabel( + "Applies only in Full tunnel.\n" + "- Auto-local bypass: keep LAN/docker reachable.\n" + "- Ingress-reply bypass: keep inbound/public services reachable." + ) + hint.setWordWrap(True) + hint.setStyleSheet("color: gray;") + layout.addWidget(hint) + + chk_auto = QCheckBox("Auto-local bypass (LAN/container subnets)") + chk_auto.setChecked(bool(self._adv_auto_local_bypass)) + chk_auto.setToolTip( + "EN: Keeps LAN/container routes direct in Full tunnel.\n" + "RU: Сохраняет LAN/контейнерные маршруты direct в Full tunnel." + ) + layout.addWidget(chk_auto) + + chk_ingress = QCheckBox("Ingress-reply bypass (keep public services reachable)") + chk_ingress.setChecked(bool(self._adv_ingress_reply_bypass)) + chk_ingress.setToolTip( + "EN: Keeps replies for inbound WAN connections on main/direct route.\n" + "RU: Оставляет ответы на входящие WAN-соединения по main/direct." + ) + layout.addWidget(chk_ingress) + + state = QLabel( + "Current mode is Full tunnel: changes apply now." + if mode == "full_tunnel" + else "Current mode is not Full tunnel: changes are saved and applied later." + ) + state.setWordWrap(True) + state.setStyleSheet("color: green;" if mode == "full_tunnel" else "color: #b07f00;") + layout.addWidget(state) + + reset_note = QLabel( + "Reset bypass = disable both toggles and apply to current mode." + ) + reset_note.setWordWrap(True) + reset_note.setStyleSheet("color: gray;") + layout.addWidget(reset_note) + + row = QHBoxLayout() + row.addStretch(1) + btn_cancel = QPushButton("Cancel") + btn_reset = QPushButton("Reset bypass") + btn_apply = QPushButton("Apply") + row.addWidget(btn_cancel) + row.addWidget(btn_reset) + row.addWidget(btn_apply) + layout.addLayout(row) + btn_cancel.clicked.connect(dlg.reject) + btn_apply.clicked.connect(dlg.accept) + action = {"mode": "apply"} + + def on_reset_click() -> None: + action["mode"] = "reset" + dlg.accept() + + btn_reset.clicked.connect(on_reset_click) + + if dlg.exec() != QDialog.Accepted: + return + def work() -> None: - mode = self._selected_mode() + if action["mode"] == "reset": + view = self.ctrl.traffic_advanced_reset() + self._adv_auto_local_bypass = bool(view.auto_local_bypass) + self._adv_ingress_reply_bypass = bool(view.ingress_reply_bypass) + self._emit_log( + "Traffic advanced bypass reset: " + f"mode={view.desired_mode}, auto_local={view.auto_local_bypass}, " + f"ingress_reply={view.ingress_reply_bypass}, message={view.message}" + ) + op_ok = bool(view.healthy) and not self._is_operation_error(view.message) + self._set_action_status( + f"Advanced bypass reset ({view.message})", + ok=op_ok, + ) + self.refresh_state() + if self.refresh_cb: + self.refresh_cb() + return + + auto_local = bool(chk_auto.isChecked()) + ingress_reply = bool(chk_ingress.isChecked()) preferred = self._preferred_iface_value() - auto_local = self.chk_auto_local.isChecked() - view = self.ctrl.traffic_mode_set(mode, preferred, auto_local) - msg = ( - f"Traffic auto-local set: mode={view.desired_mode}, " - f"auto_local_bypass={view.auto_local_bypass}, " - f"bypass_routes={view.bypass_candidates}, overrides={view.overrides_applied}, " - f"cgroup_uids={view.cgroup_resolved_uids}, message={view.message}" + view = self.ctrl.traffic_mode_set(mode, preferred, auto_local, ingress_reply) + self._adv_auto_local_bypass = bool(view.auto_local_bypass) + self._adv_ingress_reply_bypass = bool(view.ingress_reply_bypass) + self._emit_log( + "Traffic advanced bypass set: " + f"mode={view.desired_mode}, auto_local={view.auto_local_bypass}, " + f"ingress_reply={view.ingress_reply_bypass}, ingress_active={view.ingress_reply_active}, " + f"message={view.message}" ) - self._emit_log(msg) op_ok = bool(view.healthy) and not self._is_operation_error(view.message) self._set_action_status( - f"Auto-local bypass set: {'on' if view.auto_local_bypass else 'off'} ({view.message})", + "Advanced bypass saved: " + f"auto_local={'on' if view.auto_local_bypass else 'off'}, " + f"ingress_reply={'on' if view.ingress_reply_bypass else 'off'} ({view.message})", ok=op_ok, ) self.refresh_state() if self.refresh_cb: self.refresh_cb() - self._safe(work, title="Auto-local bypass error") + self._safe(work, title="Advanced bypass error") def on_apply_overrides(self) -> None: def work() -> None: mode = self._selected_mode() preferred = self._preferred_iface_value() - auto_local = self.chk_auto_local.isChecked() + auto_local = bool(self._adv_auto_local_bypass) + ingress_reply = bool(self._adv_ingress_reply_bypass) vpn_subnets = self._lines_from_text(self.ed_vpn_subnets.toPlainText()) vpn_uids = self._lines_from_text(self.ed_vpn_uids.toPlainText()) vpn_cgroups = self._lines_from_text(self.ed_vpn_cgroups.toPlainText()) @@ -1067,6 +1279,7 @@ RU: Применяет policy-rules и проверяет health. При оши mode, preferred, auto_local, + ingress_reply, vpn_subnets, vpn_uids, vpn_cgroups, @@ -1076,6 +1289,7 @@ RU: Применяет policy-rules и проверяет health. При оши ) msg = ( f"Traffic overrides applied: mode={view.desired_mode}, " + f"auto_local={view.auto_local_bypass}, ingress_reply={view.ingress_reply_bypass}, ingress_active={view.ingress_reply_active}, " f"vpn_subnets={len(view.force_vpn_subnets)}, vpn_uids={len(view.force_vpn_uids)}, vpn_cgroups={len(view.force_vpn_cgroups)}, " f"direct_subnets={len(view.force_direct_subnets)}, direct_uids={len(view.force_direct_uids)}, direct_cgroups={len(view.force_direct_cgroups)}, " f"overrides={view.overrides_applied}, cgroup_uids={view.cgroup_resolved_uids}, " @@ -1177,6 +1391,77 @@ RU: Применяет policy-rules и проверяет health. При оши return primary + def _ui_runtime_mark_ttl_sec(self) -> int: + if bool(getattr(self, "chk_app_temporary", None)) and self.chk_app_temporary.isChecked(): + return int(self.spn_app_ttl.value()) * 3600 + return 0 + + def _is_chromium_like_cmd(self, tokens: list[str]) -> bool: + toks = [str(x or "").strip() for x in (tokens or []) if str(x or "").strip()] + if not toks: + return False + exe = os.path.basename(toks[0]).lower() + known = { + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "microsoft-edge", + "microsoft-edge-stable", + "brave", + "brave-browser", + "opera", + "opera-beta", + "opera-developer", + "vivaldi", + "vivaldi-stable", + } + if exe in known: + return True + if any(x in exe for x in ("chrome", "chromium", "edge", "brave", "opera", "vivaldi")): + return True + + # flatpak run + if exe == "flatpak": + for i, t in enumerate(toks): + if t == "run": + for cand in toks[i + 1:]: + c = cand.strip().lower() + if not c or c.startswith("-") or c == "--": + continue + return any(x in c for x in ("chrome", "chromium", "edge", "brave", "opera", "vivaldi")) + break + return False + + def _maybe_harden_browser_cmdline(self, cmdline: str) -> str: + raw = (cmdline or "").strip() + if not raw: + return raw + if not bool(getattr(self, "chk_app_browser_harden", None)) or not self.chk_app_browser_harden.isChecked(): + return raw + try: + toks = shlex.split(raw) + except Exception: + return raw + if not self._is_chromium_like_cmd(toks): + return raw + + flags = [ + "--disable-quic", + "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", + ] + low = [t.lower() for t in toks] + changed = False + for fl in flags: + fl_low = fl.lower() + if any(t == fl_low or t.startswith(fl_low + "=") for t in low): + continue + toks.append(fl) + changed = True + if not changed: + return raw + return " ".join(shlex.quote(t) for t in toks) + def _launch_and_mark( self, *, @@ -1193,8 +1478,12 @@ RU: Применяет policy-rules и проверяет health. При оши raise ValueError("invalid target") ttl = int(ttl_sec or 0) if ttl <= 0: - ttl = int(self.spn_app_ttl.value()) * 3600 + ttl = self._ui_runtime_mark_ttl_sec() + ttl_log = "persistent" if ttl <= 0 else f"{ttl}s" key = (app_key or "").strip() or self._infer_app_key_from_cmdline(cmdline) + run_cmdline = self._maybe_harden_browser_cmdline(cmdline) + if run_cmdline != cmdline: + self._append_app_log("[app] browser hardening: added anti-leak flags") # EN: If we already have a running unit for the same app_key+target, refresh mark instead of spawning. # RU: Если уже есть запущенный unit для того же app_key+target — обновляем метку, не плодим инстансы. @@ -1221,7 +1510,7 @@ RU: Применяет policy-rules и проверяет health. При оши target=tgt, cgroup=cg, unit=unit, - command=cmdline, + command=run_cmdline, app_key=key, timeout_sec=ttl, ) @@ -1235,7 +1524,7 @@ RU: Применяет policy-rules и проверяет health. При оши unit=unit, target=tgt, app_key=key, - cmdline=cmdline, + cmdline=run_cmdline, cgroup_id=int(res.cgroup_id or 0), ) self.refresh_appmarks_items(quiet=True) @@ -1245,42 +1534,59 @@ RU: Применяет policy-rules и проверяет health. При оши return unit = f"svpn-{tgt}-{int(time.time())}.service" - self._append_app_log(f"[app] launching: app={key or '-'} target={tgt} ttl={ttl}s unit={unit}") - cg, out = self._run_systemd_unit(cmdline, unit=unit) + self._append_app_log(f"[app] launching: app={key or '-'} target={tgt} ttl={ttl_log} unit={unit}") + try: + cg, out = self._run_systemd_unit(run_cmdline, unit=unit) + except Exception as e: + try: + self._stop_scope_unit(unit) + self._append_app_log(f"[app] fail-closed: stopped unit after launch error: {unit}") + except Exception as stop_err: + self._append_app_log(f"[app] fail-closed WARN: stop failed for {unit}: {stop_err}") + raise RuntimeError(f"{e}\n\nUnit: {unit}") from e if out: self._append_app_log(f"[app] systemd-run:\n{out}") self._append_app_log(f"[app] ControlGroup: {cg}") - self._set_last_scope(unit=unit, target=tgt, app_key=key, cmdline=cmdline, cgroup_id=0) res = self.ctrl.traffic_appmarks_apply( op="add", target=tgt, cgroup=cg, unit=unit, - command=cmdline, + command=run_cmdline, app_key=key, timeout_sec=ttl, ) if not res.ok: + stop_note = "" + try: + self._stop_scope_unit(unit) + self._append_app_log(f"[app] fail-closed: stopped unit after mark failure: {unit}") + stop_note = "\n\nUnit was stopped (fail-closed)." + except Exception as stop_err: + self._append_app_log(f"[app] fail-closed WARN: stop failed for {unit}: {stop_err}") + stop_note = f"\n\nWARNING: failed to stop unit after mark error: {stop_err}" low = (res.message or "").lower() if "cgroupv2 path fails" in low or "no such file or directory" in low: raise RuntimeError( (res.message or "appmark apply failed") + + stop_note + "\n\n" + "EN: This usually means the app didn't stay inside the new systemd unit " + "(often because it was already running). Close the app completely and run again.\n" + "RU: Обычно это значит, что приложение не осталось в новом systemd unit " + "(часто потому что оно уже было запущено). Полностью закрой приложение и запусти снова." ) - raise RuntimeError(res.message or "appmark apply failed") + raise RuntimeError((res.message or "appmark apply failed") + stop_note) - self._append_app_log(f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s") + timeout_txt = "persistent" if int(res.timeout_sec or 0) <= 0 else f"{res.timeout_sec}s" + self._append_app_log(f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={timeout_txt}") self._set_action_status(f"App mark added: target={tgt} cgroup_id={res.cgroup_id}", ok=True) self._set_last_scope( unit=unit, target=tgt, app_key=key, - cmdline=cmdline, + cmdline=run_cmdline, cgroup_id=int(res.cgroup_id or 0), ) self.refresh_appmarks_items(quiet=True) @@ -1322,7 +1628,7 @@ RU: Применяет policy-rules и проверяет health. При оши script = os.path.abspath(os.path.join(os.path.dirname(__file__), "svpn_run_profile.py")) # Use env python3 so the shortcut works even if python3 is not /usr/bin/python3. - exec_line = f"/usr/bin/env python3 {script} --id {pid}" + exec_line = f"/usr/bin/env SVPN_BROWSER_HARDEN=1 python3 {script} --id {pid}" # Keep .desktop content ASCII-ish. Values are UTF-8-safe by spec, but avoid surprises. name_safe = (name or "SVPN profile").replace("\n", " ").replace("\r", " ").strip() @@ -1392,6 +1698,7 @@ RU: Применяет policy-rules и проверяет health. При оши app_key = (getattr(p, "app_key", "") or "").strip() cmd = (getattr(p, "command", "") or "").strip() ttl_sec = int(getattr(p, "ttl_sec", 0) or 0) + ttl_txt = "persistent" if ttl_sec <= 0 else f"{ttl_sec}s" label = name or pid or "(unnamed)" if target in ("vpn", "direct"): @@ -1434,7 +1741,7 @@ RU: Применяет policy-rules и проверяет health. При оши f"id: {pid}\n" f"app_key: {app_key}\n" f"target: {target}\n" - f"ttl: {ttl_sec}s\n\n" + f"ttl: {ttl_txt}\n\n" f"shortcut: {sc_state}\n" f"shortcut_path: {sc_path}\n\n" f"runtime_marks: {len(items)}\n" @@ -1468,7 +1775,7 @@ RU: Применяет policy-rules и проверяет health. При оши return target = "vpn" if self.rad_app_vpn.isChecked() else "direct" - ttl_sec = int(self.spn_app_ttl.value()) * 3600 + ttl_sec = self._ui_runtime_mark_ttl_sec() name = (self.ed_app_profile_name.text() or "").strip() app_key = self._infer_app_key_from_cmdline(cmdline) @@ -1585,6 +1892,9 @@ RU: Применяет policy-rules и проверяет health. При оши # UI uses hours; round up. hours = max(1, (ttl_sec + 3599) // 3600) self.spn_app_ttl.setValue(int(hours)) + self.chk_app_temporary.setChecked(True) + else: + self.chk_app_temporary.setChecked(False) self.ed_app_profile_name.setText(name) self._set_action_status("Profile loaded into form", ok=True) @@ -1675,13 +1985,15 @@ RU: Применяет policy-rules и проверяет health. При оши unit = (getattr(it, "unit", "") or "").strip() cmd = (getattr(it, "command", "") or "").strip() rem = int(getattr(it, "remaining_sec", 0) or 0) + if rem < 0: + rem_txt = "persistent" + else: + rem_h = rem // 3600 + rem_m = (rem % 3600) // 60 + rem_s = rem % 60 + rem_txt = f"ttl {rem_h:02d}:{rem_m:02d}:{rem_s:02d}" - rem_h = rem // 3600 - rem_m = (rem % 3600) // 60 - rem_s = rem % 60 - rem_txt = f"{rem_h:02d}:{rem_m:02d}:{rem_s:02d}" - - label = f"{tgt} {app_key or unit or mid} (ttl {rem_txt})" + label = f"{tgt} {app_key or unit or mid} ({rem_txt})" q = QListWidgetItem(label) q.setToolTip( ( @@ -1689,7 +2001,7 @@ RU: Применяет policy-rules и проверяет health. При оши f"target: {tgt}\n" f"app_key: {app_key}\n" f"unit: {unit}\n" - f"remaining: {rem}s\n\n" + f"remaining: {('persistent' if rem < 0 else str(rem) + 's')}\n\n" f"{cmd}" ).strip() ) @@ -2033,9 +2345,10 @@ RU: Применяет policy-rules и проверяет health. При оши app_key = f"pid:{pid}" target = "vpn" if self.rad_app_vpn.isChecked() else "direct" - ttl_sec = int(self.spn_app_ttl.value()) * 3600 + ttl_sec = self._ui_runtime_mark_ttl_sec() - self._append_app_log(f"[pid] mark: pid={pid} target={target} ttl={ttl_sec}s") + ttl_txt = "persistent" if ttl_sec <= 0 else f"{ttl_sec}s" + self._append_app_log(f"[pid] mark: pid={pid} target={target} ttl={ttl_txt}") self._append_app_log(f"[pid] cgroup: {cg}") if cmdline: self._append_app_log(f"[pid] cmdline: {cmdline}") @@ -2055,7 +2368,8 @@ RU: Применяет policy-rules и проверяет health. При оши QMessageBox.critical(self, "Mark PID error", res.message or "mark failed") return - self._append_app_log(f"[pid] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s") + res_timeout_txt = "persistent" if int(res.timeout_sec or 0) <= 0 else f"{res.timeout_sec}s" + self._append_app_log(f"[pid] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res_timeout_txt}") self._set_action_status(f"PID marked: target={target} cgroup_id={res.cgroup_id}", ok=True) self._set_last_scope(unit="", target=target, app_key=app_key, cmdline=cmdline, cgroup_id=int(res.cgroup_id or 0)) @@ -2073,7 +2387,7 @@ RU: Применяет policy-rules и проверяет health. При оши return target = "vpn" if self.rad_app_vpn.isChecked() else "direct" - ttl_sec = int(self.spn_app_ttl.value()) * 3600 + ttl_sec = self._ui_runtime_mark_ttl_sec() app_key = self._infer_app_key_from_cmdline(cmdline) self._launch_and_mark(cmdline=cmdline, target=target, ttl_sec=ttl_sec, app_key=app_key) diff --git a/selective-vpn-gui/vpn_dashboard_qt.py b/selective-vpn-gui/vpn_dashboard_qt.py index ca0af4a..8967b7f 100755 --- a/selective-vpn-gui/vpn_dashboard_qt.py +++ b/selective-vpn-gui/vpn_dashboard_qt.py @@ -511,6 +511,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и "static-ips", "last-ips-map-direct", "last-ips-map-wildcard", + "wildcard-observed-hosts", "smartdns.conf", ): QListWidgetItem(name, self.lst_files) @@ -631,6 +632,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и "static-ips": "static", "last-ips-map-direct": "last-ips-map-direct", "last-ips-map-wildcard": "last-ips-map-wildcard", + "wildcard-observed-hosts": "wildcard-observed-hosts", "smartdns.conf": "smartdns", } if name in api_map: @@ -643,6 +645,8 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и path = "/var/lib/selective-vpn/last-ips-map-direct.txt (artifact: agvpn4)" elif name == "last-ips-map-wildcard": path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (artifact: agvpn_dyn4)" + elif name == "wildcard-observed-hosts": + path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (derived unique hosts)" else: path = f"/etc/selective-vpn/domains/{name}.txt" return content, source, path @@ -1530,7 +1534,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и def work(): name = self._get_selected_domains_file() content, source, path = self._load_file_content(name) - is_readonly = name in ("last-ips-map-direct", "last-ips-map-wildcard") + is_readonly = name in ("last-ips-map-direct", "last-ips-map-wildcard", "wildcard-observed-hosts") self.txt_domains.setReadOnly(is_readonly) self.btn_domains_save.setEnabled(not is_readonly) self._set_text(self.txt_domains, content)