platform: modularize api/gui, add docs-tests-web foundation, and refresh root config

This commit is contained in:
beckline
2026-03-26 22:40:54 +03:00
parent 0e2d7f61ea
commit 6a56d734c2
562 changed files with 70151 additions and 16423 deletions

View File

@@ -0,0 +1,88 @@
package resolver
import "sort"
type ResolverArtifacts struct {
IPs []string
DirectIPs []string
WildcardIPs []string
IPMap [][2]string
DirectIPMap [][2]string
WildcardIPMap [][2]string
}
func BuildResolverArtifacts(resolved map[string][]string, staticLabels map[string][]string, isWildcardHost func(string) bool) ResolverArtifacts {
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{}{}
}
for host, ips := range resolved {
wildcardHost := false
if isWildcardHost != nil {
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)
add(ipSetDirect, ipMapDirect, ipEntry, lbl)
}
}
var out ResolverArtifacts
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(&out.IPMap, ipMapAll)
appendMapPairs(&out.DirectIPMap, ipMapDirect)
appendMapPairs(&out.WildcardIPMap, ipMapWildcard)
appendIPs(&out.IPs, ipSetAll)
appendIPs(&out.DirectIPs, ipSetDirect)
appendIPs(&out.WildcardIPs, ipSetWildcard)
return out
}

View File

@@ -0,0 +1,60 @@
package resolver
import (
"hash/fnv"
"regexp"
"strconv"
"strings"
)
var reANSI = regexp.MustCompile(`\x1B\[[0-9;]*[A-Za-z]`)
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 StripANSI(s string) string {
return reANSI.ReplaceAllString(s, "")
}
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
}

View File

@@ -0,0 +1,150 @@
package resolver
import (
"os"
"strings"
)
type DNSConfig struct {
Default []string
Meta []string
SmartDNS string
Mode string
}
type DNSConfigDeps struct {
ActivePool []string
IsSmartDNSForced bool
SmartDNSAddr string
SmartDNSForcedMode string
ResolveFallbackPool func() []string
MergeDNSUpstreamPools func(primary, fallback []string) []string
NormalizeDNSUpstream func(raw string, defaultPort string) string
NormalizeSmartDNSAddr func(raw string) string
NormalizeDNSResolverMode func(raw string) string
}
func LoadDNSConfig(path string, base DNSConfig, deps DNSConfigDeps, logf func(string, ...any)) DNSConfig {
cfg := DNSConfig{
Default: append([]string(nil), base.Default...),
Meta: append([]string(nil), base.Meta...),
SmartDNS: strings.TrimSpace(base.SmartDNS),
Mode: strings.TrimSpace(base.Mode),
}
if cfg.Mode == "" {
cfg.Mode = "direct"
}
if len(deps.ActivePool) > 0 {
cfg.Default = append([]string(nil), deps.ActivePool...)
cfg.Meta = append([]string(nil), deps.ActivePool...)
}
if deps.IsSmartDNSForced {
addr := strings.TrimSpace(deps.SmartDNSAddr)
if deps.NormalizeSmartDNSAddr != nil {
if n := deps.NormalizeSmartDNSAddr(addr); n != "" {
addr = n
}
}
if addr == "" {
addr = cfg.SmartDNS
}
cfg.Default = []string{addr}
cfg.Meta = []string{addr}
cfg.SmartDNS = addr
if strings.TrimSpace(deps.SmartDNSForcedMode) != "" {
cfg.Mode = deps.SmartDNSForcedMode
} else {
cfg.Mode = "smartdns"
}
if logf != nil {
logf("dns-config: SmartDNS forced (%s), ignore %s", addr, path)
}
return cfg
}
data, err := os.ReadFile(path)
if err != nil {
if logf != nil {
logf("dns-config: can't read %s: %v", path, err)
}
fallback := []string(nil)
if deps.ResolveFallbackPool != nil {
fallback = deps.ResolveFallbackPool()
}
if deps.MergeDNSUpstreamPools != nil {
cfg.Default = deps.MergeDNSUpstreamPools(cfg.Default, fallback)
cfg.Meta = deps.MergeDNSUpstreamPools(cfg.Meta, fallback)
}
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 deps.NormalizeDNSUpstream != nil {
if n := deps.NormalizeDNSUpstream(v, "53"); n != "" {
def = append(def, n)
}
}
}
case "meta":
for _, v := range vals {
if deps.NormalizeDNSUpstream != nil {
if n := deps.NormalizeDNSUpstream(v, "53"); n != "" {
meta = append(meta, n)
}
}
}
case "smartdns":
if len(vals) > 0 && deps.NormalizeSmartDNSAddr != nil {
if n := deps.NormalizeSmartDNSAddr(vals[0]); n != "" {
cfg.SmartDNS = n
}
}
case "mode":
if len(vals) > 0 {
rawMode := vals[0]
if deps.NormalizeDNSResolverMode != nil {
cfg.Mode = deps.NormalizeDNSResolverMode(rawMode)
} else {
cfg.Mode = strings.ToLower(strings.TrimSpace(rawMode))
}
}
}
}
if len(deps.ActivePool) == 0 {
if len(def) > 0 {
cfg.Default = def
}
if len(meta) > 0 {
cfg.Meta = meta
}
}
fallback := []string(nil)
if deps.ResolveFallbackPool != nil {
fallback = deps.ResolveFallbackPool()
}
if deps.MergeDNSUpstreamPools != nil {
cfg.Default = deps.MergeDNSUpstreamPools(cfg.Default, fallback)
cfg.Meta = deps.MergeDNSUpstreamPools(cfg.Meta, fallback)
}
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
}

