Harden resolver and expand traffic runtime controls

This commit is contained in:
beckline
2026-02-24 00:17:46 +03:00
parent 89eaaf3f23
commit 50518a641d
18 changed files with 2048 additions and 181 deletions

View File

@@ -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 {