resolver: wave DNS lookup with fallback pool and bounded retries

This commit is contained in:
beckline
2026-02-21 19:56:14 +03:00
parent a5e93888a5
commit 0b28586f31

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"hash/fnv"
"net" "net"
"net/netip" "net/netip"
"os" "os"
@@ -140,6 +141,15 @@ type wildcardMatcher struct {
suffix []string 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 { func normalizeWildcardDomain(raw string) string {
d := strings.TrimSpace(strings.SplitN(raw, "#", 2)[0]) d := strings.TrimSpace(strings.SplitN(raw, "#", 2)[0])
d = strings.ToLower(d) d = strings.ToLower(d)
@@ -248,6 +258,14 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
if workers > 500 { if workers > 500 {
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) domainCache := loadDomainCacheState(opts.CachePath, logf)
ptrCache := loadJSONMap(opts.PtrCachePath) ptrCache := loadJSONMap(opts.PtrCachePath)
@@ -266,7 +284,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
} }
if logf != nil { 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() start := time.Now()
@@ -309,7 +327,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
for i := 0; i < workers; i++ { for i := 0; i < workers; i++ {
go func() { go func() {
for j := range jobs { 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 { results <- struct {
host string host string
ips []string ips []string
@@ -462,7 +480,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
// DNS resolve helpers // 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 useMeta := false
for _, m := range metaSpecial { for _, m := range metaSpecial {
if host == m { if host == m {
@@ -484,7 +502,7 @@ func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards w
dnsList = []string{cfg.SmartDNS} dnsList = []string{cfg.SmartDNS}
} }
} }
ips, stats := digA(host, dnsList, 3*time.Second, logf) ips, stats := digA(host, dnsList, timeout, logf)
out := []string{} out := []string{}
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, ip := range ips { for _, ip := range ips {
@@ -504,9 +522,22 @@ func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards w
// RU: `digA` - содержит основную логику для dig a. // RU: `digA` - содержит основную логику для dig a.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
func digA(host string, dnsList []string, timeout time.Duration, logf func(string, ...any)) ([]string, dnsMetrics) { func digA(host string, dnsList []string, timeout time.Duration, logf func(string, ...any)) ([]string, dnsMetrics) {
var ips []string
stats := dnsMetrics{} 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) server, port := splitDNS(entry)
if server == "" { if server == "" {
continue continue
@@ -531,17 +562,24 @@ func digA(host string, dnsList []string, timeout time.Duration, logf func(string
if logf != nil { if logf != nil {
logf("dns warn %s via %s: kind=%s err=%v", host, addr, kind, err) 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 continue
} }
stats.addSuccess(addr) stats.addSuccess(addr)
var ips []string
for _, ip := range records { for _, ip := range records {
if isPrivateIPv4(ip) { if isPrivateIPv4(ip) {
continue continue
} }
ips = append(ips, ip) ips = append(ips, ip)
} }
return uniqueStrings(ips), stats
} }
return uniqueStrings(ips), stats return nil, stats
} }
func classifyDNSError(err error) dnsErrorKind { func classifyDNSError(err error) dnsErrorKind {
@@ -1066,6 +1104,8 @@ func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig {
if len(meta) > 0 { if len(meta) > 0 {
cfg.Meta = meta cfg.Meta = meta
} }
cfg.Default = mergeDNSUpstreamPools(cfg.Default, resolverFallbackPool())
cfg.Meta = mergeDNSUpstreamPools(cfg.Meta, resolverFallbackPool())
if logf != nil { 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) 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 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 // text cleanup + IP classifiers
// --------------------------------------------------------------------- // ---------------------------------------------------------------------