View File

@@ -0,0 +1,52 @@
package resolver
import (
"errors"
"net"
"strings"
)
func ClassifyDNSError(err error) string {
if err == nil {
return "other"
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound {
return "nxdomain"
}
if dnsErr.IsTimeout {
return "timeout"
}
if dnsErr.IsTemporary {
return "temporary"
}
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "no such host"), strings.Contains(msg, "nxdomain"):
return "nxdomain"
case strings.Contains(msg, "i/o timeout"), strings.Contains(msg, "timeout"):
return "timeout"
case strings.Contains(msg, "temporary"):
return "temporary"
default:
return "other"
}
}
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), ""
}

View File

@@ -0,0 +1,158 @@
package resolver
import (
"fmt"
"sort"
"strings"
)
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
m.Skipped += other.Skipped
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
dst.Skipped += src.Skipped
}
}
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, "; ")
}

View File

@@ -0,0 +1,57 @@
package resolver
import "strings"
func BuildResolverFallbackPool(raw string, fallbackDefaults []string, normalize func(string) string) []string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "off", "none", "0":
return nil
}
candidates := fallbackDefaults
if strings.TrimSpace(raw) != "" {
candidates = nil
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t'
})
for _, f := range fields {
if normalize == nil {
continue
}
if n := normalize(f); n != "" {
candidates = append(candidates, n)
}
}
}
return UniqueStrings(candidates)
}
func MergeDNSUpstreamPools(primary, fallback []string, maxUpstreams int, normalize func(string) string) []string {
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
}
if normalize == nil {
continue
}
n := normalize(item)
if n == "" {
continue
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
}
add(primary)
add(fallback)
return out
}

View File

@@ -0,0 +1,649 @@
package resolver
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
)
type DomainCacheSource string
const (
DomainCacheSourceDirect DomainCacheSource = "direct"
DomainCacheSourceWildcard DomainCacheSource = "wildcard"
)
const (
DomainStateActive = "active"
DomainStateStable = "stable"
DomainStateSuspect = "suspect"
DomainStateQuarantine = "quarantine"
DomainStateHardQuar = "hard_quarantine"
DomainScoreMin = -100
DomainScoreMax = 100
DomainCacheVersion = 4
DefaultQuarantineTTL = 24 * 3600
DefaultHardQuarTTL = 7 * 24 * 3600
)
var EnvInt = func(key string, def int) int { return def }
var NXHardQuarantineEnabled = func() bool { return false }
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: DomainCacheVersion,
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 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 = DomainCacheVersion
}
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
}
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 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 (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", DefaultHardQuarTTL)
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)
if normKind == DNSErrorTimeout && prevKind != DNSErrorNXDomain {
if entry.Score < -10 {
entry.Score = -10
}
entry.State = DomainStateSuspect
}
if normKind == DNSErrorNXDomain && !NXHardQuarantineEnabled() && 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": DomainCacheVersion,
"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 {
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,
)
}

View File

@@ -0,0 +1,53 @@
package resolver
import (
"os"
"strings"
)
func SmartDNSFallbackForTimeoutEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_SMARTDNS_TIMEOUT_FALLBACK"))) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return false
}
}
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
}

