1270 lines
32 KiB
Go
1270 lines
32 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"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
|
|
}
|
|
|
|
type dnsMetrics struct {
|
|
Attempts int
|
|
OK int
|
|
NXDomain int
|
|
Timeout int
|
|
Temporary int
|
|
Other 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) 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}", k, v.Attempts, v.OK, v.NXDomain, v.Timeout, v.Temporary, v.Other))
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
type wildcardMatcher struct {
|
|
exact map[string]struct{}
|
|
suffix []string
|
|
}
|
|
|
|
var resolverFallbackDNS = []string{
|
|
"1.1.1.1#53",
|
|
"1.0.0.1#53",
|
|
"9.9.9.9#53",
|
|
"149.112.112.112#53",
|
|
"8.8.8.8#53",
|
|
"8.8.4.4#53",
|
|
}
|
|
|
|
func normalizeWildcardDomain(raw string) string {
|
|
d := strings.TrimSpace(strings.SplitN(raw, "#", 2)[0])
|
|
d = strings.ToLower(d)
|
|
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())
|
|
|
|
cacheSourceForHost := func(host string) domainCacheSource {
|
|
switch cfg.Mode {
|
|
case DNSModeSmartDNS:
|
|
return domainCacheSourceWildcard
|
|
case DNSModeHybridWildcard:
|
|
if wildcards.match(host) {
|
|
return domainCacheSourceWildcard
|
|
}
|
|
}
|
|
return domainCacheSourceDirect
|
|
}
|
|
|
|
if logf != nil {
|
|
logf("resolver start: domains=%d ttl=%ds workers=%d dns_timeout_ms=%d", len(domains), ttl, workers, dnsTimeoutMs)
|
|
}
|
|
start := time.Now()
|
|
|
|
fresh := map[string][]string{}
|
|
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
|
|
}
|
|
toResolve = append(toResolve, d)
|
|
}
|
|
|
|
resolved := map[string][]string{}
|
|
for k, v := range fresh {
|
|
resolved[k] = v
|
|
}
|
|
|
|
if logf != nil {
|
|
logf("resolve: domains=%d cache_hits=%d to_resolve=%d", len(domains), len(fresh), len(toResolve))
|
|
}
|
|
|
|
dnsStats := dnsMetrics{}
|
|
|
|
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, 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
|
|
source := cacheSourceForHost(r.host)
|
|
domainCache.set(r.host, source, r.ips, now)
|
|
if logf != nil {
|
|
logf("%s -> %v", r.host, r.ips)
|
|
}
|
|
} else if logf != nil {
|
|
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()
|
|
logf(
|
|
"resolve summary: domains=%d cache_hits=%d resolved_now=%d unresolved=%d static_entries=%d static_skipped=%d unique_ips=%d direct_ips=%d wildcard_ips=%d ptr_lookups=%d ptr_errors=%d dns_attempts=%d dns_ok=%d dns_nxdomain=%d dns_timeout=%d dns_temporary=%d dns_other=%d dns_errors=%d duration_ms=%d",
|
|
len(domains),
|
|
len(fresh),
|
|
len(resolved)-len(fresh),
|
|
len(domains)-len(resolved),
|
|
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,
|
|
dnsErrors,
|
|
time.Since(start).Milliseconds(),
|
|
)
|
|
if perUpstream := dnsStats.formatPerUpstream(); perUpstream != "" {
|
|
logf("resolve dns upstreams: %s", perUpstream)
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// DNS resolve helpers
|
|
// ---------------------------------------------------------------------
|
|
|
|
func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards wildcardMatcher, timeout time.Duration, logf func(string, ...any)) ([]string, dnsMetrics) {
|
|
useMeta := false
|
|
for _, m := range metaSpecial {
|
|
if host == m {
|
|
useMeta = true
|
|
break
|
|
}
|
|
}
|
|
dnsList := cfg.Default
|
|
if useMeta {
|
|
dnsList = cfg.Meta
|
|
}
|
|
switch cfg.Mode {
|
|
case DNSModeSmartDNS:
|
|
if cfg.SmartDNS != "" {
|
|
dnsList = []string{cfg.SmartDNS}
|
|
}
|
|
case DNSModeHybridWildcard:
|
|
if cfg.SmartDNS != "" && wildcards.match(host) {
|
|
dnsList = []string{cfg.SmartDNS}
|
|
}
|
|
}
|
|
ips, stats := digA(host, dnsList, timeout, logf)
|
|
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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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) {
|
|
stats := dnsMetrics{}
|
|
if len(dnsList) == 0 {
|
|
return nil, stats
|
|
}
|
|
|
|
tryLimit := envInt("RESOLVE_DNS_TRY_LIMIT", 2)
|
|
if tryLimit < 1 {
|
|
tryLimit = 1
|
|
}
|
|
if tryLimit > len(dnsList) {
|
|
tryLimit = len(dnsList)
|
|
}
|
|
|
|
start := pickDNSStartIndex(host, len(dnsList))
|
|
for attempt := 0; attempt < tryLimit; attempt++ {
|
|
entry := dnsList[(start+attempt)%len(dnsList)]
|
|
server, port := splitDNS(entry)
|
|
if server == "" {
|
|
continue
|
|
}
|
|
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)
|
|
records, err := r.LookupHost(ctx, host)
|
|
cancel()
|
|
if err != nil {
|
|
kind := classifyDNSError(err)
|
|
stats.addError(addr, kind)
|
|
if logf != nil {
|
|
logf("dns warn %s via %s: kind=%s err=%v", host, addr, kind, err)
|
|
}
|
|
// NXDOMAIN usually means authoritative negative answer.
|
|
// Do not fan out further retries for this host.
|
|
if kind == dnsErrorNXDomain {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
stats.addSuccess(addr)
|
|
var ips []string
|
|
for _, ip := range records {
|
|
if isPrivateIPv4(ip) {
|
|
continue
|
|
}
|
|
ips = append(ips, ip)
|
|
}
|
|
return uniqueStrings(ips), stats
|
|
}
|
|
return nil, stats
|
|
}
|
|
|
|
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"`
|
|
LastResolved int `json:"last_resolved"`
|
|
}
|
|
|
|
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: 2,
|
|
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 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 = 2
|
|
}
|
|
normalized := newDomainCacheState()
|
|
for host, rec := range st.Domains {
|
|
host = strings.TrimSpace(strings.ToLower(host))
|
|
if host == "" {
|
|
continue
|
|
}
|
|
nrec := domainCacheRecord{}
|
|
if rec.Direct != nil {
|
|
ips := normalizeCacheIPs(rec.Direct.IPs)
|
|
if len(ips) > 0 && rec.Direct.LastResolved > 0 {
|
|
nrec.Direct = &domainCacheEntry{IPs: ips, LastResolved: rec.Direct.LastResolved}
|
|
}
|
|
}
|
|
if rec.Wildcard != nil {
|
|
ips := normalizeCacheIPs(rec.Wildcard.IPs)
|
|
if len(ips) > 0 && rec.Wildcard.LastResolved > 0 {
|
|
nrec.Wildcard = &domainCacheEntry{IPs: ips, LastResolved: rec.Wildcard.LastResolved}
|
|
}
|
|
}
|
|
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) 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]
|
|
entry := &domainCacheEntry{IPs: norm, LastResolved: now}
|
|
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": 2,
|
|
"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 && len(rec.Direct.IPs) > 0 && rec.Direct.LastResolved > 0 {
|
|
recOut["direct"] = map[string]any{
|
|
"ips": rec.Direct.IPs,
|
|
"last_resolved": rec.Direct.LastResolved,
|
|
}
|
|
}
|
|
if rec.Wildcard != nil && len(rec.Wildcard.IPs) > 0 && rec.Wildcard.LastResolved > 0 {
|
|
recOut["wildcard"] = map[string]any{
|
|
"ips": rec.Wildcard.IPs,
|
|
"last_resolved": rec.Wildcard.LastResolved,
|
|
}
|
|
}
|
|
if len(recOut) > 0 {
|
|
domainsAny[host] = recOut
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
// 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, как и раньше.
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if logf != nil {
|
|
logf("dns-config: use built-in defaults, can't read %s: %v", path, err)
|
|
}
|
|
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(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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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
|
|
}
|