Files
elmprodvpn/selective-vpn-api/app/resolver.go

2984 lines
77 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"net"
"net/netip"
"os"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// ---------------------------------------------------------------------
// Go resolver
// ---------------------------------------------------------------------
// EN: Go-based domain resolver pipeline used by routes update.
// EN: Handles cache reuse, concurrent DNS lookups, PTR labeling for static entries,
// EN: and returns deduplicated IP sets plus IP-to-label mapping artifacts.
// RU: Go-резолвер, используемый пайплайном обновления маршрутов.
// RU: Обрабатывает кэш, конкурентные DNS-запросы, PTR-лейблы для static entries
// RU: и возвращает дедуплицированный список IP и IP-to-label mapping.
type dnsErrorKind string
const (
dnsErrorNXDomain dnsErrorKind = "nxdomain"
dnsErrorTimeout dnsErrorKind = "timeout"
dnsErrorTemporary dnsErrorKind = "temporary"
dnsErrorOther dnsErrorKind = "other"
)
type dnsUpstreamMetrics struct {
Attempts int
OK int
NXDomain int
Timeout int
Temporary int
Other int
Skipped int
}
type dnsMetrics struct {
Attempts int
OK int
NXDomain int
Timeout int
Temporary int
Other int
Skipped int
PerUpstream map[string]*dnsUpstreamMetrics
}
func (m *dnsMetrics) ensureUpstream(upstream string) *dnsUpstreamMetrics {
if m.PerUpstream == nil {
m.PerUpstream = map[string]*dnsUpstreamMetrics{}
}
if us, ok := m.PerUpstream[upstream]; ok {
return us
}
us := &dnsUpstreamMetrics{}
m.PerUpstream[upstream] = us
return us
}
func (m *dnsMetrics) addSuccess(upstream string) {
m.Attempts++
m.OK++
us := m.ensureUpstream(upstream)
us.Attempts++
us.OK++
}
func (m *dnsMetrics) addError(upstream string, kind dnsErrorKind) {
m.Attempts++
us := m.ensureUpstream(upstream)
us.Attempts++
switch kind {
case dnsErrorNXDomain:
m.NXDomain++
us.NXDomain++
case dnsErrorTimeout:
m.Timeout++
us.Timeout++
case dnsErrorTemporary:
m.Temporary++
us.Temporary++
default:
m.Other++
us.Other++
}
}
func (m *dnsMetrics) addCooldownSkip(upstream string) {
m.Skipped++
us := m.ensureUpstream(upstream)
us.Skipped++
}
func (m *dnsMetrics) merge(other dnsMetrics) {
m.Attempts += other.Attempts
m.OK += other.OK
m.NXDomain += other.NXDomain
m.Timeout += other.Timeout
m.Temporary += other.Temporary
m.Other += other.Other
for upstream, src := range other.PerUpstream {
dst := m.ensureUpstream(upstream)
dst.Attempts += src.Attempts
dst.OK += src.OK
dst.NXDomain += src.NXDomain
dst.Timeout += src.Timeout
dst.Temporary += src.Temporary
dst.Other += src.Other
}
}
func (m dnsMetrics) totalErrors() int {
return m.NXDomain + m.Timeout + m.Temporary + m.Other
}
func (m dnsMetrics) formatPerUpstream() string {
if len(m.PerUpstream) == 0 {
return ""
}
keys := make([]string, 0, len(m.PerUpstream))
for k := range m.PerUpstream {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
v := m.PerUpstream[k]
parts = append(parts, fmt.Sprintf("%s{attempts=%d ok=%d nxdomain=%d timeout=%d temporary=%d other=%d skipped=%d}", k, v.Attempts, v.OK, v.NXDomain, v.Timeout, v.Temporary, v.Other, v.Skipped))
}
return strings.Join(parts, "; ")
}
func (m dnsMetrics) formatResolverHealth() string {
if len(m.PerUpstream) == 0 {
return ""
}
keys := make([]string, 0, len(m.PerUpstream))
for k := range m.PerUpstream {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
v := m.PerUpstream[k]
if v == nil || v.Attempts <= 0 {
continue
}
okRate := float64(v.OK) / float64(v.Attempts)
timeoutRate := float64(v.Timeout) / float64(v.Attempts)
score := okRate*100.0 - timeoutRate*50.0
state := "bad"
switch {
case score >= 70 && timeoutRate <= 0.05:
state = "good"
case score >= 35:
state = "degraded"
default:
state = "bad"
}
parts = append(parts, fmt.Sprintf("%s{score=%.1f state=%s attempts=%d ok=%d timeout=%d nxdomain=%d temporary=%d other=%d skipped=%d}", k, score, state, v.Attempts, v.OK, v.Timeout, v.NXDomain, v.Temporary, v.Other, v.Skipped))
}
return strings.Join(parts, "; ")
}
type wildcardMatcher struct {
exact map[string]struct{}
suffix []string
}
type dnsAttemptPolicy struct {
TryLimit int
DomainBudget time.Duration
StopOnNX bool
}
const (
domainStateActive = "active"
domainStateStable = "stable"
domainStateSuspect = "suspect"
domainStateQuarantine = "quarantine"
domainStateHardQuar = "hard_quarantine"
domainScoreMin = -100
domainScoreMax = 100
defaultQuarantineTTL = 24 * 3600
defaultHardQuarantineTT = 7 * 24 * 3600
)
type resolverTimeoutRecheckStats struct {
Checked int
Recovered int
RecoveredIPs int
StillTimeout int
NowNXDomain int
NowTemporary int
NowOther int
NoSignal int
}
type resolverLiveBatchStats struct {
Target int
Total int
Deferred int
P1 int
P2 int
P3 int
NXHeavyPct int
NXHeavyTotal int
NXHeavySkip int
NextTarget int
NextReason string
NextNXPct int
NextNXReason string
DNSAttempts int
DNSTimeout int
DNSCoolSkips int
}
type dnsCooldownState struct {
Attempts int
TimeoutLike int
FailStreak int
BanUntil int64
BanLevel int
}
type dnsRunCooldown struct {
mu sync.Mutex
enabled bool
minAttempts int
timeoutRatePct int
failStreak int
banSec int
maxBanSec int
temporaryAsError bool
byUpstream map[string]*dnsCooldownState
}
// Empty by default: primary resolver pool comes from DNS upstream pool state.
// Optional fallback list can still be provided via RESOLVE_DNS_FALLBACKS env.
var resolverFallbackDNS []string
func normalizeWildcardDomain(raw string) string {
d := strings.TrimSpace(strings.SplitN(raw, "#", 2)[0])
d = strings.ToLower(d)
d = strings.TrimPrefix(d, "*.")
d = strings.TrimPrefix(d, ".")
d = strings.TrimSuffix(d, ".")
return d
}
func newWildcardMatcher(domains []string) wildcardMatcher {
seen := map[string]struct{}{}
m := wildcardMatcher{exact: map[string]struct{}{}}
for _, raw := range domains {
d := normalizeWildcardDomain(raw)
if d == "" {
continue
}
if _, ok := seen[d]; ok {
continue
}
seen[d] = struct{}{}
m.exact[d] = struct{}{}
m.suffix = append(m.suffix, "."+d)
}
return m
}
func (m wildcardMatcher) match(host string) bool {
if len(m.exact) == 0 {
return false
}
h := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
if h == "" {
return false
}
if _, ok := m.exact[h]; ok {
return true
}
for _, suffix := range m.suffix {
if strings.HasSuffix(h, suffix) {
return true
}
}
return false
}
// ---------------------------------------------------------------------
// EN: `runResolverJob` runs the workflow for resolver job.
// RU: `runResolverJob` - запускает рабочий процесс для resolver job.
// ---------------------------------------------------------------------
func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResult, error) {
res := resolverResult{
DomainCache: map[string]any{},
PtrCache: map[string]any{},
}
domains := loadList(opts.DomainsPath)
metaSpecial := loadList(opts.MetaPath)
staticLines := readLinesAllowMissing(opts.StaticPath)
wildcards := newWildcardMatcher(opts.SmartDNSWildcards)
cfg := loadDNSConfig(opts.DNSConfigPath, logf)
if !smartDNSForced() {
cfg.Mode = normalizeDNSResolverMode(opts.Mode, opts.ViaSmartDNS)
}
if addr := normalizeSmartDNSAddr(opts.SmartDNSAddr); addr != "" {
cfg.SmartDNS = addr
}
if cfg.SmartDNS == "" {
cfg.SmartDNS = smartDNSAddr()
}
if cfg.Mode == DNSModeSmartDNS && cfg.SmartDNS != "" {
cfg.Default = []string{cfg.SmartDNS}
cfg.Meta = []string{cfg.SmartDNS}
}
if logf != nil {
switch cfg.Mode {
case DNSModeSmartDNS:
logf("resolver dns mode: SmartDNS-only (%s)", cfg.SmartDNS)
case DNSModeHybridWildcard:
logf("resolver dns mode: hybrid_wildcard smartdns=%s wildcards=%d default=%v meta=%v", cfg.SmartDNS, len(wildcards.exact), cfg.Default, cfg.Meta)
default:
logf("resolver dns mode: direct default=%v meta=%v", cfg.Default, cfg.Meta)
}
}
ttl := opts.TTL
if ttl <= 0 {
ttl = 24 * 3600
}
// safety clamp: 60s .. 24h
if ttl < 60 {
ttl = 60
}
if ttl > 24*3600 {
ttl = 24 * 3600
}
workers := opts.Workers
if workers <= 0 {
workers = 80
}
// safety clamp: 1..500
if workers < 1 {
workers = 1
}
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)
now := int(time.Now().Unix())
precheckEverySec := envInt("RESOLVE_PRECHECK_EVERY_SEC", 24*3600)
if precheckEverySec < 0 {
precheckEverySec = 0
}
precheckMaxDomains := envInt("RESOLVE_PRECHECK_MAX_DOMAINS", 3000)
if precheckMaxDomains < 0 {
precheckMaxDomains = 0
}
if precheckMaxDomains > 50000 {
precheckMaxDomains = 50000
}
timeoutRecheckMax := envInt("RESOLVE_TIMEOUT_RECHECK_MAX", precheckMaxDomains)
if timeoutRecheckMax < 0 {
timeoutRecheckMax = 0
}
if timeoutRecheckMax > 50000 {
timeoutRecheckMax = 50000
}
precheckStatePath := opts.CachePath + ".precheck.json"
precheckLastRun := loadResolverPrecheckLastRun(precheckStatePath)
liveBatchMin := envInt("RESOLVE_LIVE_BATCH_MIN", 800)
liveBatchMax := envInt("RESOLVE_LIVE_BATCH_MAX", 3000)
liveBatchDefault := envInt("RESOLVE_LIVE_BATCH_DEFAULT", 1800)
if liveBatchMin < 200 {
liveBatchMin = 200
}
if liveBatchMin > 50000 {
liveBatchMin = 50000
}
if liveBatchMax < liveBatchMin {
liveBatchMax = liveBatchMin
}
if liveBatchMax > 50000 {
liveBatchMax = 50000
}
if liveBatchDefault < liveBatchMin {
liveBatchDefault = liveBatchMin
}
if liveBatchDefault > liveBatchMax {
liveBatchDefault = liveBatchMax
}
liveBatchTarget := loadResolverLiveBatchTarget(precheckStatePath, liveBatchDefault, liveBatchMin, liveBatchMax)
liveBatchNXHeavyMin := envInt("RESOLVE_LIVE_BATCH_NX_HEAVY_MIN_PCT", 5)
liveBatchNXHeavyMax := envInt("RESOLVE_LIVE_BATCH_NX_HEAVY_MAX_PCT", 35)
liveBatchNXHeavyDefault := envInt("RESOLVE_LIVE_BATCH_NX_HEAVY_PCT", 10)
if liveBatchNXHeavyMin < 0 {
liveBatchNXHeavyMin = 0
}
if liveBatchNXHeavyMin > 100 {
liveBatchNXHeavyMin = 100
}
if liveBatchNXHeavyMax < liveBatchNXHeavyMin {
liveBatchNXHeavyMax = liveBatchNXHeavyMin
}
if liveBatchNXHeavyMax > 100 {
liveBatchNXHeavyMax = 100
}
if liveBatchNXHeavyDefault < liveBatchNXHeavyMin {
liveBatchNXHeavyDefault = liveBatchNXHeavyMin
}
if liveBatchNXHeavyDefault > liveBatchNXHeavyMax {
liveBatchNXHeavyDefault = liveBatchNXHeavyMax
}
liveBatchNXHeavyPct := loadResolverLiveBatchNXHeavyPct(precheckStatePath, liveBatchNXHeavyDefault, liveBatchNXHeavyMin, liveBatchNXHeavyMax)
precheckEnvForced := resolvePrecheckForceEnvEnabled()
precheckFileForced := resolvePrecheckForceFileEnabled(precheckForcePath)
precheckDue := precheckEnvForced || precheckFileForced || (precheckEverySec > 0 && (precheckLastRun <= 0 || now-precheckLastRun >= precheckEverySec))
precheckScheduled := 0
staleKeepSec := envInt("RESOLVE_STALE_KEEP_SEC", 48*3600)
if staleKeepSec < 0 {
staleKeepSec = 0
}
if staleKeepSec > 7*24*3600 {
staleKeepSec = 7 * 24 * 3600
}
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 {
case DNSModeSmartDNS:
return domainCacheSourceWildcard
case DNSModeHybridWildcard:
if wildcards.match(host) {
return domainCacheSourceWildcard
}
}
return domainCacheSourceDirect
}
cooldown := newDNSRunCooldown()
timeoutRecheck := resolverTimeoutRecheckStats{}
if precheckDue && timeoutRecheckMax > 0 {
timeoutRecheck = runTimeoutQuarantineRecheck(
domains,
cfg,
metaSpecial,
wildcards,
dnsTimeout,
&domainCache,
cacheSourceForHost,
now,
timeoutRecheckMax,
workers,
)
}
if logf != nil {
logf("resolver start: domains=%d ttl=%ds workers=%d dns_timeout_ms=%d", len(domains), ttl, workers, dnsTimeoutMs)
directPolicy := directDNSAttemptPolicy(len(cfg.Default))
wildcardPolicy := wildcardDNSAttemptPolicy(1)
cEnabled, cMin, cRate, cStreak, cBan, cMaxBan := cooldown.configSnapshot()
logf(
"resolver policy: direct_try=%d direct_budget_ms=%d wildcard_try=%d wildcard_budget_ms=%d nx_early_stop=%t nx_hard_quarantine=%t cooldown_enabled=%t cooldown_min_attempts=%d cooldown_timeout_rate=%d cooldown_fail_streak=%d cooldown_ban_sec=%d cooldown_max_ban_sec=%d live_batch_target=%d live_batch_min=%d live_batch_max=%d live_batch_nx_heavy_pct=%d live_batch_nx_heavy_min=%d live_batch_nx_heavy_max=%d stale_keep_sec=%d precheck_every_sec=%d precheck_max=%d precheck_forced_env=%t precheck_forced_file=%t",
directPolicy.TryLimit,
directPolicy.DomainBudget.Milliseconds(),
wildcardPolicy.TryLimit,
wildcardPolicy.DomainBudget.Milliseconds(),
resolveNXEarlyStopEnabled(),
resolveNXHardQuarantineEnabled(),
cEnabled,
cMin,
cRate,
cStreak,
cBan,
cMaxBan,
liveBatchTarget,
liveBatchMin,
liveBatchMax,
liveBatchNXHeavyPct,
liveBatchNXHeavyMin,
liveBatchNXHeavyMax,
staleKeepSec,
precheckEverySec,
precheckMaxDomains,
precheckEnvForced,
precheckFileForced,
)
}
start := time.Now()
fresh := map[string][]string{}
cacheNegativeHits := 0
quarantineHits := 0
staleHits := 0
var toResolve []string
for _, d := range domains {
source := cacheSourceForHost(d)
if ips, ok := domainCache.get(d, source, now, ttl); ok {
fresh[d] = ips
if logf != nil {
logf("cache hit[%s]: %s -> %v", source, d, ips)
}
continue
}
// Quarantine has priority over negative TTL cache so 24h quarantine
// is not silently overridden by shorter negative cache windows.
if state, age, ok := domainCache.getQuarantine(d, source, now); ok {
kind, hasKind := domainCache.getLastErrorKind(d, source)
timeoutKind := hasKind && kind == dnsErrorTimeout
if precheckDue && precheckScheduled < precheckMaxDomains {
// Timeout-based quarantine is rechecked in background batch and should
// not flood trace with per-domain debug lines.
if timeoutKind {
quarantineHits++
if staleKeepSec > 0 {
if staleIPs, staleAge, ok := domainCache.getStale(d, source, now, staleKeepSec); ok {
staleHits++
fresh[d] = staleIPs
if logf != nil {
logf("cache stale-keep (quarantine)[age=%ds]: %s -> %v", staleAge, d, staleIPs)
}
}
}
continue
}
precheckScheduled++
toResolve = append(toResolve, d)
if logf != nil {
logf("precheck schedule[quarantine/%s age=%ds]: %s (%s)", state, age, d, source)
}
continue
}
quarantineHits++
if logf != nil {
logf("cache quarantine hit[%s age=%ds]: %s (%s)", state, age, d, source)
}
if staleKeepSec > 0 {
if staleIPs, staleAge, ok := domainCache.getStale(d, source, now, staleKeepSec); ok {
staleHits++
fresh[d] = staleIPs
if logf != nil {
logf("cache stale-keep (quarantine)[age=%ds]: %s -> %v", staleAge, d, staleIPs)
}
}
}
continue
}
if kind, age, ok := domainCache.getNegative(d, source, now, negTTLNX, negTTLTimeout, negTTLTemporary, negTTLOther); ok {
if precheckDue && precheckScheduled < precheckMaxDomains {
if kind == dnsErrorTimeout {
cacheNegativeHits++
continue
}
precheckScheduled++
toResolve = append(toResolve, d)
if logf != nil {
logf("precheck schedule[negative/%s age=%ds]: %s (%s)", kind, age, d, source)
}
continue
}
cacheNegativeHits++
if logf != nil {
logf("cache neg hit[%s/%s age=%ds]: %s", source, kind, age, d)
}
continue
}
toResolve = append(toResolve, d)
}
resolved := map[string][]string{}
for k, v := range fresh {
resolved[k] = v
}
toResolveTotal := len(toResolve)
liveDeferred := 0
liveP1 := 0
liveP2 := 0
liveP3 := 0
liveNXHeavyTotal := 0
liveNXHeavySkip := 0
toResolve, liveP1, liveP2, liveP3, liveNXHeavyTotal, liveNXHeavySkip = pickAdaptiveLiveBatch(
toResolve,
liveBatchTarget,
now,
liveBatchNXHeavyPct,
domainCache,
cacheSourceForHost,
wildcards,
)
liveDeferred = toResolveTotal - len(toResolve)
if liveDeferred < 0 {
liveDeferred = 0
}
if logf != nil {
logf("resolve: domains=%d cache_hits=%d cache_neg_hits=%d quarantine_hits=%d stale_hits=%d precheck_due=%t precheck_scheduled=%d to_resolve=%d to_resolve_total=%d deferred_by_live_batch=%d live_p1=%d live_p2=%d live_p3=%d live_nxheavy_total=%d live_nxheavy_skip=%d", len(domains), len(fresh), cacheNegativeHits, quarantineHits, staleHits, precheckDue, precheckScheduled, len(toResolve), toResolveTotal, liveDeferred, liveP1, liveP2, liveP3, liveNXHeavyTotal, liveNXHeavySkip)
}
dnsStats := dnsMetrics{}
resolvedNowDNS := 0
resolvedNowStale := 0
unresolvedAfterAttempts := 0
if len(toResolve) > 0 {
type job struct {
host string
}
jobs := make(chan job, len(toResolve))
results := make(chan struct {
host string
ips []string
stats dnsMetrics
}, len(toResolve))
for i := 0; i < workers; i++ {
go func() {
for j := range jobs {
ips, stats := resolveHostGo(j.host, cfg, metaSpecial, wildcards, dnsTimeout, cooldown, logf)
results <- struct {
host string
ips []string
stats dnsMetrics
}{j.host, ips, stats}
}
}()
}
for _, h := range toResolve {
jobs <- job{host: h}
}
close(jobs)
for i := 0; i < len(toResolve); i++ {
r := <-results
dnsStats.merge(r.stats)
hostErrors := r.stats.totalErrors()
if hostErrors > 0 && logf != nil {
logf("resolve errors for %s: total=%d nxdomain=%d timeout=%d temporary=%d other=%d", r.host, hostErrors, r.stats.NXDomain, r.stats.Timeout, r.stats.Temporary, r.stats.Other)
}
if len(r.ips) > 0 {
resolved[r.host] = r.ips
resolvedNowDNS++
source := cacheSourceForHost(r.host)
domainCache.set(r.host, source, r.ips, now)
if logf != nil {
logf("%s -> %v", r.host, r.ips)
}
} else {
staleApplied := false
if hostErrors > 0 {
source := cacheSourceForHost(r.host)
domainCache.setErrorWithStats(r.host, source, r.stats, now)
if staleKeepSec > 0 && shouldUseStaleOnError(r.stats) {
if staleIPs, staleAge, ok := domainCache.getStale(r.host, source, now, staleKeepSec); ok {
staleHits++
resolvedNowStale++
staleApplied = true
resolved[r.host] = staleIPs
if logf != nil {
logf("cache stale-keep (error)[age=%ds]: %s -> %v", staleAge, r.host, staleIPs)
}
}
}
}
if !staleApplied {
unresolvedAfterAttempts++
}
if logf != nil {
if _, ok := resolved[r.host]; !ok {
logf("%s: no IPs", r.host)
}
}
}
}
}
staticEntries, staticSkipped := parseStaticEntriesGo(staticLines, logf)
staticLabels, ptrLookups, ptrErrors := resolveStaticLabels(staticEntries, cfg, ptrCache, ttl, logf)
ipSetAll := map[string]struct{}{}
ipSetDirect := map[string]struct{}{}
ipSetWildcard := map[string]struct{}{}
ipMapAll := map[string]map[string]struct{}{}
ipMapDirect := map[string]map[string]struct{}{}
ipMapWildcard := map[string]map[string]struct{}{}
add := func(set map[string]struct{}, labels map[string]map[string]struct{}, ip, label string) {
if ip == "" {
return
}
set[ip] = struct{}{}
m := labels[ip]
if m == nil {
m = map[string]struct{}{}
labels[ip] = m
}
m[label] = struct{}{}
}
isWildcardHost := func(host string) bool {
switch cfg.Mode {
case DNSModeSmartDNS:
return true
case DNSModeHybridWildcard:
return wildcards.match(host)
default:
return false
}
}
for host, ips := range resolved {
wildcardHost := isWildcardHost(host)
for _, ip := range ips {
add(ipSetAll, ipMapAll, ip, host)
if wildcardHost {
add(ipSetWildcard, ipMapWildcard, ip, host)
} else {
add(ipSetDirect, ipMapDirect, ip, host)
}
}
}
for ipEntry, labels := range staticLabels {
for _, lbl := range labels {
add(ipSetAll, ipMapAll, ipEntry, lbl)
// Static entries are explicit operator rules; keep them in direct set.
add(ipSetDirect, ipMapDirect, ipEntry, lbl)
}
}
appendMapPairs := func(dst *[][2]string, labelsByIP map[string]map[string]struct{}) {
for ip := range labelsByIP {
labels := labelsByIP[ip]
for lbl := range labels {
*dst = append(*dst, [2]string{ip, lbl})
}
}
sort.Slice(*dst, func(i, j int) bool {
if (*dst)[i][0] == (*dst)[j][0] {
return (*dst)[i][1] < (*dst)[j][1]
}
return (*dst)[i][0] < (*dst)[j][0]
})
}
appendIPs := func(dst *[]string, set map[string]struct{}) {
for ip := range set {
*dst = append(*dst, ip)
}
sort.Strings(*dst)
}
appendMapPairs(&res.IPMap, ipMapAll)
appendMapPairs(&res.DirectIPMap, ipMapDirect)
appendMapPairs(&res.WildcardIPMap, ipMapWildcard)
appendIPs(&res.IPs, ipSetAll)
appendIPs(&res.DirectIPs, ipSetDirect)
appendIPs(&res.WildcardIPs, ipSetWildcard)
res.DomainCache = domainCache.toMap()
res.PtrCache = ptrCache
if logf != nil {
dnsErrors := dnsStats.totalErrors()
unresolvedSuppressed := cacheNegativeHits + quarantineHits + liveDeferred
logf(
"resolve summary: domains=%d cache_hits=%d cache_neg_hits=%d quarantine_hits=%d stale_hits=%d resolved_now=%d unresolved=%d unresolved_live=%d unresolved_suppressed=%d live_batch_target=%d live_batch_deferred=%d live_batch_p1=%d live_batch_p2=%d live_batch_p3=%d live_batch_nxheavy_pct=%d live_batch_nxheavy_total=%d live_batch_nxheavy_skip=%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_cooldown_skips=%d dns_errors=%d timeout_recheck_checked=%d timeout_recheck_recovered=%d timeout_recheck_recovered_ips=%d timeout_recheck_still_timeout=%d timeout_recheck_now_nxdomain=%d timeout_recheck_now_temporary=%d timeout_recheck_now_other=%d timeout_recheck_no_signal=%d duration_ms=%d",
len(domains),
len(fresh),
cacheNegativeHits,
quarantineHits,
staleHits,
len(resolved)-len(fresh),
len(domains)-len(resolved),
unresolvedAfterAttempts,
unresolvedSuppressed,
liveBatchTarget,
liveDeferred,
liveP1,
liveP2,
liveP3,
liveBatchNXHeavyPct,
liveNXHeavyTotal,
liveNXHeavySkip,
len(staticEntries),
staticSkipped,
len(res.IPs),
len(res.DirectIPs),
len(res.WildcardIPs),
ptrLookups,
ptrErrors,
dnsStats.Attempts,
dnsStats.OK,
dnsStats.NXDomain,
dnsStats.Timeout,
dnsStats.Temporary,
dnsStats.Other,
dnsStats.Skipped,
dnsErrors,
timeoutRecheck.Checked,
timeoutRecheck.Recovered,
timeoutRecheck.RecoveredIPs,
timeoutRecheck.StillTimeout,
timeoutRecheck.NowNXDomain,
timeoutRecheck.NowTemporary,
timeoutRecheck.NowOther,
timeoutRecheck.NoSignal,
time.Since(start).Milliseconds(),
)
if perUpstream := dnsStats.formatPerUpstream(); perUpstream != "" {
logf("resolve dns upstreams: %s", perUpstream)
}
if health := dnsStats.formatResolverHealth(); health != "" {
logf("resolve dns health: %s", health)
}
if stateSummary := domainCache.formatStateSummary(now); stateSummary != "" {
logf("resolve domain states: %s", stateSummary)
}
logf(
"resolve breakdown: resolved_now_total=%d resolved_now_dns=%d resolved_now_stale=%d skipped_neg=%d skipped_quarantine=%d deferred_live_batch=%d unresolved_after_attempts=%d",
len(resolved)-len(fresh),
resolvedNowDNS,
resolvedNowStale,
cacheNegativeHits,
quarantineHits,
liveDeferred,
unresolvedAfterAttempts,
)
if precheckDue {
logf("resolve precheck done: scheduled=%d state=%s", precheckScheduled, precheckStatePath)
}
}
if precheckDue {
nextTarget, nextReason := computeNextLiveBatchTarget(liveBatchTarget, liveBatchMin, liveBatchMax, dnsStats, liveDeferred)
nextNXPct, nextNXReason := computeNextLiveBatchNXHeavyPct(
liveBatchNXHeavyPct,
liveBatchNXHeavyMin,
liveBatchNXHeavyMax,
dnsStats,
resolvedNowDNS,
liveP3,
liveNXHeavyTotal,
liveNXHeavySkip,
)
if logf != nil {
logf(
"resolve live-batch nxheavy: pct=%d next=%d reason=%s selected=%d total=%d skipped=%d",
liveBatchNXHeavyPct,
nextNXPct,
nextNXReason,
liveP3,
liveNXHeavyTotal,
liveNXHeavySkip,
)
}
saveResolverPrecheckState(
precheckStatePath,
now,
timeoutRecheck,
resolverLiveBatchStats{
Target: liveBatchTarget,
Total: toResolveTotal,
Deferred: liveDeferred,
P1: liveP1,
P2: liveP2,
P3: liveP3,
NXHeavyPct: liveBatchNXHeavyPct,
NXHeavyTotal: liveNXHeavyTotal,
NXHeavySkip: liveNXHeavySkip,
NextTarget: nextTarget,
NextReason: nextReason,
NextNXPct: nextNXPct,
NextNXReason: nextNXReason,
DNSAttempts: dnsStats.Attempts,
DNSTimeout: dnsStats.Timeout,
DNSCoolSkips: dnsStats.Skipped,
},
)
}
if precheckFileForced {
_ = os.Remove(precheckForcePath)
if logf != nil {
logf("resolve precheck force-file consumed: %s", precheckForcePath)
}
}
return res, nil
}
// ---------------------------------------------------------------------
// DNS resolve helpers
// ---------------------------------------------------------------------
func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards wildcardMatcher, timeout time.Duration, cooldown *dnsRunCooldown, logf func(string, ...any)) ([]string, dnsMetrics) {
useMeta := false
for _, m := range metaSpecial {
if host == m {
useMeta = true
break
}
}
dnsList := cfg.Default
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
}
}
policy := directDNSAttemptPolicy(len(dnsList))
if primaryViaSmartDNS {
policy = wildcardDNSAttemptPolicy(len(dnsList))
}
ips, stats := digAWithPolicy(host, dnsList, timeout, logf, policy, cooldown)
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,
)
}
fallbackPolicy := wildcardDNSAttemptPolicy(1)
fallbackIPs, fallbackStats := digAWithPolicy(host, []string{cfg.SmartDNS}, timeout, logf, fallbackPolicy, cooldown)
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 {
if isPrivateIPv4(ip) {
continue
}
if _, ok := seen[ip]; !ok {
seen[ip] = struct{}{}
out = append(out, ip)
}
}
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
}
func shouldUseStaleOnError(stats dnsMetrics) bool {
if stats.OK > 0 {
return false
}
return stats.Timeout > 0 || stats.Temporary > 0 || stats.Other > 0
}
func runTimeoutQuarantineRecheck(
domains []string,
cfg dnsConfig,
metaSpecial []string,
wildcards wildcardMatcher,
timeout time.Duration,
domainCache *domainCacheState,
cacheSourceForHost func(string) domainCacheSource,
now int,
limit int,
workers int,
) resolverTimeoutRecheckStats {
stats := resolverTimeoutRecheckStats{}
if limit <= 0 || now <= 0 {
return stats
}
if workers < 1 {
workers = 1
}
if workers > 200 {
workers = 200
}
seen := map[string]struct{}{}
capHint := len(domains)
if capHint > limit {
capHint = limit
}
candidates := make([]string, 0, capHint)
for _, raw := range domains {
host := strings.TrimSpace(strings.ToLower(raw))
if host == "" {
continue
}
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
source := cacheSourceForHost(host)
if _, _, ok := domainCache.getQuarantine(host, source, now); !ok {
continue
}
kind, ok := domainCache.getLastErrorKind(host, source)
if !ok || kind != dnsErrorTimeout {
continue
}
candidates = append(candidates, host)
if len(candidates) >= limit {
break
}
}
if len(candidates) == 0 {
return stats
}
recoveredIPSet := map[string]struct{}{}
type result struct {
host string
source domainCacheSource
ips []string
dns dnsMetrics
}
jobs := make(chan string, len(candidates))
results := make(chan result, len(candidates))
for i := 0; i < workers; i++ {
go func() {
for host := range jobs {
src := cacheSourceForHost(host)
ips, dnsStats := resolveHostGo(host, cfg, metaSpecial, wildcards, timeout, nil, nil)
results <- result{host: host, source: src, ips: ips, dns: dnsStats}
}
}()
}
for _, host := range candidates {
jobs <- host
}
close(jobs)
for i := 0; i < len(candidates); i++ {
r := <-results
stats.Checked++
if len(r.ips) > 0 {
for _, ip := range r.ips {
ip = strings.TrimSpace(ip)
if ip == "" {
continue
}
recoveredIPSet[ip] = struct{}{}
}
domainCache.set(r.host, r.source, r.ips, now)
stats.Recovered++
continue
}
if r.dns.totalErrors() > 0 {
domainCache.setErrorWithStats(r.host, r.source, r.dns, now)
}
kind, ok := classifyHostErrorKind(r.dns)
if !ok {
stats.NoSignal++
continue
}
switch kind {
case dnsErrorTimeout:
stats.StillTimeout++
case dnsErrorNXDomain:
stats.NowNXDomain++
case dnsErrorTemporary:
stats.NowTemporary++
default:
stats.NowOther++
}
}
stats.RecoveredIPs = len(recoveredIPSet)
return stats
}
// ---------------------------------------------------------------------
// EN: `digA` contains core logic for dig a.
// RU: `digA` - содержит основную логику для dig a.
// ---------------------------------------------------------------------
func digA(host string, dnsList []string, timeout time.Duration, logf func(string, ...any)) ([]string, dnsMetrics) {
return digAWithPolicy(host, dnsList, timeout, logf, defaultDNSAttemptPolicy(len(dnsList)), nil)
}
func digAWithPolicy(host string, dnsList []string, timeout time.Duration, logf func(string, ...any), policy dnsAttemptPolicy, cooldown *dnsRunCooldown) ([]string, dnsMetrics) {
stats := dnsMetrics{}
if len(dnsList) == 0 {
return nil, stats
}
tryLimit := policy.TryLimit
if tryLimit <= 0 {
tryLimit = 1
}
if tryLimit > len(dnsList) {
tryLimit = len(dnsList)
}
budget := policy.DomainBudget
if budget <= 0 {
budget = time.Duration(tryLimit) * timeout
}
if budget < 200*time.Millisecond {
budget = 200 * time.Millisecond
}
deadline := time.Now().Add(budget)
start := pickDNSStartIndex(host, len(dnsList))
for attempt := 0; attempt < tryLimit; attempt++ {
remaining := time.Until(deadline)
if remaining <= 0 {
if logf != nil {
logf("dns budget exhausted %s: attempts=%d budget_ms=%d", host, attempt, budget.Milliseconds())
}
break
}
entry := dnsList[(start+attempt)%len(dnsList)]
server, port := splitDNS(entry)
if server == "" {
continue
}
if port == "" {
port = "53"
}
addr := net.JoinHostPort(server, port)
if cooldown != nil && cooldown.shouldSkip(addr, time.Now().Unix()) {
stats.addCooldownSkip(addr)
continue
}
r := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, "udp", addr)
},
}
perAttemptTimeout := timeout
if remaining < perAttemptTimeout {
perAttemptTimeout = remaining
}
if perAttemptTimeout < 100*time.Millisecond {
perAttemptTimeout = 100 * time.Millisecond
}
ctx, cancel := context.WithTimeout(context.Background(), perAttemptTimeout)
records, err := r.LookupHost(ctx, host)
cancel()
if err != nil {
kind := classifyDNSError(err)
stats.addError(addr, kind)
if cooldown != nil {
if banned, banSec := cooldown.observeError(addr, kind, time.Now().Unix()); banned && logf != nil {
logf("dns cooldown ban %s: timeout-like failures; ban_sec=%d", addr, banSec)
}
}
if logf != nil {
logf("dns warn %s via %s: kind=%s attempt=%d/%d err=%v", host, addr, kind, attempt+1, tryLimit, err)
}
if policy.StopOnNX && kind == dnsErrorNXDomain {
if logf != nil {
logf("dns early-stop %s: nxdomain via %s (attempt=%d/%d)", host, addr, attempt+1, tryLimit)
}
break
}
continue
}
var ips []string
for _, ip := range records {
if isPrivateIPv4(ip) {
continue
}
ips = append(ips, ip)
}
if len(ips) == 0 {
stats.addError(addr, dnsErrorOther)
if cooldown != nil {
_, _ = cooldown.observeError(addr, dnsErrorOther, time.Now().Unix())
}
if logf != nil {
logf("dns warn %s via %s: kind=other err=no_public_ips", host, addr)
}
continue
}
stats.addSuccess(addr)
if cooldown != nil {
cooldown.observeSuccess(addr)
}
return uniqueStrings(ips), stats
}
return nil, stats
}
func defaultDNSAttemptPolicy(dnsCount int) dnsAttemptPolicy {
tryLimit := envInt("RESOLVE_DNS_TRY_LIMIT", 2)
if tryLimit < 1 {
tryLimit = 1
}
if dnsCount > 0 && tryLimit > dnsCount {
tryLimit = dnsCount
}
budgetMS := envInt("RESOLVE_DNS_DOMAIN_BUDGET_MS", 1200)
if budgetMS < 200 {
budgetMS = 200
}
if budgetMS > 15000 {
budgetMS = 15000
}
return dnsAttemptPolicy{
TryLimit: tryLimit,
DomainBudget: time.Duration(budgetMS) * time.Millisecond,
StopOnNX: resolveNXEarlyStopEnabled(),
}
}
func directDNSAttemptPolicy(dnsCount int) dnsAttemptPolicy {
tryLimit := envInt("RESOLVE_DIRECT_TRY_LIMIT", 2)
if tryLimit < 1 {
tryLimit = 1
}
if tryLimit > 3 {
tryLimit = 3
}
if dnsCount > 0 && tryLimit > dnsCount {
tryLimit = dnsCount
}
budgetMS := envInt("RESOLVE_DIRECT_BUDGET_MS", 1200)
if budgetMS < 200 {
budgetMS = 200
}
if budgetMS > 15000 {
budgetMS = 15000
}
return dnsAttemptPolicy{
TryLimit: tryLimit,
DomainBudget: time.Duration(budgetMS) * time.Millisecond,
StopOnNX: resolveNXEarlyStopEnabled(),
}
}
func wildcardDNSAttemptPolicy(dnsCount int) dnsAttemptPolicy {
tryLimit := envInt("RESOLVE_WILDCARD_TRY_LIMIT", 1)
if tryLimit < 1 {
tryLimit = 1
}
if tryLimit > 2 {
tryLimit = 2
}
if dnsCount > 0 && tryLimit > dnsCount {
tryLimit = dnsCount
}
budgetMS := envInt("RESOLVE_WILDCARD_BUDGET_MS", 1200)
if budgetMS < 200 {
budgetMS = 200
}
if budgetMS > 15000 {
budgetMS = 15000
}
return dnsAttemptPolicy{
TryLimit: tryLimit,
DomainBudget: time.Duration(budgetMS) * time.Millisecond,
StopOnNX: resolveNXEarlyStopEnabled(),
}
}
func resolveNXEarlyStopEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_NX_EARLY_STOP"))) {
case "0", "false", "no", "off":
return false
default:
return true
}
}
func resolveNXHardQuarantineEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_NX_HARD_QUARANTINE"))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func newDNSRunCooldown() *dnsRunCooldown {
enabled := true
switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_DNS_COOLDOWN_ENABLED"))) {
case "0", "false", "no", "off":
enabled = false
}
c := &dnsRunCooldown{
enabled: enabled,
minAttempts: envInt("RESOLVE_DNS_COOLDOWN_MIN_ATTEMPTS", 300),
timeoutRatePct: envInt("RESOLVE_DNS_COOLDOWN_TIMEOUT_RATE_PCT", 70),
failStreak: envInt("RESOLVE_DNS_COOLDOWN_FAIL_STREAK", 25),
banSec: envInt("RESOLVE_DNS_COOLDOWN_BAN_SEC", 60),
maxBanSec: envInt("RESOLVE_DNS_COOLDOWN_MAX_BAN_SEC", 300),
temporaryAsError: true,
byUpstream: map[string]*dnsCooldownState{},
}
if c.minAttempts < 50 {
c.minAttempts = 50
}
if c.minAttempts > 2000 {
c.minAttempts = 2000
}
if c.timeoutRatePct < 40 {
c.timeoutRatePct = 40
}
if c.timeoutRatePct > 95 {
c.timeoutRatePct = 95
}
if c.failStreak < 8 {
c.failStreak = 8
}
if c.failStreak > 200 {
c.failStreak = 200
}
if c.banSec < 10 {
c.banSec = 10
}
if c.banSec > 3600 {
c.banSec = 3600
}
if c.maxBanSec < c.banSec {
c.maxBanSec = c.banSec
}
if c.maxBanSec > 3600 {
c.maxBanSec = 3600
}
return c
}
func (c *dnsRunCooldown) configSnapshot() (enabled bool, minAttempts, timeoutRatePct, failStreak, banSec, maxBanSec int) {
if c == nil {
return false, 0, 0, 0, 0, 0
}
return c.enabled, c.minAttempts, c.timeoutRatePct, c.failStreak, c.banSec, c.maxBanSec
}
func (c *dnsRunCooldown) stateFor(upstream string) *dnsCooldownState {
if c.byUpstream == nil {
c.byUpstream = map[string]*dnsCooldownState{}
}
st, ok := c.byUpstream[upstream]
if ok {
return st
}
st = &dnsCooldownState{}
c.byUpstream[upstream] = st
return st
}
func (c *dnsRunCooldown) shouldSkip(upstream string, now int64) bool {
if c == nil || !c.enabled {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
st := c.stateFor(upstream)
return st.BanUntil > now
}
func (c *dnsRunCooldown) observeSuccess(upstream string) {
if c == nil || !c.enabled {
return
}
c.mu.Lock()
defer c.mu.Unlock()
st := c.stateFor(upstream)
st.Attempts++
st.FailStreak = 0
}
func (c *dnsRunCooldown) observeError(upstream string, kind dnsErrorKind, now int64) (bool, int) {
if c == nil || !c.enabled {
return false, 0
}
c.mu.Lock()
defer c.mu.Unlock()
st := c.stateFor(upstream)
st.Attempts++
timeoutLike := kind == dnsErrorTimeout || (c.temporaryAsError && kind == dnsErrorTemporary)
if timeoutLike {
st.TimeoutLike++
st.FailStreak++
} else {
st.FailStreak = 0
return false, 0
}
if st.BanUntil > now {
return false, 0
}
rateBan := st.Attempts >= c.minAttempts && (st.TimeoutLike*100 >= c.timeoutRatePct*st.Attempts)
streakBan := st.FailStreak >= c.failStreak
if !rateBan && !streakBan {
return false, 0
}
st.BanLevel++
dur := c.banSec
if st.BanLevel > 1 {
for i := 1; i < st.BanLevel; i++ {
dur *= 2
if dur >= c.maxBanSec {
dur = c.maxBanSec
break
}
}
}
if dur > c.maxBanSec {
dur = c.maxBanSec
}
st.BanUntil = now + int64(dur)
st.FailStreak = 0
return true, dur
}
func resolvePrecheckForceEnvEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_PRECHECK_FORCE"))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func resolvePrecheckForceFileEnabled(path string) bool {
if strings.TrimSpace(path) == "" {
return false
}
_, err := os.Stat(path)
return err == nil
}
func classifyDNSError(err error) dnsErrorKind {
if err == nil {
return dnsErrorOther
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound {
return dnsErrorNXDomain
}
if dnsErr.IsTimeout {
return dnsErrorTimeout
}
if dnsErr.IsTemporary {
return dnsErrorTemporary
}
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "no such host"), strings.Contains(msg, "nxdomain"):
return dnsErrorNXDomain
case strings.Contains(msg, "i/o timeout"), strings.Contains(msg, "timeout"):
return dnsErrorTimeout
case strings.Contains(msg, "temporary"):
return dnsErrorTemporary
default:
return dnsErrorOther
}
}
// ---------------------------------------------------------------------
// EN: `splitDNS` splits dns into structured parts.
// RU: `splitDNS` - разделяет dns на структурированные части.
// ---------------------------------------------------------------------
func splitDNS(dns string) (string, string) {
if strings.Contains(dns, "#") {
parts := strings.SplitN(dns, "#", 2)
host := strings.TrimSpace(parts[0])
port := strings.TrimSpace(parts[1])
if host == "" {
host = "127.0.0.1"
}
if port == "" {
port = "53"
}
return host, port
}
return strings.TrimSpace(dns), ""
}
// ---------------------------------------------------------------------
// static entries + PTR labels
// ---------------------------------------------------------------------
func parseStaticEntriesGo(lines []string, logf func(string, ...any)) (entries [][3]string, skipped int) {
for _, ln := range lines {
s := strings.TrimSpace(ln)
if s == "" || strings.HasPrefix(s, "#") {
continue
}
comment := ""
if idx := strings.Index(s, "#"); idx >= 0 {
comment = strings.TrimSpace(s[idx+1:])
s = strings.TrimSpace(s[:idx])
}
if s == "" || isPrivateIPv4(s) {
continue
}
// validate ip/prefix
rawBase := strings.SplitN(s, "/", 2)[0]
if strings.Contains(s, "/") {
if _, err := netip.ParsePrefix(s); err != nil {
skipped++
if logf != nil {
logf("static skip invalid prefix %q: %v", s, err)
}
continue
}
} else {
if _, err := netip.ParseAddr(rawBase); err != nil {
skipped++
if logf != nil {
logf("static skip invalid ip %q: %v", s, err)
}
continue
}
}
entries = append(entries, [3]string{s, rawBase, comment})
}
return entries, skipped
}
// ---------------------------------------------------------------------
// EN: `resolveStaticLabels` resolves static labels into concrete values.
// RU: `resolveStaticLabels` - резолвит static labels в конкретные значения.
// ---------------------------------------------------------------------
func resolveStaticLabels(entries [][3]string, cfg dnsConfig, ptrCache map[string]any, ttl int, logf func(string, ...any)) (map[string][]string, int, int) {
now := int(time.Now().Unix())
result := map[string][]string{}
ptrLookups := 0
ptrErrors := 0
dnsForPtr := ""
if len(cfg.Default) > 0 {
dnsForPtr = cfg.Default[0]
} else {
dnsForPtr = defaultDNS1
}
for _, e := range entries {
ipEntry, baseIP, comment := e[0], e[1], e[2]
var labels []string
if comment != "" {
labels = append(labels, "*"+comment)
}
if comment == "" {
if cached, ok := ptrCache[baseIP].(map[string]any); ok {
names, _ := cached["names"].([]any)
last, _ := cached["last_resolved"].(float64)
if len(names) > 0 && last > 0 && now-int(last) <= ttl {
for _, n := range names {
if s, ok := n.(string); ok && s != "" {
labels = append(labels, "*"+s)
}
}
}
}
if len(labels) == 0 {
ptrLookups++
names, err := digPTR(baseIP, dnsForPtr, 3*time.Second, logf)
if err != nil {
ptrErrors++
}
if len(names) > 0 {
ptrCache[baseIP] = map[string]any{"names": names, "last_resolved": now}
for _, n := range names {
labels = append(labels, "*"+n)
}
}
}
}
if len(labels) == 0 {
labels = []string{"*[STATIC-IP]"}
}
result[ipEntry] = labels
if logf != nil {
logf("static %s -> %v", ipEntry, labels)
}
}
return result, ptrLookups, ptrErrors
}
// ---------------------------------------------------------------------
// DNS config + cache helpers
// ---------------------------------------------------------------------
type domainCacheSource string
const (
domainCacheSourceDirect domainCacheSource = "direct"
domainCacheSourceWildcard domainCacheSource = "wildcard"
)
type domainCacheEntry struct {
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"`
Score int `json:"score,omitempty"`
State string `json:"state,omitempty"`
QuarantineUntil int `json:"quarantine_until,omitempty"`
}
type domainCacheRecord struct {
Direct *domainCacheEntry `json:"direct,omitempty"`
Wildcard *domainCacheEntry `json:"wildcard,omitempty"`
}
type domainCacheState struct {
Version int `json:"version"`
Domains map[string]domainCacheRecord `json:"domains"`
}
func newDomainCacheState() domainCacheState {
return domainCacheState{
Version: 4,
Domains: map[string]domainCacheRecord{},
}
}
func normalizeCacheIPs(raw []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(raw))
for _, ip := range raw {
ip = strings.TrimSpace(ip)
if ip == "" || isPrivateIPv4(ip) {
continue
}
if _, ok := seen[ip]; ok {
continue
}
seen[ip] = struct{}{}
out = append(out, ip)
}
sort.Strings(out)
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
}
out.Score = clampDomainScore(in.Score)
if st := normalizeDomainState(in.State, out.Score); st != "" {
out.State = st
}
if in.QuarantineUntil > 0 {
out.QuarantineUntil = in.QuarantineUntil
}
if out.LastResolved <= 0 && out.LastErrorAt <= 0 {
if out.Score == 0 && out.QuarantineUntil <= 0 {
return nil
}
}
return out
}
func parseAnyStringSlice(raw any) []string {
switch v := raw.(type) {
case []string:
return append([]string(nil), v...)
case []any:
out := make([]string, 0, len(v))
for _, x := range v {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func parseAnyInt(raw any) (int, bool) {
switch v := raw.(type) {
case int:
return v, true
case int64:
return int(v), true
case float64:
return int(v), true
case json.Number:
n, err := v.Int64()
if err != nil {
return 0, false
}
return int(n), true
default:
return 0, false
}
}
func parseLegacyDomainCacheEntry(raw any) (domainCacheEntry, bool) {
m, ok := raw.(map[string]any)
if !ok {
return domainCacheEntry{}, false
}
ips := normalizeCacheIPs(parseAnyStringSlice(m["ips"]))
if len(ips) == 0 {
return domainCacheEntry{}, false
}
ts, ok := parseAnyInt(m["last_resolved"])
if !ok || ts <= 0 {
return domainCacheEntry{}, false
}
return domainCacheEntry{IPs: ips, LastResolved: ts}, true
}
func loadDomainCacheState(path string, logf func(string, ...any)) domainCacheState {
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return newDomainCacheState()
}
var st domainCacheState
if err := json.Unmarshal(data, &st); err == nil && st.Domains != nil {
if st.Version <= 0 {
st.Version = 4
}
normalized := newDomainCacheState()
for host, rec := range st.Domains {
host = strings.TrimSpace(strings.ToLower(host))
if host == "" {
continue
}
nrec := domainCacheRecord{}
nrec.Direct = normalizeDomainCacheEntry(rec.Direct)
nrec.Wildcard = normalizeDomainCacheEntry(rec.Wildcard)
if nrec.Direct != nil || nrec.Wildcard != nil {
normalized.Domains[host] = nrec
}
}
return normalized
}
// Legacy shape: { "domain.tld": {"ips":[...], "last_resolved":...}, ... }
var legacy map[string]any
if err := json.Unmarshal(data, &legacy); err != nil {
if logf != nil {
logf("domain-cache: invalid json at %s, ignore", path)
}
return newDomainCacheState()
}
out := newDomainCacheState()
migrated := 0
for host, raw := range legacy {
host = strings.TrimSpace(strings.ToLower(host))
if host == "" || host == "version" || host == "domains" {
continue
}
entry, ok := parseLegacyDomainCacheEntry(raw)
if !ok {
continue
}
rec := out.Domains[host]
rec.Direct = &entry
out.Domains[host] = rec
migrated++
}
if logf != nil && migrated > 0 {
logf("domain-cache: migrated legacy entries=%d into split cache (direct bucket)", migrated)
}
return out
}
func (s domainCacheState) get(domain string, source domainCacheSource, now, ttl int) ([]string, bool) {
rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))]
if !ok {
return nil, false
}
var entry *domainCacheEntry
switch source {
case domainCacheSourceWildcard:
entry = rec.Wildcard
default:
entry = rec.Direct
}
if entry == nil || entry.LastResolved <= 0 {
return nil, false
}
if now-entry.LastResolved > ttl {
return nil, false
}
ips := normalizeCacheIPs(entry.IPs)
if len(ips) == 0 {
return nil, false
}
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) getStoredIPs(domain string, source domainCacheSource) []string {
rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))]
if !ok {
return nil
}
entry := getCacheEntryBySource(rec, source)
if entry == nil {
return nil
}
return normalizeCacheIPs(entry.IPs)
}
func (s domainCacheState) getLastErrorKind(domain string, source domainCacheSource) (dnsErrorKind, bool) {
rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))]
if !ok {
return "", false
}
entry := getCacheEntryBySource(rec, source)
if entry == nil || entry.LastErrorAt <= 0 {
return "", false
}
return normalizeCacheErrorKind(entry.LastErrorKind)
}
func (s domainCacheState) getQuarantine(domain string, source domainCacheSource, now int) (string, int, bool) {
rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))]
if !ok {
return "", 0, false
}
entry := getCacheEntryBySource(rec, source)
if entry == nil || entry.QuarantineUntil <= 0 {
return "", 0, false
}
if now >= entry.QuarantineUntil {
return "", 0, false
}
state := normalizeDomainState(entry.State, entry.Score)
if state == "" {
state = domainStateQuarantine
}
age := 0
if entry.LastErrorAt > 0 {
age = now - entry.LastErrorAt
}
return state, age, true
}
func (s domainCacheState) getStale(domain string, source domainCacheSource, now, maxAge int) ([]string, int, bool) {
if maxAge <= 0 {
return nil, 0, false
}
rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))]
if !ok {
return nil, 0, false
}
entry := getCacheEntryBySource(rec, source)
if entry == nil || entry.LastResolved <= 0 {
return nil, 0, false
}
age := now - entry.LastResolved
if age < 0 || age > maxAge {
return nil, 0, false
}
ips := normalizeCacheIPs(entry.IPs)
if len(ips) == 0 {
return nil, 0, false
}
return ips, age, true
}
func (s *domainCacheState) set(domain string, source domainCacheSource, ips []string, now int) {
host := strings.TrimSpace(strings.ToLower(domain))
if host == "" || now <= 0 {
return
}
norm := normalizeCacheIPs(ips)
if len(norm) == 0 {
return
}
if s.Domains == nil {
s.Domains = map[string]domainCacheRecord{}
}
rec := s.Domains[host]
prev := getCacheEntryBySource(rec, source)
prevScore := 0
if prev != nil {
prevScore = prev.Score
}
entry := &domainCacheEntry{
IPs: norm,
LastResolved: now,
LastErrorKind: "",
LastErrorAt: 0,
Score: clampDomainScore(prevScore + envInt("RESOLVE_DOMAIN_SCORE_OK", 8)),
QuarantineUntil: 0,
}
entry.State = domainStateFromScore(entry.Score)
switch source {
case domainCacheSourceWildcard:
rec.Wildcard = entry
default:
rec.Direct = entry
}
s.Domains[host] = rec
}
func getCacheEntryBySource(rec domainCacheRecord, source domainCacheSource) *domainCacheEntry {
switch source {
case domainCacheSourceWildcard:
return rec.Wildcard
default:
return rec.Direct
}
}
func clampDomainScore(v int) int {
if v < domainScoreMin {
return domainScoreMin
}
if v > domainScoreMax {
return domainScoreMax
}
return v
}
func domainStateFromScore(score int) string {
switch {
case score >= 20:
return domainStateActive
case score >= 5:
return domainStateStable
case score >= -10:
return domainStateSuspect
case score >= -30:
return domainStateQuarantine
default:
return domainStateHardQuar
}
}
func normalizeDomainState(raw string, score int) string {
switch strings.TrimSpace(strings.ToLower(raw)) {
case domainStateActive:
return domainStateActive
case domainStateStable:
return domainStateStable
case domainStateSuspect:
return domainStateSuspect
case domainStateQuarantine:
return domainStateQuarantine
case domainStateHardQuar:
return domainStateHardQuar
default:
if score == 0 {
return ""
}
return domainStateFromScore(score)
}
}
func domainScorePenalty(stats dnsMetrics) int {
if stats.NXDomain >= 2 {
return envInt("RESOLVE_DOMAIN_SCORE_NX_CONFIRMED", -15)
}
if stats.NXDomain > 0 {
return envInt("RESOLVE_DOMAIN_SCORE_NX_SINGLE", -7)
}
if stats.Timeout > 0 {
return envInt("RESOLVE_DOMAIN_SCORE_TIMEOUT", -3)
}
if stats.Temporary > 0 {
return envInt("RESOLVE_DOMAIN_SCORE_TEMPORARY", -2)
}
return envInt("RESOLVE_DOMAIN_SCORE_OTHER", -2)
}
func (s *domainCacheState) setErrorWithStats(domain string, source domainCacheSource, stats dnsMetrics, now int) {
host := strings.TrimSpace(strings.ToLower(domain))
if host == "" || now <= 0 {
return
}
kind, ok := classifyHostErrorKind(stats)
if !ok {
return
}
normKind, ok := normalizeCacheErrorKind(string(kind))
if !ok {
return
}
penalty := domainScorePenalty(stats)
quarantineTTL := envInt("RESOLVE_QUARANTINE_TTL_SEC", defaultQuarantineTTL)
if quarantineTTL < 0 {
quarantineTTL = 0
}
hardQuarantineTTL := envInt("RESOLVE_HARD_QUARANTINE_TTL_SEC", defaultHardQuarantineTT)
if hardQuarantineTTL < 0 {
hardQuarantineTTL = 0
}
if s.Domains == nil {
s.Domains = map[string]domainCacheRecord{}
}
rec := s.Domains[host]
entry := getCacheEntryBySource(rec, source)
if entry == nil {
entry = &domainCacheEntry{}
}
prevKind, _ := normalizeCacheErrorKind(entry.LastErrorKind)
entry.Score = clampDomainScore(entry.Score + penalty)
entry.State = domainStateFromScore(entry.Score)
// Timeout-only failures are treated as transient transport noise by default.
// Keep them in suspect bucket (no quarantine) unless we have NX signal.
if normKind == dnsErrorTimeout && prevKind != dnsErrorNXDomain {
if entry.Score < -10 {
entry.Score = -10
}
entry.State = domainStateSuspect
}
// NXDOMAIN-heavy synthetic subdomains create large false "hard quarantine" pools.
// By default, keep NX failures in regular quarantine (24h), not hard quarantine.
if normKind == dnsErrorNXDomain && !resolveNXHardQuarantineEnabled() && entry.State == domainStateHardQuar {
entry.State = domainStateQuarantine
if entry.Score < -30 {
entry.Score = -30
}
}
entry.LastErrorKind = string(normKind)
entry.LastErrorAt = now
switch entry.State {
case domainStateHardQuar:
entry.QuarantineUntil = now + hardQuarantineTTL
case domainStateQuarantine:
entry.QuarantineUntil = now + quarantineTTL
default:
entry.QuarantineUntil = 0
}
switch source {
case domainCacheSourceWildcard:
rec.Wildcard = entry
default:
rec.Direct = entry
}
s.Domains[host] = rec
}
func (s domainCacheState) toMap() map[string]any {
out := map[string]any{
"version": 4,
"domains": map[string]any{},
}
domainsAny := out["domains"].(map[string]any)
hosts := make([]string, 0, len(s.Domains))
for host := range s.Domains {
hosts = append(hosts, host)
}
sort.Strings(hosts)
for _, host := range hosts {
rec := s.Domains[host]
recOut := map[string]any{}
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 rec.Direct.Score != 0 {
directOut["score"] = rec.Direct.Score
}
if st := normalizeDomainState(rec.Direct.State, rec.Direct.Score); st != "" {
directOut["state"] = st
}
if rec.Direct.QuarantineUntil > 0 {
directOut["quarantine_until"] = rec.Direct.QuarantineUntil
}
if len(directOut) > 0 {
recOut["direct"] = directOut
}
}
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 rec.Wildcard.Score != 0 {
wildOut["score"] = rec.Wildcard.Score
}
if st := normalizeDomainState(rec.Wildcard.State, rec.Wildcard.Score); st != "" {
wildOut["state"] = st
}
if rec.Wildcard.QuarantineUntil > 0 {
wildOut["quarantine_until"] = rec.Wildcard.QuarantineUntil
}
if len(wildOut) > 0 {
recOut["wildcard"] = wildOut
}
}
if len(recOut) > 0 {
domainsAny[host] = recOut
}
}
return out
}
func (s domainCacheState) formatStateSummary(now int) string {
type counters struct {
active int
stable int
suspect int
quarantine int
hardQuar int
}
add := func(c *counters, entry *domainCacheEntry) {
if entry == nil {
return
}
st := normalizeDomainState(entry.State, entry.Score)
if entry.QuarantineUntil > now {
// Keep hard quarantine state if explicitly marked,
// otherwise active quarantine bucket.
if st == domainStateHardQuar {
c.hardQuar++
return
}
c.quarantine++
return
}
switch st {
case domainStateActive:
c.active++
case domainStateStable:
c.stable++
case domainStateSuspect:
c.suspect++
case domainStateQuarantine:
c.quarantine++
case domainStateHardQuar:
c.hardQuar++
}
}
var c counters
for _, rec := range s.Domains {
add(&c, rec.Direct)
add(&c, rec.Wildcard)
}
total := c.active + c.stable + c.suspect + c.quarantine + c.hardQuar
if total == 0 {
return ""
}
return fmt.Sprintf(
"active=%d stable=%d suspect=%d quarantine=%d hard_quarantine=%d total=%d",
c.active, c.stable, c.suspect, c.quarantine, c.hardQuar, total,
)
}
func digPTR(ip, upstream string, timeout time.Duration, logf func(string, ...any)) ([]string, error) {
server, port := splitDNS(upstream)
if server == "" {
return nil, fmt.Errorf("upstream empty")
}
if port == "" {
port = "53"
}
addr := net.JoinHostPort(server, port)
r := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, "udp", addr)
},
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
names, err := r.LookupAddr(ctx, ip)
cancel()
if err != nil {
if logf != nil {
logf("ptr error %s via %s: %v", ip, addr, err)
}
return nil, err
}
seen := map[string]struct{}{}
var out []string
for _, n := range names {
n = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(n)), ".")
if n == "" {
continue
}
if _, ok := seen[n]; !ok {
seen[n] = struct{}{}
out = append(out, n)
}
}
return out, nil
}
// ---------------------------------------------------------------------
// EN: `loadDNSConfig` loads dns config from storage or config.
// RU: `loadDNSConfig` - загружает dns config из хранилища или конфига.
// ---------------------------------------------------------------------
func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig {
cfg := dnsConfig{
Default: []string{defaultDNS1, defaultDNS2},
Meta: []string{defaultMeta1, defaultMeta2},
SmartDNS: smartDNSAddr(),
Mode: DNSModeDirect,
}
activePool := loadEnabledDNSUpstreamPool()
if len(activePool) > 0 {
cfg.Default = activePool
cfg.Meta = activePool
}
// 1) Если форсируем SmartDNS — вообще игнорим файл и ходим только через локальный резолвер.
if smartDNSForced() {
addr := smartDNSAddr()
cfg.Default = []string{addr}
cfg.Meta = []string{addr}
cfg.SmartDNS = addr
cfg.Mode = DNSModeSmartDNS
if logf != nil {
logf("dns-config: SmartDNS forced (%s), ignore %s", addr, path)
}
return cfg
}
// 2) Читаем dns-upstreams.conf для legacy-совместимости и smartdns/mode значений.
data, err := os.ReadFile(path)
if err != nil {
if logf != nil {
logf("dns-config: can't read %s: %v", path, err)
}
cfg.Default = mergeDNSUpstreamPools(cfg.Default, resolverFallbackPool())
cfg.Meta = mergeDNSUpstreamPools(cfg.Meta, resolverFallbackPool())
return cfg
}
var def, meta []string
lines := strings.Split(string(data), "\n")
for _, ln := range lines {
s := strings.TrimSpace(ln)
if s == "" || strings.HasPrefix(s, "#") {
continue
}
parts := strings.Fields(s)
if len(parts) < 2 {
continue
}
key := strings.ToLower(parts[0])
vals := parts[1:]
switch key {
case "default":
for _, v := range vals {
if n := normalizeDNSUpstream(v, "53"); n != "" {
def = append(def, n)
}
}
case "meta":
for _, v := range vals {
if n := normalizeDNSUpstream(v, "53"); n != "" {
meta = append(meta, n)
}
}
case "smartdns":
if len(vals) > 0 {
if n := normalizeSmartDNSAddr(vals[0]); n != "" {
cfg.SmartDNS = n
}
}
case "mode":
if len(vals) > 0 {
cfg.Mode = normalizeDNSResolverMode(DNSResolverMode(vals[0]), false)
}
}
}
if len(activePool) == 0 {
if len(def) > 0 {
cfg.Default = def
}
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)
}
return cfg
}
// ---------------------------------------------------------------------
// EN: `readLinesAllowMissing` reads lines allow missing from input data.
// RU: `readLinesAllowMissing` - читает lines allow missing из входных данных.
// ---------------------------------------------------------------------
func readLinesAllowMissing(path string) []string {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
return strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
}
// ---------------------------------------------------------------------
// EN: `loadJSONMap` loads json map from storage or config.
// RU: `loadJSONMap` - загружает json map из хранилища или конфига.
// ---------------------------------------------------------------------
func loadJSONMap(path string) map[string]any {
data, err := os.ReadFile(path)
if err != nil {
return map[string]any{}
}
var out map[string]any
if err := json.Unmarshal(data, &out); err != nil {
return map[string]any{}
}
return out
}
func loadResolverPrecheckLastRun(path string) int {
m := loadJSONMap(path)
if len(m) == 0 {
return 0
}
v, ok := parseAnyInt(m["last_run"])
if !ok || v <= 0 {
return 0
}
return v
}
func loadResolverLiveBatchTarget(path string, fallback, minV, maxV int) int {
if fallback < minV {
fallback = minV
}
if fallback > maxV {
fallback = maxV
}
m := loadJSONMap(path)
if len(m) == 0 {
return fallback
}
raw := m["live_batch_next_target"]
if raw == nil {
raw = m["live_batch_target"]
}
v, ok := parseAnyInt(raw)
if !ok || v <= 0 {
return fallback
}
if v < minV {
v = minV
}
if v > maxV {
v = maxV
}
return v
}
func loadResolverLiveBatchNXHeavyPct(path string, fallback, minV, maxV int) int {
if fallback < minV {
fallback = minV
}
if fallback > maxV {
fallback = maxV
}
m := loadJSONMap(path)
if len(m) == 0 {
return fallback
}
raw := m["live_batch_nxheavy_next_pct"]
if raw == nil {
raw = m["live_batch_nxheavy_pct"]
}
v, ok := parseAnyInt(raw)
if !ok {
return fallback
}
if v < minV {
v = minV
}
if v > maxV {
v = maxV
}
return v
}
func computeNextLiveBatchTarget(current, minV, maxV int, dnsStats dnsMetrics, deferred int) (int, string) {
if current < minV {
current = minV
}
if current > maxV {
current = maxV
}
next := current
reason := "stable"
attempts := dnsStats.Attempts
timeoutRate := 0.0
if attempts > 0 {
timeoutRate = float64(dnsStats.Timeout) / float64(attempts)
}
switch {
case attempts == 0:
reason = "no_dns_attempts"
case dnsStats.Skipped > 0 || timeoutRate >= 0.15:
next = int(float64(current) * 0.75)
reason = "timeout_high_or_cooldown"
case timeoutRate >= 0.08:
next = int(float64(current) * 0.90)
reason = "timeout_medium"
case timeoutRate <= 0.03 && deferred > 0:
next = int(float64(current) * 1.15)
reason = "timeout_low_expand"
case timeoutRate <= 0.03:
next = int(float64(current) * 1.10)
reason = "timeout_low"
}
if next < minV {
next = minV
}
if next > maxV {
next = maxV
}
if next == current && reason == "timeout_low" {
reason = "stable"
}
return next, reason
}
func computeNextLiveBatchNXHeavyPct(
current, minV, maxV int,
dnsStats dnsMetrics,
resolvedNowDNS int,
liveP3 int,
liveNXHeavyTotal int,
liveNXHeavySkip int,
) (int, string) {
if current < minV {
current = minV
}
if current > maxV {
current = maxV
}
next := current
reason := "stable"
attempts := dnsStats.Attempts
timeoutRate := 0.0
nxRate := 0.0
okRate := 0.0
if attempts > 0 {
timeoutRate = float64(dnsStats.Timeout) / float64(attempts)
nxRate = float64(dnsStats.NXDomain) / float64(attempts)
okRate = float64(dnsStats.OK) / float64(attempts)
}
nxSelectedRatio := 0.0
if liveNXHeavyTotal > 0 {
nxSelectedRatio = float64(liveP3) / float64(liveNXHeavyTotal)
}
switch {
case attempts == 0:
reason = "no_dns_attempts"
case timeoutRate >= 0.20 || dnsStats.Skipped > 0:
next = current - 3
reason = "timeout_very_high_or_cooldown"
case timeoutRate >= 0.12:
next = current - 2
reason = "timeout_high"
case dnsStats.OK == 0 && dnsStats.NXDomain > 0:
next = current - 2
reason = "no_success_nx_only"
case nxRate >= 0.90 && resolvedNowDNS == 0:
next = current - 2
reason = "nx_dominant_no_resolve"
case nxSelectedRatio >= 0.95 && resolvedNowDNS == 0:
next = current - 1
reason = "nxheavy_selected_dominant"
case timeoutRate <= 0.02 && okRate >= 0.10 && liveNXHeavySkip > 0:
next = current + 2
reason = "healthy_fast_reintroduce_nxheavy"
case timeoutRate <= 0.04 && resolvedNowDNS > 0 && liveNXHeavySkip > 0:
next = current + 1
reason = "healthy_reintroduce_nxheavy"
}
if next < minV {
next = minV
}
if next > maxV {
next = maxV
}
if next == current && reason != "no_dns_attempts" {
reason = "stable"
}
return next, reason
}
func classifyLiveBatchHost(
host string,
cache domainCacheState,
cacheSourceForHost func(string) domainCacheSource,
wildcards wildcardMatcher,
) (priority int, nxHeavy bool) {
h := strings.TrimSpace(strings.ToLower(host))
if h == "" {
return 2, false
}
if _, ok := wildcards.exact[h]; ok {
return 1, false
}
source := cacheSourceForHost(h)
rec, ok := cache.Domains[h]
if !ok {
return 2, false
}
entry := getCacheEntryBySource(rec, source)
if entry == nil {
return 2, false
}
stored := normalizeCacheIPs(entry.IPs)
state := normalizeDomainState(entry.State, entry.Score)
errKind, hasErr := normalizeCacheErrorKind(entry.LastErrorKind)
nxHeavy = hasErr && errKind == dnsErrorNXDomain && (state == domainStateQuarantine || state == domainStateHardQuar || entry.Score <= -10)
switch {
case len(stored) > 0:
return 1, false
case state == domainStateActive || state == domainStateStable || state == domainStateSuspect:
return 1, false
case nxHeavy:
return 3, true
default:
return 2, false
}
}
func splitLiveBatchCandidates(
candidates []string,
cache domainCacheState,
cacheSourceForHost func(string) domainCacheSource,
wildcards wildcardMatcher,
) (p1, p2, p3 []string, nxHeavyTotal int) {
for _, host := range candidates {
h := strings.TrimSpace(strings.ToLower(host))
if h == "" {
continue
}
prio, nxHeavy := classifyLiveBatchHost(h, cache, cacheSourceForHost, wildcards)
switch prio {
case 1:
p1 = append(p1, h)
case 3:
nxHeavyTotal++
p3 = append(p3, h)
case 2:
p2 = append(p2, h)
default:
if nxHeavy {
nxHeavyTotal++
p3 = append(p3, h)
} else {
p2 = append(p2, h)
}
}
}
return p1, p2, p3, nxHeavyTotal
}
func pickAdaptiveLiveBatch(
candidates []string,
target int,
now int,
nxHeavyPct int,
cache domainCacheState,
cacheSourceForHost func(string) domainCacheSource,
wildcards wildcardMatcher,
) ([]string, int, int, int, int, int) {
if len(candidates) == 0 {
return nil, 0, 0, 0, 0, 0
}
if target <= 0 {
p1, p2, p3, nxTotal := splitLiveBatchCandidates(candidates, cache, cacheSourceForHost, wildcards)
return append([]string(nil), candidates...), len(p1), len(p2), len(p3), nxTotal, 0
}
if target > len(candidates) {
target = len(candidates)
}
if nxHeavyPct < 0 {
nxHeavyPct = 0
}
if nxHeavyPct > 100 {
nxHeavyPct = 100
}
start := now % len(candidates)
if start < 0 {
start = 0
}
rotated := make([]string, 0, len(candidates))
for i := 0; i < len(candidates); i++ {
idx := (start + i) % len(candidates)
rotated = append(rotated, candidates[idx])
}
p1, p2, p3, nxTotal := splitLiveBatchCandidates(rotated, cache, cacheSourceForHost, wildcards)
out := make([]string, 0, target)
selectedP1 := 0
selectedP2 := 0
selectedP3 := 0
take := func(src []string, n int) ([]string, int) {
if n <= 0 || len(src) == 0 {
return src, 0
}
if n > len(src) {
n = len(src)
}
out = append(out, src[:n]...)
return src[n:], n
}
remain := target
var took int
p1, took = take(p1, remain)
selectedP1 += took
remain = target - len(out)
p2, took = take(p2, remain)
selectedP2 += took
remain = target - len(out)
p3Cap := (target * nxHeavyPct) / 100
if nxHeavyPct > 0 && p3Cap == 0 {
p3Cap = 1
}
if len(out) == 0 && len(p3) > 0 && p3Cap == 0 {
p3Cap = 1
}
if p3Cap > remain {
p3Cap = remain
}
p3, took = take(p3, p3Cap)
selectedP3 += took
// Keep forward progress if every candidate is in NX-heavy bucket.
if len(out) == 0 && len(p3) > 0 && target > 0 {
remain = target - len(out)
p3, took = take(p3, remain)
selectedP3 += took
}
nxSkipped := nxTotal - selectedP3
if nxSkipped < 0 {
nxSkipped = 0
}
return out, selectedP1, selectedP2, selectedP3, nxTotal, nxSkipped
}
func saveResolverPrecheckState(path string, ts int, timeoutStats resolverTimeoutRecheckStats, live resolverLiveBatchStats) {
if path == "" || ts <= 0 {
return
}
state := loadJSONMap(path)
if state == nil {
state = map[string]any{}
}
state["last_run"] = ts
state["timeout_recheck"] = map[string]any{
"checked": timeoutStats.Checked,
"recovered": timeoutStats.Recovered,
"recovered_ips": timeoutStats.RecoveredIPs,
"still_timeout": timeoutStats.StillTimeout,
"now_nxdomain": timeoutStats.NowNXDomain,
"now_temporary": timeoutStats.NowTemporary,
"now_other": timeoutStats.NowOther,
"no_signal": timeoutStats.NoSignal,
}
state["live_batch_target"] = live.Target
state["live_batch_total"] = live.Total
state["live_batch_deferred"] = live.Deferred
state["live_batch_p1"] = live.P1
state["live_batch_p2"] = live.P2
state["live_batch_p3"] = live.P3
state["live_batch_nxheavy_pct"] = live.NXHeavyPct
state["live_batch_nxheavy_total"] = live.NXHeavyTotal
state["live_batch_nxheavy_skip"] = live.NXHeavySkip
state["live_batch_nxheavy_next_pct"] = live.NextNXPct
state["live_batch_nxheavy_next_reason"] = live.NextNXReason
state["live_batch_next_target"] = live.NextTarget
state["live_batch_next_reason"] = live.NextReason
state["live_batch_dns_attempts"] = live.DNSAttempts
state["live_batch_dns_timeout"] = live.DNSTimeout
state["live_batch_dns_cooldown_skips"] = live.DNSCoolSkips
saveJSON(state, path)
}
// ---------------------------------------------------------------------
// EN: `saveJSON` saves json to persistent storage.
// RU: `saveJSON` - сохраняет json в постоянное хранилище.
// ---------------------------------------------------------------------
func saveJSON(data any, path string) {
tmp := path + ".tmp"
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
return
}
_ = os.WriteFile(tmp, b, 0o644)
_ = os.Rename(tmp, path)
}
// ---------------------------------------------------------------------
// EN: `uniqueStrings` contains core logic for unique strings.
// RU: `uniqueStrings` - содержит основную логику для unique strings.
// ---------------------------------------------------------------------
func uniqueStrings(in []string) []string {
seen := map[string]struct{}{}
var out []string
for _, v := range in {
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
out = append(out, v)
}
}
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
// ---------------------------------------------------------------------
var reANSI = regexp.MustCompile(`\x1B\[[0-9;]*[A-Za-z]`)
func stripANSI(s string) string {
return reANSI.ReplaceAllString(s, "")
}
// ---------------------------------------------------------------------
// EN: `isPrivateIPv4` checks whether private i pv4 is true.
// RU: `isPrivateIPv4` - проверяет, является ли private i pv4 истинным условием.
// ---------------------------------------------------------------------
func isPrivateIPv4(ip string) bool {
parts := strings.Split(strings.Split(ip, "/")[0], ".")
if len(parts) != 4 {
return true
}
vals := make([]int, 4)
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 || n > 255 {
return true
}
vals[i] = n
}
if vals[0] == 10 || vals[0] == 127 || vals[0] == 0 {
return true
}
if vals[0] == 192 && vals[1] == 168 {
return true
}
if vals[0] == 172 && vals[1] >= 16 && vals[1] <= 31 {
return true
}
return false
}