View File

@@ -0,0 +1,234 @@
package resolver
import (
"context"
"net"
"time"
)
const (
dnsModeSmartDNS = "smartdns"
dnsModeHybridWildcard = "hybrid_wildcard"
)
type DNSAttemptPolicy struct {
TryLimit int
DomainBudget time.Duration
StopOnNX bool
}
type DNSCooldown interface {
ShouldSkip(upstream string, now int64) bool
ObserveSuccess(upstream string)
ObserveError(upstream string, kind DNSErrorKind, now int64) (bool, int)
}
func ResolveHost(
host string,
cfg DNSConfig,
metaSpecial []string,
isWildcard func(string) bool,
timeout time.Duration,
cooldown DNSCooldown,
directPolicyFor func(int) DNSAttemptPolicy,
wildcardPolicyFor func(int) DNSAttemptPolicy,
smartDNSFallbackEnabled bool,
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 != "" && isWildcard != nil && isWildcard(host) {
dnsList = []string{cfg.SmartDNS}
primaryViaSmartDNS = true
}
}
policy := safePolicy(directPolicyFor, len(dnsList), timeout)
if primaryViaSmartDNS {
policy = safePolicy(wildcardPolicyFor, len(dnsList), timeout)
}
ips, stats := DigAWithPolicy(host, dnsList, timeout, policy, cooldown, logf)
if len(ips) == 0 &&
!primaryViaSmartDNS &&
cfg.SmartDNS != "" &&
smartDNSFallbackEnabled &&
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 := safePolicy(wildcardPolicyFor, 1, timeout)
fallbackIPs, fallbackStats := DigAWithPolicy(host, []string{cfg.SmartDNS}, timeout, fallbackPolicy, cooldown, 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 := make([]string, 0, len(ips))
seen := map[string]struct{}{}
for _, ip := range ips {
if IsPrivateIPv4(ip) {
continue
}
if _, ok := seen[ip]; ok {
continue
}
seen[ip] = struct{}{}
out = append(out, ip)
}
return out, stats
}
func DigAWithPolicy(
host string,
dnsList []string,
timeout time.Duration,
policy DNSAttemptPolicy,
cooldown DNSCooldown,
logf func(string, ...any),
) ([]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 {
kindRaw := ClassifyDNSError(err)
kind, ok := NormalizeCacheErrorKind(kindRaw)
if !ok {
kind = DNSErrorOther
}
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 safePolicy(factory func(int) DNSAttemptPolicy, count int, timeout time.Duration) DNSAttemptPolicy {
if factory != nil {
return factory(count)
}
return DNSAttemptPolicy{
TryLimit: 1,
DomainBudget: timeout,
StopOnNX: true,
}
}

View File

@@ -0,0 +1,135 @@
package resolver
import (
"encoding/json"
"os"
"strconv"
"strings"
)
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")
}
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 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)
}
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
case string:
s := strings.TrimSpace(v)
if s == "" {
return 0, false
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return n, true
default:
return 0, false
}
}
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
}

View File

@@ -0,0 +1,113 @@
package resolver
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,
selectedP3 int,
nxTotal 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
okRate := 0.0
nxRate := 0.0
if attempts > 0 {
timeoutRate = float64(dnsStats.Timeout) / float64(attempts)
okRate = float64(dnsStats.OK) / float64(attempts)
nxRate = float64(dnsStats.NXDomain) / float64(attempts)
}
nxSelectedRatio := 0.0
if nxTotal > 0 {
nxSelectedRatio = float64(selectedP3) / float64(nxTotal)
}
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
}

View File

@@ -0,0 +1,161 @@
package resolver
import "strings"
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 wildcards.IsExact(h) {
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
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
}

View File

