platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
88
selective-vpn-api/app/resolver/artifacts.go
Normal file
88
selective-vpn-api/app/resolver/artifacts.go
Normal 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
|
||||
}
|
||||
60
selective-vpn-api/app/resolver/common.go
Normal file
60
selective-vpn-api/app/resolver/common.go
Normal 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
|
||||
}
|
||||
150
selective-vpn-api/app/resolver/dns_config.go
Normal file
150
selective-vpn-api/app/resolver/dns_config.go
Normal 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
|
||||
}
|
||||
52
selective-vpn-api/app/resolver/dns_helpers.go
Normal file
52
selective-vpn-api/app/resolver/dns_helpers.go
Normal 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), ""
|
||||
}
|
||||
158
selective-vpn-api/app/resolver/dns_metrics.go
Normal file
158
selective-vpn-api/app/resolver/dns_metrics.go
Normal 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, "; ")
|
||||
}
|
||||
57
selective-vpn-api/app/resolver/dns_upstreams.go
Normal file
57
selective-vpn-api/app/resolver/dns_upstreams.go
Normal 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
|
||||
}
|
||||
649
selective-vpn-api/app/resolver/domain_cache.go
Normal file
649
selective-vpn-api/app/resolver/domain_cache.go
Normal 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,
|
||||
)
|
||||
}
|
||||
53
selective-vpn-api/app/resolver/error_policy.go
Normal file
53
selective-vpn-api/app/resolver/error_policy.go
Normal 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
|
||||
}
|
||||
234
selective-vpn-api/app/resolver/host_lookup.go
Normal file
234
selective-vpn-api/app/resolver/host_lookup.go
Normal 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,
|
||||
}
|
||||
}
|
||||
135
selective-vpn-api/app/resolver/io_helpers.go
Normal file
135
selective-vpn-api/app/resolver/io_helpers.go
Normal 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
|
||||
}
|
||||
113
selective-vpn-api/app/resolver/live_batch.go
Normal file
113
selective-vpn-api/app/resolver/live_batch.go
Normal 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
|
||||
}
|
||||
161
selective-vpn-api/app/resolver/live_batch_select.go
Normal file
161
selective-vpn-api/app/resolver/live_batch_select.go
Normal 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
|
||||
}
|
||||
62
selective-vpn-api/app/resolver/mode_runtime.go
Normal file
62
selective-vpn-api/app/resolver/mode_runtime.go
Normal 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)
|
||||
}
|
||||
}
|
||||
119
selective-vpn-api/app/resolver/planning.go
Normal file
119
selective-vpn-api/app/resolver/planning.go
Normal 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
|
||||
}
|
||||
106
selective-vpn-api/app/resolver/precheck_finalize.go
Normal file
106
selective-vpn-api/app/resolver/precheck_finalize.go
Normal 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
|
||||
}
|
||||
39
selective-vpn-api/app/resolver/precheck_state.go
Normal file
39
selective-vpn-api/app/resolver/precheck_state.go
Normal 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)
|
||||
}
|
||||
31
selective-vpn-api/app/resolver/precheck_types.go
Normal file
31
selective-vpn-api/app/resolver/precheck_types.go
Normal 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
|
||||
}
|
||||
115
selective-vpn-api/app/resolver/resolve_batch.go
Normal file
115
selective-vpn-api/app/resolver/resolve_batch.go
Normal 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
|
||||
}
|
||||
205
selective-vpn-api/app/resolver/runtime_tuning.go
Normal file
205
selective-vpn-api/app/resolver/runtime_tuning.go
Normal 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,
|
||||
}
|
||||
}
|
||||
64
selective-vpn-api/app/resolver/start_log.go
Normal file
64
selective-vpn-api/app/resolver/start_log.go
Normal 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,
|
||||
)
|
||||
}
|
||||
139
selective-vpn-api/app/resolver/static_labels.go
Normal file
139
selective-vpn-api/app/resolver/static_labels.go
Normal 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
|
||||
}
|
||||
112
selective-vpn-api/app/resolver/summary_log.go
Normal file
112
selective-vpn-api/app/resolver/summary_log.go
Normal 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)
|
||||
}
|
||||
}
|
||||
127
selective-vpn-api/app/resolver/timeout_recheck.go
Normal file
127
selective-vpn-api/app/resolver/timeout_recheck.go
Normal 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
|
||||
}
|
||||
70
selective-vpn-api/app/resolver/wildcard_matcher.go
Normal file
70
selective-vpn-api/app/resolver/wildcard_matcher.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user