From 0b28586f310ea687e29fde60e75e2c0430d46908 Mon Sep 17 00:00:00 2001 From: beckline Date: Sat, 21 Feb 2026 19:56:14 +0300 Subject: [PATCH] resolver: wave DNS lookup with fallback pool and bounded retries --- selective-vpn-api/app/resolver.go | 113 ++++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 7 deletions(-) diff --git a/selective-vpn-api/app/resolver.go b/selective-vpn-api/app/resolver.go index ee07ecb..816797d 100644 --- a/selective-vpn-api/app/resolver.go +++ b/selective-vpn-api/app/resolver.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "hash/fnv" "net" "net/netip" "os" @@ -140,6 +141,15 @@ type wildcardMatcher struct { suffix []string } +var resolverFallbackDNS = []string{ + "1.1.1.1#53", + "1.0.0.1#53", + "9.9.9.9#53", + "149.112.112.112#53", + "8.8.8.8#53", + "8.8.4.4#53", +} + func normalizeWildcardDomain(raw string) string { d := strings.TrimSpace(strings.SplitN(raw, "#", 2)[0]) d = strings.ToLower(d) @@ -248,6 +258,14 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul if workers > 500 { workers = 500 } + dnsTimeoutMs := envInt("RESOLVE_DNS_TIMEOUT_MS", 1800) + if dnsTimeoutMs < 300 { + dnsTimeoutMs = 300 + } + if dnsTimeoutMs > 5000 { + dnsTimeoutMs = 5000 + } + dnsTimeout := time.Duration(dnsTimeoutMs) * time.Millisecond domainCache := loadDomainCacheState(opts.CachePath, logf) ptrCache := loadJSONMap(opts.PtrCachePath) @@ -266,7 +284,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul } if logf != nil { - logf("resolver start: domains=%d ttl=%ds workers=%d", len(domains), ttl, workers) + logf("resolver start: domains=%d ttl=%ds workers=%d dns_timeout_ms=%d", len(domains), ttl, workers, dnsTimeoutMs) } start := time.Now() @@ -309,7 +327,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul for i := 0; i < workers; i++ { go func() { for j := range jobs { - ips, stats := resolveHostGo(j.host, cfg, metaSpecial, wildcards, logf) + ips, stats := resolveHostGo(j.host, cfg, metaSpecial, wildcards, dnsTimeout, logf) results <- struct { host string ips []string @@ -462,7 +480,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul // DNS resolve helpers // --------------------------------------------------------------------- -func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards wildcardMatcher, logf func(string, ...any)) ([]string, dnsMetrics) { +func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards wildcardMatcher, timeout time.Duration, logf func(string, ...any)) ([]string, dnsMetrics) { useMeta := false for _, m := range metaSpecial { if host == m { @@ -484,7 +502,7 @@ func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards w dnsList = []string{cfg.SmartDNS} } } - ips, stats := digA(host, dnsList, 3*time.Second, logf) + ips, stats := digA(host, dnsList, timeout, logf) out := []string{} seen := map[string]struct{}{} for _, ip := range ips { @@ -504,9 +522,22 @@ func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards w // RU: `digA` - содержит основную логику для dig a. // --------------------------------------------------------------------- func digA(host string, dnsList []string, timeout time.Duration, logf func(string, ...any)) ([]string, dnsMetrics) { - var ips []string stats := dnsMetrics{} - for _, entry := range dnsList { + if len(dnsList) == 0 { + return nil, stats + } + + tryLimit := envInt("RESOLVE_DNS_TRY_LIMIT", 2) + if tryLimit < 1 { + tryLimit = 1 + } + if tryLimit > len(dnsList) { + tryLimit = len(dnsList) + } + + start := pickDNSStartIndex(host, len(dnsList)) + for attempt := 0; attempt < tryLimit; attempt++ { + entry := dnsList[(start+attempt)%len(dnsList)] server, port := splitDNS(entry) if server == "" { continue @@ -531,17 +562,24 @@ func digA(host string, dnsList []string, timeout time.Duration, logf func(string if logf != nil { logf("dns warn %s via %s: kind=%s err=%v", host, addr, kind, err) } + // NXDOMAIN usually means authoritative negative answer. + // Do not fan out further retries for this host. + if kind == dnsErrorNXDomain { + break + } continue } stats.addSuccess(addr) + var ips []string for _, ip := range records { if isPrivateIPv4(ip) { continue } ips = append(ips, ip) } + return uniqueStrings(ips), stats } - return uniqueStrings(ips), stats + return nil, stats } func classifyDNSError(err error) dnsErrorKind { @@ -1066,6 +1104,8 @@ func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig { if len(meta) > 0 { cfg.Meta = meta } + cfg.Default = mergeDNSUpstreamPools(cfg.Default, resolverFallbackPool()) + cfg.Meta = mergeDNSUpstreamPools(cfg.Meta, resolverFallbackPool()) if logf != nil { logf("dns-config: accept %s: mode=%s smartdns=%s default=%v; meta=%v", path, cfg.Mode, cfg.SmartDNS, cfg.Default, cfg.Meta) } @@ -1130,6 +1170,65 @@ func uniqueStrings(in []string) []string { return out } +func pickDNSStartIndex(host string, size int) int { + if size <= 1 { + return 0 + } + h := fnv.New32a() + _, _ = h.Write([]byte(strings.ToLower(strings.TrimSpace(host)))) + return int(h.Sum32() % uint32(size)) +} + +func resolverFallbackPool() []string { + raw := strings.TrimSpace(os.Getenv("RESOLVE_DNS_FALLBACKS")) + switch strings.ToLower(raw) { + case "off", "none", "0": + return nil + } + + candidates := resolverFallbackDNS + if raw != "" { + candidates = nil + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' + }) + for _, f := range fields { + if n := normalizeDNSUpstream(f, "53"); n != "" { + candidates = append(candidates, n) + } + } + } + return uniqueStrings(candidates) +} + +func mergeDNSUpstreamPools(primary, fallback []string) []string { + maxUpstreams := envInt("RESOLVE_DNS_MAX_UPSTREAMS", 12) + if maxUpstreams < 1 { + maxUpstreams = 1 + } + out := make([]string, 0, len(primary)+len(fallback)) + seen := map[string]struct{}{} + add := func(items []string) { + for _, item := range items { + if len(out) >= maxUpstreams { + return + } + n := normalizeDNSUpstream(item, "53") + if n == "" { + continue + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + out = append(out, n) + } + } + add(primary) + add(fallback) + return out +} + // --------------------------------------------------------------------- // text cleanup + IP classifiers // ---------------------------------------------------------------------