@@ -0,0 +1,62 @@
package resolver
import "strings"
type DNSModeRuntimeInput struct {
Config DNSConfig
Mode string
ViaSmartDNS bool
SmartDNSAddr string
SmartDNSForced bool
SmartDNSDefault string
NormalizeMode func(mode string, viaSmartDNS bool) string
NormalizeSmartDNSAddr func(raw string) string
}
func ApplyDNSModeRuntime(in DNSModeRuntimeInput) DNSConfig {
cfg := DNSConfig{
Default: append([]string(nil), in.Config.Default...),
Meta: append([]string(nil), in.Config.Meta...),
SmartDNS: strings.TrimSpace(in.Config.SmartDNS),
Mode: strings.TrimSpace(in.Config.Mode),
}
if !in.SmartDNSForced && in.NormalizeMode != nil {
if mode := strings.TrimSpace(in.NormalizeMode(in.Mode, in.ViaSmartDNS)); mode != "" {
cfg.Mode = mode
}
}
if in.NormalizeSmartDNSAddr != nil {
if addr := strings.TrimSpace(in.NormalizeSmartDNSAddr(in.SmartDNSAddr)); addr != "" {
cfg.SmartDNS = addr
}
} else if addr := strings.TrimSpace(in.SmartDNSAddr); addr != "" {
cfg.SmartDNS = addr
}
if cfg.SmartDNS == "" {
cfg.SmartDNS = strings.TrimSpace(in.SmartDNSDefault)
}
if cfg.Mode == "smartdns" && cfg.SmartDNS != "" {
cfg.Default = []string{cfg.SmartDNS}
cfg.Meta = []string{cfg.SmartDNS}
}
return cfg
}
func LogDNSMode(cfg DNSConfig, wildcardCount int, logf func(string, ...any)) {
if logf == nil {
return
}
switch cfg.Mode {
case "smartdns":
logf("resolver dns mode: SmartDNS-only (%s)", cfg.SmartDNS)
case "hybrid_wildcard":
logf("resolver dns mode: hybrid_wildcard smartdns=%s wildcards=%d default=%v meta=%v", cfg.SmartDNS, wildcardCount, cfg.Default, cfg.Meta)
default:
logf("resolver dns mode: direct default=%v meta=%v", cfg.Default, cfg.Meta)
}
}

View File

