Files
elmprodvpn/selective-vpn-api/app/resolver.go
beckline 10a10f44a8 baseline: api+gui traffic mode + candidates picker
Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
2026-02-14 15:52:20 +03:00

1171 lines
30 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"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
}
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
}
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", len(domains), ttl, workers)
}
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, 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, 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, 3*time.Second, 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) {
var ips []string
stats := dnsMetrics{}
for _, entry := range 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)
}
continue
}
stats.addSuccess(addr)
for _, ip := range records {
if isPrivateIPv4(ip) {
continue
}
ips = append(ips, ip)
}
}
return uniqueStrings(ips), 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
}
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
}
// ---------------------------------------------------------------------
// 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
}