@@ -0,0 +1,119 @@
package resolver
type ResolvePlanningInput struct {
Domains []string
Now int
TTL int
PrecheckDue bool
PrecheckMaxDomains int
StaleKeepSec int
NegTTLNX int
NegTTLTimeout int
NegTTLTemporary int
NegTTLOther int
}
type ResolvePlanningResult struct {
Fresh map[string][]string
ToResolve []string
CacheNegativeHits int
QuarantineHits int
StaleHits int
PrecheckScheduled int
}
func BuildResolvePlanning(
in ResolvePlanningInput,
domainCache *DomainCacheState,
cacheSourceForHost func(string) DomainCacheSource,
logf func(string, ...any),
) ResolvePlanningResult {
result := ResolvePlanningResult{
Fresh: map[string][]string{},
}
if domainCache == nil {
result.ToResolve = append(result.ToResolve, in.Domains...)
return result
}
resolveSource := cacheSourceForHost
if resolveSource == nil {
resolveSource = func(string) DomainCacheSource { return DomainCacheSourceDirect }
}
for _, d := range in.Domains {
source := resolveSource(d)
if ips, ok := domainCache.Get(d, source, in.Now, in.TTL); ok {
result.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, in.Now); ok {
kind, hasKind := domainCache.GetLastErrorKind(d, source)
timeoutKind := hasKind && kind == DNSErrorTimeout
if in.PrecheckDue && result.PrecheckScheduled < in.PrecheckMaxDomains {
// Timeout-based quarantine is rechecked in background batch and should
// not flood trace with per-domain debug lines.
if timeoutKind {
result.QuarantineHits++
if in.StaleKeepSec > 0 {
if staleIPs, staleAge, ok := domainCache.GetStale(d, source, in.Now, in.StaleKeepSec); ok {
result.StaleHits++
result.Fresh[d] = staleIPs
if logf != nil {
logf("cache stale-keep (quarantine)[age=%ds]: %s -> %v", staleAge, d, staleIPs)
}
}
}
continue
}
result.PrecheckScheduled++
result.ToResolve = append(result.ToResolve, d)
if logf != nil {
logf("precheck schedule[quarantine/%s age=%ds]: %s (%s)", state, age, d, source)
}
continue
}
result.QuarantineHits++
if logf != nil {
logf("cache quarantine hit[%s age=%ds]: %s (%s)", state, age, d, source)
}
if in.StaleKeepSec > 0 {
if staleIPs, staleAge, ok := domainCache.GetStale(d, source, in.Now, in.StaleKeepSec); ok {
result.StaleHits++
result.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, in.Now, in.NegTTLNX, in.NegTTLTimeout, in.NegTTLTemporary, in.NegTTLOther); ok {
if in.PrecheckDue && result.PrecheckScheduled < in.PrecheckMaxDomains {
if kind == DNSErrorTimeout {
result.CacheNegativeHits++
continue
}
result.PrecheckScheduled++
result.ToResolve = append(result.ToResolve, d)
if logf != nil {
logf("precheck schedule[negative/%s age=%ds]: %s (%s)", kind, age, d, source)
}
continue
}
result.CacheNegativeHits++
if logf != nil {
logf("cache neg hit[%s/%s age=%ds]: %s", source, kind, age, d)
}
continue
}
result.ToResolve = append(result.ToResolve, d)
}
return result
}

View File

@@ -0,0 +1,106 @@
package resolver
import (
"os"
"strings"
)
type ResolverPrecheckFinalizeInput struct {
PrecheckDue bool
PrecheckStatePath string
Now int
TimeoutRecheck ResolverTimeoutRecheckStats
LiveBatchTarget int
LiveBatchMin int
LiveBatchMax int
LiveBatchNXHeavyPct int
LiveBatchNXHeavyMin int
LiveBatchNXHeavyMax int
DNSStats DNSMetrics
LiveDeferred int
ResolvedNowDNS int
LiveP1 int
LiveP2 int
LiveP3 int
LiveNXHeavyTotal int
LiveNXHeavySkip int
ToResolveTotal int
PrecheckFileForced bool
PrecheckForcePath string
}
type ResolverPrecheckFinalizeResult struct {
NextTarget int
NextReason string
NextNXPct int
NextNXReason string
Saved bool
ForceFileConsumed bool
}
func FinalizeResolverPrecheck(in ResolverPrecheckFinalizeInput, logf func(string, ...any)) ResolverPrecheckFinalizeResult {
out := ResolverPrecheckFinalizeResult{}
if in.PrecheckDue {
nextTarget, nextReason := ComputeNextLiveBatchTarget(in.LiveBatchTarget, in.LiveBatchMin, in.LiveBatchMax, in.DNSStats, in.LiveDeferred)
nextNXPct, nextNXReason := ComputeNextLiveBatchNXHeavyPct(
in.LiveBatchNXHeavyPct,
in.LiveBatchNXHeavyMin,
in.LiveBatchNXHeavyMax,
in.DNSStats,
in.ResolvedNowDNS,
in.LiveP3,
in.LiveNXHeavyTotal,
in.LiveNXHeavySkip,
)
if logf != nil {
logf(
"resolve live-batch nxheavy: pct=%d next=%d reason=%s selected=%d total=%d skipped=%d",
in.LiveBatchNXHeavyPct,
nextNXPct,
nextNXReason,
in.LiveP3,
in.LiveNXHeavyTotal,
in.LiveNXHeavySkip,
)
}
SaveResolverPrecheckState(
in.PrecheckStatePath,
in.Now,
in.TimeoutRecheck,
ResolverLiveBatchStats{
Target: in.LiveBatchTarget,
Total: in.ToResolveTotal,
Deferred: in.LiveDeferred,
P1: in.LiveP1,
P2: in.LiveP2,
P3: in.LiveP3,
NXHeavyPct: in.LiveBatchNXHeavyPct,
NXHeavyTotal: in.LiveNXHeavyTotal,
NXHeavySkip: in.LiveNXHeavySkip,
NextTarget: nextTarget,
NextReason: nextReason,
NextNXPct: nextNXPct,
NextNXReason: nextNXReason,
DNSAttempts: in.DNSStats.Attempts,
DNSTimeout: in.DNSStats.Timeout,
DNSCoolSkips: in.DNSStats.Skipped,
},
)
out.NextTarget = nextTarget
out.NextReason = nextReason
out.NextNXPct = nextNXPct
out.NextNXReason = nextNXReason
out.Saved = true
}
if in.PrecheckFileForced && strings.TrimSpace(in.PrecheckForcePath) != "" {
_ = os.Remove(in.PrecheckForcePath)
if logf != nil {
logf("resolve precheck force-file consumed: %s", in.PrecheckForcePath)
}
out.ForceFileConsumed = true
}
return out
}

View File

@@ -0,0 +1,39 @@
package resolver
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)
}

View File

@@ -0,0 +1,31 @@
package resolver
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
}

View File

@@ -0,0 +1,115 @@
package resolver
type ResolveBatchInput struct {
ToResolve []string
Workers int
Now int
StaleKeepSec int
}
type ResolveBatchResult struct {
DNSStats DNSMetrics
ResolvedNowDNS int
ResolvedNowStale int
UnresolvedAfterAttempts int
StaleHitsDelta int
}
func ExecuteResolveBatch(
in ResolveBatchInput,
resolved map[string][]string,
domainCache *DomainCacheState,
cacheSourceForHost func(string) DomainCacheSource,
resolveHost func(string) ([]string, DNSMetrics),
logf func(string, ...any),
) ResolveBatchResult {
out := ResolveBatchResult{}
if len(in.ToResolve) == 0 || resolveHost == nil || domainCache == nil {
return out
}
workers := in.Workers
if workers < 1 {
workers = 1
}
if workers > 500 {
workers = 500
}
resolveSource := cacheSourceForHost
if resolveSource == nil {
resolveSource = func(string) DomainCacheSource { return DomainCacheSourceDirect }
}
type result struct {
host string
ips []string
stats DNSMetrics
}
jobs := make(chan string, len(in.ToResolve))
results := make(chan result, len(in.ToResolve))
for i := 0; i < workers; i++ {
go func() {
for host := range jobs {
ips, stats := resolveHost(host)
results <- result{host: host, ips: ips, stats: stats}
}
}()
}
for _, host := range in.ToResolve {
jobs <- host
}
close(jobs)
for i := 0; i < len(in.ToResolve); i++ {
r := <-results
out.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 {
if resolved != nil {
resolved[r.host] = r.ips
}
out.ResolvedNowDNS++
source := resolveSource(r.host)
domainCache.Set(r.host, source, r.ips, in.Now)
if logf != nil {
logf("%s -> %v", r.host, r.ips)
}
continue
}
staleApplied := false
if hostErrors > 0 {
source := resolveSource(r.host)
domainCache.SetErrorWithStats(r.host, source, r.stats, in.Now)
if in.StaleKeepSec > 0 && ShouldUseStaleOnError(r.stats) {
if staleIPs, staleAge, ok := domainCache.GetStale(r.host, source, in.Now, in.StaleKeepSec); ok {
out.StaleHitsDelta++
out.ResolvedNowStale++
staleApplied = true
if resolved != nil {
resolved[r.host] = staleIPs
}
if logf != nil {
logf("cache stale-keep (error)[age=%ds]: %s -> %v", staleAge, r.host, staleIPs)
}
}
}
}
if !staleApplied {
out.UnresolvedAfterAttempts++
}
if logf != nil && resolved != nil {
if _, ok := resolved[r.host]; !ok {
logf("%s: no IPs", r.host)
}
}
}
return out
}

View File

@@ -0,0 +1,205 @@
package resolver
import "time"
type ResolverRuntimeTuningInput struct {
TTL int
Workers int
Now int
PrecheckStatePath string
PrecheckEnvForced bool
PrecheckFileForced bool
}
type ResolverRuntimeTuning struct {
TTL int
Workers int
DNSTimeoutMS int
DNSTimeout time.Duration
PrecheckEverySec int
PrecheckMaxDomains int
TimeoutRecheckMax int
LiveBatchMin int
LiveBatchMax int
LiveBatchTarget int
LiveBatchNXHeavyMin int
LiveBatchNXHeavyMax int
LiveBatchNXHeavyPct int
PrecheckDue bool
StaleKeepSec int
NegTTLNX int
NegTTLTimeout int
NegTTLTemporary int
NegTTLOther int
}
type ResolverRuntimeTuningDeps struct {
EnvInt func(string, int) int
LoadResolverPrecheckLastRun func(path string) int
LoadResolverLiveBatchTarget func(path string, fallback, minV, maxV int) int
LoadResolverLiveBatchNXHeavyPct func(path string, fallback, minV, maxV int) int
}
func BuildResolverRuntimeTuning(in ResolverRuntimeTuningInput, deps ResolverRuntimeTuningDeps) ResolverRuntimeTuning {
envInt := deps.EnvInt
if envInt == nil {
envInt = func(_ string, def int) int { return def }
}
ttl := in.TTL
if ttl <= 0 {
ttl = 24 * 3600
}
if ttl < 60 {
ttl = 60
}
if ttl > 24*3600 {
ttl = 24 * 3600
}
workers := in.Workers
if workers <= 0 {
workers = 80
}
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
}
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
}
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 := liveBatchDefault
if deps.LoadResolverLiveBatchTarget != nil {
liveBatchTarget = deps.LoadResolverLiveBatchTarget(in.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 := liveBatchNXHeavyDefault
if deps.LoadResolverLiveBatchNXHeavyPct != nil {
liveBatchNXHeavyPct = deps.LoadResolverLiveBatchNXHeavyPct(in.PrecheckStatePath, liveBatchNXHeavyDefault, liveBatchNXHeavyMin, liveBatchNXHeavyMax)
}
precheckLastRun := 0
if deps.LoadResolverPrecheckLastRun != nil {
precheckLastRun = deps.LoadResolverPrecheckLastRun(in.PrecheckStatePath)
}
precheckDue := in.PrecheckEnvForced || in.PrecheckFileForced || (precheckEverySec > 0 && (precheckLastRun <= 0 || in.Now-precheckLastRun >= precheckEverySec))
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)
return ResolverRuntimeTuning{
TTL: ttl,
Workers: workers,
DNSTimeoutMS: dnsTimeoutMs,
DNSTimeout: time.Duration(dnsTimeoutMs) * time.Millisecond,
PrecheckEverySec: precheckEverySec,
PrecheckMaxDomains: precheckMaxDomains,
TimeoutRecheckMax: timeoutRecheckMax,
LiveBatchMin: liveBatchMin,
LiveBatchMax: liveBatchMax,
LiveBatchTarget: liveBatchTarget,
LiveBatchNXHeavyMin: liveBatchNXHeavyMin,
LiveBatchNXHeavyMax: liveBatchNXHeavyMax,
LiveBatchNXHeavyPct: liveBatchNXHeavyPct,
PrecheckDue: precheckDue,
StaleKeepSec: staleKeepSec,
NegTTLNX: negTTLNX,
NegTTLTimeout: negTTLTimeout,
NegTTLTemporary: negTTLTemporary,
NegTTLOther: negTTLOther,
}
}

View File

@@ -0,0 +1,64 @@
package resolver
type ResolverStartLogInput struct {
DomainsTotal int
TTL int
Workers int
DNSTimeoutMS int
DirectTry int
DirectBudgetMS int64
WildcardTry int
WildcardBudgetMS int64
NXEarlyStop bool
NXHardQuarantine bool
CooldownEnabled bool
CooldownMinAttempts int
CooldownTimeoutRate int
CooldownFailStreak int
CooldownBanSec int
CooldownMaxBanSec int
LiveBatchTarget int
LiveBatchMin int
LiveBatchMax int
LiveBatchNXHeavyPct int
LiveBatchNXHeavyMin int
LiveBatchNXHeavyMax int
StaleKeepSec int
PrecheckEverySec int
PrecheckMaxDomains int
PrecheckForcedEnv bool
PrecheckForcedFile bool
}
func LogResolverStart(in ResolverStartLogInput, logf func(string, ...any)) {
if logf == nil {
return
}
logf("resolver start: domains=%d ttl=%ds workers=%d dns_timeout_ms=%d", in.DomainsTotal, in.TTL, in.Workers, in.DNSTimeoutMS)
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",
in.DirectTry,
in.DirectBudgetMS,
in.WildcardTry,
in.WildcardBudgetMS,
in.NXEarlyStop,
in.NXHardQuarantine,
in.CooldownEnabled,
in.CooldownMinAttempts,
in.CooldownTimeoutRate,
in.CooldownFailStreak,
in.CooldownBanSec,
in.CooldownMaxBanSec,
in.LiveBatchTarget,
in.LiveBatchMin,
in.LiveBatchMax,
in.LiveBatchNXHeavyPct,
in.LiveBatchNXHeavyMin,
in.LiveBatchNXHeavyMax,
in.StaleKeepSec,
in.PrecheckEverySec,
in.PrecheckMaxDomains,
in.PrecheckForcedEnv,
in.PrecheckForcedFile,
)
}

View File

@@ -0,0 +1,139 @@
package resolver
import (
"context"
"fmt"
"net"
"net/netip"
"strings"
"time"
)
func ParseStaticEntries(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
}
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
}
func ResolveStaticLabels(entries [][3]string, dnsForPtr string, 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
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
}
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
}

View File

@@ -0,0 +1,112 @@
package resolver
type ResolverSummaryLogInput struct {
DomainsTotal int
FreshCount int
CacheNegativeHits int
QuarantineHits int
StaleHits int
ResolvedTotal int
UnresolvedAfterAttempts int
LiveBatchTarget int
LiveDeferred int
LiveP1 int
LiveP2 int
LiveP3 int
LiveBatchNXHeavyPct int
LiveNXHeavyTotal int
LiveNXHeavySkip int
StaticEntries int
StaticSkipped int
UniqueIPs int
DirectIPs int
WildcardIPs int
PtrLookups int
PtrErrors int
DNSStats DNSMetrics
TimeoutRecheck ResolverTimeoutRecheckStats
DurationMS int64
DomainStateSummary string
ResolvedNowDNS int
ResolvedNowStale int
PrecheckDue bool
PrecheckScheduled int
PrecheckStatePath string
}
func LogResolverSummary(in ResolverSummaryLogInput, logf func(string, ...any)) {
if logf == nil {
return
}
dnsErrors := in.DNSStats.TotalErrors()
unresolved := in.DomainsTotal - in.ResolvedTotal
unresolvedSuppressed := in.CacheNegativeHits + in.QuarantineHits + in.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",
in.DomainsTotal,
in.FreshCount,
in.CacheNegativeHits,
in.QuarantineHits,
in.StaleHits,
in.ResolvedTotal-in.FreshCount,
unresolved,
in.UnresolvedAfterAttempts,
unresolvedSuppressed,
in.LiveBatchTarget,
in.LiveDeferred,
in.LiveP1,
in.LiveP2,
in.LiveP3,
in.LiveBatchNXHeavyPct,
in.LiveNXHeavyTotal,
in.LiveNXHeavySkip,
in.StaticEntries,
in.StaticSkipped,
in.UniqueIPs,
in.DirectIPs,
in.WildcardIPs,
in.PtrLookups,
in.PtrErrors,
in.DNSStats.Attempts,
in.DNSStats.OK,
in.DNSStats.NXDomain,
in.DNSStats.Timeout,
in.DNSStats.Temporary,
in.DNSStats.Other,
in.DNSStats.Skipped,
dnsErrors,
in.TimeoutRecheck.Checked,
in.TimeoutRecheck.Recovered,
in.TimeoutRecheck.RecoveredIPs,
in.TimeoutRecheck.StillTimeout,
in.TimeoutRecheck.NowNXDomain,
in.TimeoutRecheck.NowTemporary,
in.TimeoutRecheck.NowOther,
in.TimeoutRecheck.NoSignal,
in.DurationMS,
)
if perUpstream := in.DNSStats.FormatPerUpstream(); perUpstream != "" {
logf("resolve dns upstreams: %s", perUpstream)
}
if health := in.DNSStats.FormatResolverHealth(); health != "" {
logf("resolve dns health: %s", health)
}
if in.DomainStateSummary != "" {
logf("resolve domain states: %s", in.DomainStateSummary)
}
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",
in.ResolvedTotal-in.FreshCount,
in.ResolvedNowDNS,
in.ResolvedNowStale,
in.CacheNegativeHits,
in.QuarantineHits,
in.LiveDeferred,
in.UnresolvedAfterAttempts,
)
if in.PrecheckDue {
logf("resolve precheck done: scheduled=%d state=%s", in.PrecheckScheduled, in.PrecheckStatePath)
}
}

View File

@@ -0,0 +1,127 @@
package resolver
import "strings"
func RunTimeoutQuarantineRecheck(
domains []string,
now int,
limit int,
workers int,
domainCache *DomainCacheState,
cacheSourceForHost func(string) DomainCacheSource,
resolveHost func(string) ([]string, DNSMetrics),
) ResolverTimeoutRecheckStats {
stats := ResolverTimeoutRecheckStats{}
if limit <= 0 || now <= 0 || domainCache == nil || resolveHost == nil {
return stats
}
if workers < 1 {
workers = 1
}
if workers > 200 {
workers = 200
}
resolveSource := cacheSourceForHost
if resolveSource == nil {
resolveSource = func(string) DomainCacheSource { return DomainCacheSourceDirect }
}
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 := resolveSource(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 := resolveSource(host)
ips, dnsStats := resolveHost(host)
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
}

View File

@@ -0,0 +1,70 @@
package resolver
import "strings"
type WildcardMatcher struct {
exact map[string]struct{}
suffix []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
}
func (m WildcardMatcher) IsExact(host string) bool {
if len(m.exact) == 0 {
return false
}
h := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
if h == "" {
return false
}
_, ok := m.exact[h]
return ok
}
func (m WildcardMatcher) Count() int {
return len(m.exact)
}