platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
358
selective-vpn-api/app/dnscfg/benchmark.go
Normal file
358
selective-vpn-api/app/dnscfg/benchmark.go
Normal file
@@ -0,0 +1,358 @@
|
||||
package dnscfg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
BenchmarkProfileQuick = "quick"
|
||||
BenchmarkProfileLoad = "load"
|
||||
|
||||
BenchmarkErrorNXDomain = "nxdomain"
|
||||
BenchmarkErrorTimeout = "timeout"
|
||||
BenchmarkErrorTemporary = "temporary"
|
||||
BenchmarkErrorOther = "other"
|
||||
)
|
||||
|
||||
var BenchmarkDefaultDomains = []string{
|
||||
"cloudflare.com",
|
||||
"google.com",
|
||||
"telegram.org",
|
||||
"github.com",
|
||||
"youtube.com",
|
||||
"twitter.com",
|
||||
}
|
||||
|
||||
type BenchmarkOptions struct {
|
||||
Profile string
|
||||
LoadWorkers int
|
||||
Rounds int
|
||||
SyntheticPerDomain int
|
||||
}
|
||||
|
||||
type BenchmarkResult struct {
|
||||
Upstream string
|
||||
Attempts int
|
||||
OK int
|
||||
Fail int
|
||||
NXDomain int
|
||||
Timeout int
|
||||
Temporary int
|
||||
Other int
|
||||
AvgMS int
|
||||
P95MS int
|
||||
Score float64
|
||||
Color string
|
||||
}
|
||||
|
||||
func NormalizeBenchmarkProfile(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", BenchmarkProfileLoad:
|
||||
return BenchmarkProfileLoad
|
||||
case BenchmarkProfileQuick:
|
||||
return BenchmarkProfileQuick
|
||||
default:
|
||||
return BenchmarkProfileLoad
|
||||
}
|
||||
}
|
||||
|
||||
func MakeDNSBenchmarkOptions(profile string, concurrency int) BenchmarkOptions {
|
||||
if concurrency < 1 {
|
||||
concurrency = 1
|
||||
}
|
||||
if profile == BenchmarkProfileQuick {
|
||||
return BenchmarkOptions{
|
||||
Profile: BenchmarkProfileQuick,
|
||||
LoadWorkers: 1,
|
||||
Rounds: 1,
|
||||
SyntheticPerDomain: 0,
|
||||
}
|
||||
}
|
||||
workers := concurrency * 2
|
||||
if workers < 4 {
|
||||
workers = 4
|
||||
}
|
||||
if workers > 16 {
|
||||
workers = 16
|
||||
}
|
||||
return BenchmarkOptions{
|
||||
Profile: BenchmarkProfileLoad,
|
||||
LoadWorkers: workers,
|
||||
Rounds: 3,
|
||||
SyntheticPerDomain: 2,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeBenchmarkUpstreamStrings(in []string, normalizeUpstream func(string, string) string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range in {
|
||||
n := strings.TrimSpace(raw)
|
||||
if normalizeUpstream != nil {
|
||||
n = normalizeUpstream(n, "53")
|
||||
}
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NormalizeBenchmarkDomains(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(in))
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range in {
|
||||
d := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".")
|
||||
if d == "" || strings.HasPrefix(d, "#") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[d]; ok {
|
||||
continue
|
||||
}
|
||||
seen[d] = struct{}{}
|
||||
out = append(out, d)
|
||||
}
|
||||
if len(out) > 100 {
|
||||
out = out[:100]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func BenchmarkDNSUpstream(
|
||||
upstream string,
|
||||
domains []string,
|
||||
timeout time.Duration,
|
||||
attempts int,
|
||||
opts BenchmarkOptions,
|
||||
lookupAOnce func(host, upstream string, timeout time.Duration) ([]string, error),
|
||||
classifyErr func(error) string,
|
||||
) BenchmarkResult {
|
||||
res := BenchmarkResult{Upstream: upstream}
|
||||
probes := BuildBenchmarkProbeHosts(domains, attempts, opts)
|
||||
if len(probes) == 0 {
|
||||
return res
|
||||
}
|
||||
durations := make([]int, 0, len(probes))
|
||||
var mu sync.Mutex
|
||||
jobs := make(chan string, len(probes))
|
||||
workers := opts.LoadWorkers
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
if workers > len(probes) {
|
||||
workers = len(probes)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for host := range jobs {
|
||||
start := time.Now()
|
||||
_, err := lookupAOnce(host, upstream, timeout)
|
||||
elapsed := int(time.Since(start).Milliseconds())
|
||||
if elapsed < 1 {
|
||||
elapsed = 1
|
||||
}
|
||||
mu.Lock()
|
||||
res.Attempts++
|
||||
durations = append(durations, elapsed)
|
||||
if err != nil {
|
||||
res.Fail++
|
||||
switch strings.ToLower(strings.TrimSpace(classifyErr(err))) {
|
||||
case BenchmarkErrorNXDomain:
|
||||
res.NXDomain++
|
||||
case BenchmarkErrorTimeout:
|
||||
res.Timeout++
|
||||
case BenchmarkErrorTemporary:
|
||||
res.Temporary++
|
||||
default:
|
||||
res.Other++
|
||||
}
|
||||
} else {
|
||||
res.OK++
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, host := range probes {
|
||||
jobs <- host
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
if len(durations) > 0 {
|
||||
sort.Ints(durations)
|
||||
sum := 0
|
||||
for _, d := range durations {
|
||||
sum += d
|
||||
}
|
||||
res.AvgMS = sum / len(durations)
|
||||
idx := int(float64(len(durations)-1) * 0.95)
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
res.P95MS = durations[idx]
|
||||
}
|
||||
|
||||
total := res.Attempts
|
||||
if total > 0 {
|
||||
okRate := float64(res.OK) / float64(total)
|
||||
answeredRate := float64(res.OK+res.NXDomain+res.Temporary+res.Other) / float64(total)
|
||||
timeoutRate := float64(res.Timeout) / float64(total)
|
||||
temporaryRate := float64(res.Temporary) / float64(total)
|
||||
otherRate := float64(res.Other) / float64(total)
|
||||
avg := float64(res.AvgMS)
|
||||
if avg <= 0 {
|
||||
avg = float64(timeout.Milliseconds())
|
||||
}
|
||||
p95 := float64(res.P95MS)
|
||||
if p95 <= 0 {
|
||||
p95 = avg
|
||||
}
|
||||
res.Score = answeredRate*100.0 + okRate*15.0 - timeoutRate*120.0 - temporaryRate*35.0 - otherRate*20.0 - (avg / 25.0) - (p95 / 45.0)
|
||||
}
|
||||
|
||||
timeoutRate := 0.0
|
||||
answeredRate := 0.0
|
||||
if res.Attempts > 0 {
|
||||
timeoutRate = float64(res.Timeout) / float64(res.Attempts)
|
||||
answeredRate = float64(res.OK+res.NXDomain+res.Temporary+res.Other) / float64(res.Attempts)
|
||||
}
|
||||
switch {
|
||||
case answeredRate < 0.85 || timeoutRate >= 0.10 || res.P95MS > 1800:
|
||||
res.Color = "red"
|
||||
case answeredRate >= 0.97 && timeoutRate <= 0.02 && res.P95MS <= 700:
|
||||
res.Color = "green"
|
||||
default:
|
||||
res.Color = "yellow"
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func BuildBenchmarkProbeHosts(domains []string, attempts int, opts BenchmarkOptions) []string {
|
||||
if len(domains) == 0 {
|
||||
return nil
|
||||
}
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
rounds := opts.Rounds
|
||||
if rounds < 1 {
|
||||
rounds = 1
|
||||
}
|
||||
synth := opts.SyntheticPerDomain
|
||||
if synth < 0 {
|
||||
synth = 0
|
||||
}
|
||||
out := make([]string, 0, len(domains)*attempts*rounds*(1+synth))
|
||||
for round := 0; round < rounds; round++ {
|
||||
for _, host := range domains {
|
||||
for i := 0; i < attempts; i++ {
|
||||
out = append(out, host)
|
||||
}
|
||||
for n := 0; n < synth; n++ {
|
||||
out = append(out, fmt.Sprintf("svpn-bench-%d-%d.%s", round+1, n+1, host))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 10000 {
|
||||
out = out[:10000]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func DNSLookupAOnce(
|
||||
host string,
|
||||
upstream string,
|
||||
timeout time.Duration,
|
||||
splitDNS func(string) (string, string),
|
||||
isPrivateIPv4 func(string) bool,
|
||||
) ([]string, error) {
|
||||
if splitDNS == nil {
|
||||
return nil, fmt.Errorf("splitDNS callback is nil")
|
||||
}
|
||||
server, port := splitDNS(upstream)
|
||||
if server == "" {
|
||||
return nil, fmt.Errorf("upstream empty")
|
||||
}
|
||||
if port == "" {
|
||||
port = "53"
|
||||
}
|
||||
addr := net.JoinHostPort(server, port)
|
||||
resolver := &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
d := net.Dialer{}
|
||||
return d.DialContext(ctx, "udp", addr)
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
records, err := resolver.LookupHost(ctx, host)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(records))
|
||||
for _, ip := range records {
|
||||
if isPrivateIPv4 != nil && isPrivateIPv4(ip) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ip]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ip] = struct{}{}
|
||||
out = append(out, ip)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no public ips")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func BenchmarkTopN(results []BenchmarkResult, n int, fallback []string) []string {
|
||||
out := make([]string, 0, n)
|
||||
for _, item := range results {
|
||||
if item.OK <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, item.Upstream)
|
||||
if len(out) >= n {
|
||||
return out
|
||||
}
|
||||
}
|
||||
for _, item := range fallback {
|
||||
if len(out) >= n {
|
||||
break
|
||||
}
|
||||
dup := false
|
||||
for _, e := range out {
|
||||
if e == item {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
136
selective-vpn-api/app/dnscfg/mode.go
Normal file
136
selective-vpn-api/app/dnscfg/mode.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package dnscfg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizeResolverMode(mode string, viaSmartDNS bool, directMode string, smartDNSMode string, hybridWildcardMode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case strings.ToLower(strings.TrimSpace(directMode)):
|
||||
return directMode
|
||||
case strings.ToLower(strings.TrimSpace(smartDNSMode)):
|
||||
return hybridWildcardMode
|
||||
case strings.ToLower(strings.TrimSpace(hybridWildcardMode)), "hybrid":
|
||||
return hybridWildcardMode
|
||||
default:
|
||||
if viaSmartDNS {
|
||||
return hybridWildcardMode
|
||||
}
|
||||
return directMode
|
||||
}
|
||||
}
|
||||
|
||||
func SmartDNSForced(envRaw string) bool {
|
||||
switch strings.TrimSpace(strings.ToLower(envRaw)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ModeState struct {
|
||||
ViaSmartDNS bool `json:"via_smartdns"`
|
||||
SmartDNSAddr string `json:"smartdns_addr"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type ModeConfig struct {
|
||||
Path string
|
||||
DirectMode string
|
||||
DefaultSmartDNSAddr string
|
||||
NormalizeResolverMode func(mode string, viaSmartDNS bool) string
|
||||
NormalizeSmartDNSAddr func(raw string) string
|
||||
}
|
||||
|
||||
func LoadMode(cfg ModeConfig) (ModeState, bool) {
|
||||
mode := ModeState{
|
||||
ViaSmartDNS: false,
|
||||
SmartDNSAddr: strings.TrimSpace(cfg.DefaultSmartDNSAddr),
|
||||
Mode: strings.TrimSpace(cfg.DirectMode),
|
||||
}
|
||||
needPersist := false
|
||||
|
||||
data, err := os.ReadFile(strings.TrimSpace(cfg.Path))
|
||||
switch {
|
||||
case err == nil:
|
||||
var stored ModeState
|
||||
if err := json.Unmarshal(data, &stored); err == nil {
|
||||
normalized, changed := normalizeModeState(stored, cfg)
|
||||
mode = normalized
|
||||
if strings.TrimSpace(stored.Mode) == "" || stored.ViaSmartDNS != normalized.ViaSmartDNS || changed {
|
||||
needPersist = true
|
||||
}
|
||||
} else {
|
||||
needPersist = true
|
||||
}
|
||||
case os.IsNotExist(err):
|
||||
needPersist = true
|
||||
}
|
||||
|
||||
normalized, changed := normalizeModeState(mode, cfg)
|
||||
mode = normalized
|
||||
if changed {
|
||||
needPersist = true
|
||||
}
|
||||
return mode, needPersist
|
||||
}
|
||||
|
||||
func SaveMode(cfg ModeConfig, mode ModeState) error {
|
||||
normalized, _ := normalizeModeState(mode, cfg)
|
||||
path := strings.TrimSpace(cfg.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
b, err := json.MarshalIndent(normalized, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func normalizeModeState(mode ModeState, cfg ModeConfig) (ModeState, bool) {
|
||||
changed := false
|
||||
prevMode := mode.Mode
|
||||
mode.Mode = normalizeResolverMode(cfg, mode.Mode, mode.ViaSmartDNS)
|
||||
if mode.Mode != prevMode {
|
||||
changed = true
|
||||
}
|
||||
|
||||
viaSmartDNS := mode.Mode != strings.TrimSpace(cfg.DirectMode)
|
||||
if mode.ViaSmartDNS != viaSmartDNS {
|
||||
mode.ViaSmartDNS = viaSmartDNS
|
||||
changed = true
|
||||
}
|
||||
|
||||
prevAddr := mode.SmartDNSAddr
|
||||
mode.SmartDNSAddr = normalizeSmartDNSAddr(cfg, mode.SmartDNSAddr)
|
||||
if mode.SmartDNSAddr == "" {
|
||||
mode.SmartDNSAddr = strings.TrimSpace(cfg.DefaultSmartDNSAddr)
|
||||
}
|
||||
if mode.SmartDNSAddr != prevAddr {
|
||||
changed = true
|
||||
}
|
||||
return mode, changed
|
||||
}
|
||||
|
||||
func normalizeResolverMode(cfg ModeConfig, mode string, viaSmartDNS bool) string {
|
||||
if cfg.NormalizeResolverMode == nil {
|
||||
return strings.TrimSpace(mode)
|
||||
}
|
||||
return strings.TrimSpace(cfg.NormalizeResolverMode(mode, viaSmartDNS))
|
||||
}
|
||||
|
||||
func normalizeSmartDNSAddr(cfg ModeConfig, raw string) string {
|
||||
if cfg.NormalizeSmartDNSAddr == nil {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
return strings.TrimSpace(cfg.NormalizeSmartDNSAddr(raw))
|
||||
}
|
||||
100
selective-vpn-api/app/dnscfg/pool.go
Normal file
100
selective-vpn-api/app/dnscfg/pool.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package dnscfg
|
||||
|
||||
import "strings"
|
||||
|
||||
type Upstreams struct {
|
||||
Default1 string
|
||||
Default2 string
|
||||
Meta1 string
|
||||
Meta2 string
|
||||
}
|
||||
|
||||
type UpstreamPoolItem struct {
|
||||
Addr string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func NormalizeUpstreamPoolItems(items []UpstreamPoolItem, normalizeUpstream func(raw string, defaultPort string) string) []UpstreamPoolItem {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]UpstreamPoolItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
addr := strings.TrimSpace(item.Addr)
|
||||
if normalizeUpstream != nil {
|
||||
addr = normalizeUpstream(addr, "53")
|
||||
}
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[addr]; ok {
|
||||
continue
|
||||
}
|
||||
seen[addr] = struct{}{}
|
||||
out = append(out, UpstreamPoolItem{
|
||||
Addr: addr,
|
||||
Enabled: item.Enabled,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func UpstreamPoolFromLegacy(cfg Upstreams, normalizeUpstream func(raw string, defaultPort string) string) []UpstreamPoolItem {
|
||||
out := []UpstreamPoolItem{
|
||||
{Addr: cfg.Default1, Enabled: true},
|
||||
{Addr: cfg.Default2, Enabled: true},
|
||||
{Addr: cfg.Meta1, Enabled: true},
|
||||
{Addr: cfg.Meta2, Enabled: true},
|
||||
}
|
||||
return NormalizeUpstreamPoolItems(out, normalizeUpstream)
|
||||
}
|
||||
|
||||
func UpstreamPoolToLegacy(items []UpstreamPoolItem, defaults Upstreams, normalizeUpstream func(raw string, defaultPort string) string) Upstreams {
|
||||
items = NormalizeUpstreamPoolItems(items, normalizeUpstream)
|
||||
out := defaults
|
||||
enabled := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !item.Enabled {
|
||||
continue
|
||||
}
|
||||
addr := strings.TrimSpace(item.Addr)
|
||||
if normalizeUpstream != nil {
|
||||
addr = normalizeUpstream(addr, "53")
|
||||
}
|
||||
if addr != "" {
|
||||
enabled = append(enabled, addr)
|
||||
}
|
||||
}
|
||||
if len(enabled) > 0 {
|
||||
out.Default1 = enabled[0]
|
||||
}
|
||||
if len(enabled) > 1 {
|
||||
out.Default2 = enabled[1]
|
||||
}
|
||||
if len(enabled) > 2 {
|
||||
out.Meta1 = enabled[2]
|
||||
}
|
||||
if len(enabled) > 3 {
|
||||
out.Meta2 = enabled[3]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func EnabledPool(items []UpstreamPoolItem, normalizeUpstream func(raw string, defaultPort string) string) []string {
|
||||
items = NormalizeUpstreamPoolItems(items, normalizeUpstream)
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !item.Enabled {
|
||||
continue
|
||||
}
|
||||
addr := strings.TrimSpace(item.Addr)
|
||||
if normalizeUpstream != nil {
|
||||
addr = normalizeUpstream(addr, "53")
|
||||
}
|
||||
if addr != "" {
|
||||
out = append(out, addr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
392
selective-vpn-api/app/dnscfg/prewarm.go
Normal file
392
selective-vpn-api/app/dnscfg/prewarm.go
Normal file
@@ -0,0 +1,392 @@
|
||||
package dnscfg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PrewarmDNSUpstreamMetrics struct {
|
||||
Attempts int
|
||||
OK int
|
||||
NXDomain int
|
||||
Timeout int
|
||||
Temporary int
|
||||
Other int
|
||||
Skipped int
|
||||
}
|
||||
|
||||
type PrewarmDNSMetrics struct {
|
||||
Attempts int
|
||||
OK int
|
||||
NXDomain int
|
||||
Timeout int
|
||||
Temporary int
|
||||
Other int
|
||||
Skipped int
|
||||
|
||||
PerUpstream map[string]PrewarmDNSUpstreamMetrics
|
||||
}
|
||||
|
||||
func (m *PrewarmDNSMetrics) Merge(other PrewarmDNSMetrics) {
|
||||
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
|
||||
if len(other.PerUpstream) == 0 {
|
||||
return
|
||||
}
|
||||
if m.PerUpstream == nil {
|
||||
m.PerUpstream = map[string]PrewarmDNSUpstreamMetrics{}
|
||||
}
|
||||
for upstream, src := range other.PerUpstream {
|
||||
dst := m.PerUpstream[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
|
||||
m.PerUpstream[upstream] = dst
|
||||
}
|
||||
}
|
||||
|
||||
func (m PrewarmDNSMetrics) TotalErrors() int {
|
||||
return m.NXDomain + m.Timeout + m.Temporary + m.Other
|
||||
}
|
||||
|
||||
func (m PrewarmDNSMetrics) 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, "; ")
|
||||
}
|
||||
|
||||
type PrewarmInput struct {
|
||||
Mode string
|
||||
Source string
|
||||
RuntimeEnabled bool
|
||||
SmartDNSAddr string
|
||||
Wildcards []string
|
||||
AggressiveSubs bool
|
||||
Subs []string
|
||||
SubsPerBaseLimit int
|
||||
Limit int
|
||||
Workers int
|
||||
TimeoutMS int
|
||||
EnvWorkers int
|
||||
EnvTimeoutMS int
|
||||
MaxHostsLog int
|
||||
WildcardMapPath string
|
||||
}
|
||||
|
||||
type PrewarmDeps struct {
|
||||
IsGoogleLike func(string) bool
|
||||
EnsureRuntimeSet func()
|
||||
DigA func(host string, dnsList []string, timeout time.Duration) ([]string, PrewarmDNSMetrics)
|
||||
ReadDynSet func() ([]string, error)
|
||||
ApplyDynSet func([]string) error
|
||||
Logf func(message string)
|
||||
}
|
||||
|
||||
type PrewarmResult struct {
|
||||
OK bool
|
||||
Message string
|
||||
ExitCode int
|
||||
ResolvedHosts int
|
||||
}
|
||||
|
||||
func RunPrewarm(in PrewarmInput, deps PrewarmDeps) PrewarmResult {
|
||||
smartdnsAddr := strings.TrimSpace(in.SmartDNSAddr)
|
||||
if smartdnsAddr == "" {
|
||||
return PrewarmResult{OK: false, Message: "SmartDNS address is empty"}
|
||||
}
|
||||
|
||||
wildcards := trimNonEmptyUnique(in.Wildcards)
|
||||
if len(wildcards) == 0 {
|
||||
msg := "prewarm skipped: wildcard list is empty"
|
||||
logPrewarm(deps.Logf, msg)
|
||||
return PrewarmResult{OK: true, Message: msg}
|
||||
}
|
||||
|
||||
aggressive := in.AggressiveSubs
|
||||
subs := trimNonEmptyUnique(in.Subs)
|
||||
subsPerBaseLimit := in.SubsPerBaseLimit
|
||||
if subsPerBaseLimit < 0 {
|
||||
subsPerBaseLimit = 0
|
||||
}
|
||||
|
||||
domainSet := make(map[string]struct{}, len(wildcards)*(len(subs)+1))
|
||||
for _, d := range wildcards {
|
||||
domainSet[d] = struct{}{}
|
||||
if !aggressive || isGoogleLikeSafe(deps.IsGoogleLike, d) {
|
||||
continue
|
||||
}
|
||||
maxSubs := len(subs)
|
||||
if subsPerBaseLimit > 0 && subsPerBaseLimit < maxSubs {
|
||||
maxSubs = subsPerBaseLimit
|
||||
}
|
||||
for i := 0; i < maxSubs; i++ {
|
||||
domainSet[subs[i]+"."+d] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
domains := make([]string, 0, len(domainSet))
|
||||
for d := range domainSet {
|
||||
domains = append(domains, d)
|
||||
}
|
||||
sort.Strings(domains)
|
||||
if in.Limit > 0 && len(domains) > in.Limit {
|
||||
domains = domains[:in.Limit]
|
||||
}
|
||||
if len(domains) == 0 {
|
||||
msg := "prewarm skipped: expanded wildcard list is empty"
|
||||
logPrewarm(deps.Logf, msg)
|
||||
return PrewarmResult{OK: true, Message: msg}
|
||||
}
|
||||
|
||||
workers := in.Workers
|
||||
if workers <= 0 {
|
||||
workers = in.EnvWorkers
|
||||
if workers <= 0 {
|
||||
workers = 24
|
||||
}
|
||||
}
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
if workers > 200 {
|
||||
workers = 200
|
||||
}
|
||||
|
||||
timeoutMS := in.TimeoutMS
|
||||
if timeoutMS <= 0 {
|
||||
timeoutMS = in.EnvTimeoutMS
|
||||
if timeoutMS <= 0 {
|
||||
timeoutMS = 1800
|
||||
}
|
||||
}
|
||||
if timeoutMS < 200 {
|
||||
timeoutMS = 200
|
||||
}
|
||||
if timeoutMS > 15000 {
|
||||
timeoutMS = 15000
|
||||
}
|
||||
timeout := time.Duration(timeoutMS) * time.Millisecond
|
||||
|
||||
if deps.EnsureRuntimeSet != nil {
|
||||
deps.EnsureRuntimeSet()
|
||||
}
|
||||
|
||||
logPrewarm(
|
||||
deps.Logf,
|
||||
fmt.Sprintf(
|
||||
"prewarm start: mode=%s source=%s runtime_nftset=%t smartdns=%s wildcard_domains=%d expanded=%d aggressive_subs=%t workers=%d timeout_ms=%d",
|
||||
strings.TrimSpace(in.Mode),
|
||||
strings.TrimSpace(in.Source),
|
||||
in.RuntimeEnabled,
|
||||
smartdnsAddr,
|
||||
len(wildcards),
|
||||
len(domains),
|
||||
aggressive,
|
||||
workers,
|
||||
timeoutMS,
|
||||
),
|
||||
)
|
||||
|
||||
type prewarmItem struct {
|
||||
host string
|
||||
ips []string
|
||||
stats PrewarmDNSMetrics
|
||||
}
|
||||
|
||||
jobs := make(chan string, len(domains))
|
||||
results := make(chan prewarmItem, len(domains))
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
for host := range jobs {
|
||||
ips, stats := safeDigA(deps.DigA, host, []string{smartdnsAddr}, timeout)
|
||||
results <- prewarmItem{host: host, ips: ips, stats: stats}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, host := range domains {
|
||||
jobs <- host
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
resolvedHosts := 0
|
||||
totalIPs := 0
|
||||
errorHosts := 0
|
||||
stats := PrewarmDNSMetrics{}
|
||||
resolvedIPSet := map[string]struct{}{}
|
||||
loggedHosts := 0
|
||||
maxHostsLog := in.MaxHostsLog
|
||||
if maxHostsLog <= 0 {
|
||||
maxHostsLog = 200
|
||||
}
|
||||
|
||||
for i := 0; i < len(domains); i++ {
|
||||
item := <-results
|
||||
stats.Merge(item.stats)
|
||||
if item.stats.TotalErrors() > 0 {
|
||||
errorHosts++
|
||||
}
|
||||
if len(item.ips) == 0 {
|
||||
continue
|
||||
}
|
||||
resolvedHosts++
|
||||
totalIPs += len(item.ips)
|
||||
for _, ip := range item.ips {
|
||||
if strings.TrimSpace(ip) != "" {
|
||||
resolvedIPSet[ip] = struct{}{}
|
||||
}
|
||||
}
|
||||
if loggedHosts < maxHostsLog {
|
||||
logPrewarm(deps.Logf, fmt.Sprintf("prewarm add: %s -> %s", item.host, strings.Join(item.ips, ", ")))
|
||||
loggedHosts++
|
||||
}
|
||||
}
|
||||
|
||||
manualAdded := 0
|
||||
totalDynText := "n/a"
|
||||
if !in.RuntimeEnabled {
|
||||
existing, _ := safeReadDynSet(deps.ReadDynSet)
|
||||
mergedSet := make(map[string]struct{}, len(existing)+len(resolvedIPSet))
|
||||
for _, ip := range existing {
|
||||
if strings.TrimSpace(ip) != "" {
|
||||
mergedSet[ip] = struct{}{}
|
||||
}
|
||||
}
|
||||
for ip := range resolvedIPSet {
|
||||
if _, ok := mergedSet[ip]; !ok {
|
||||
manualAdded++
|
||||
}
|
||||
mergedSet[ip] = struct{}{}
|
||||
}
|
||||
merged := make([]string, 0, len(mergedSet))
|
||||
for ip := range mergedSet {
|
||||
merged = append(merged, ip)
|
||||
}
|
||||
totalDynText = fmt.Sprintf("%d", len(merged))
|
||||
if err := safeApplyDynSet(deps.ApplyDynSet, merged); err != nil {
|
||||
msg := fmt.Sprintf("prewarm manual apply failed: %v", err)
|
||||
logPrewarm(deps.Logf, msg)
|
||||
return PrewarmResult{OK: false, Message: msg}
|
||||
}
|
||||
logPrewarm(
|
||||
deps.Logf,
|
||||
fmt.Sprintf("prewarm manual merge: existing=%d resolved=%d added=%d total_dyn=%d", len(existing), len(resolvedIPSet), manualAdded, len(merged)),
|
||||
)
|
||||
}
|
||||
|
||||
if len(domains) > loggedHosts {
|
||||
logPrewarm(
|
||||
deps.Logf,
|
||||
fmt.Sprintf(
|
||||
"prewarm add: trace truncated, omitted=%d hosts (full wildcard map: %s)",
|
||||
len(domains)-loggedHosts,
|
||||
strings.TrimSpace(in.WildcardMapPath),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"prewarm done: source=%s expanded=%d resolved=%d total_ips=%d error_hosts=%d dns_attempts=%d dns_ok=%d dns_errors=%d manual_added=%d dyn_total=%s",
|
||||
strings.TrimSpace(in.Source),
|
||||
len(domains),
|
||||
resolvedHosts,
|
||||
totalIPs,
|
||||
errorHosts,
|
||||
stats.Attempts,
|
||||
stats.OK,
|
||||
stats.TotalErrors(),
|
||||
manualAdded,
|
||||
totalDynText,
|
||||
)
|
||||
logPrewarm(deps.Logf, msg)
|
||||
if perUpstream := stats.FormatPerUpstream(); perUpstream != "" {
|
||||
logPrewarm(deps.Logf, "prewarm dns upstreams: "+perUpstream)
|
||||
}
|
||||
|
||||
return PrewarmResult{
|
||||
OK: true,
|
||||
Message: msg,
|
||||
ExitCode: resolvedHosts,
|
||||
ResolvedHosts: resolvedHosts,
|
||||
}
|
||||
}
|
||||
|
||||
func logPrewarm(logf func(string), msg string) {
|
||||
if logf != nil {
|
||||
logf(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func safeDigA(
|
||||
dig func(host string, dnsList []string, timeout time.Duration) ([]string, PrewarmDNSMetrics),
|
||||
host string,
|
||||
dnsList []string,
|
||||
timeout time.Duration,
|
||||
) ([]string, PrewarmDNSMetrics) {
|
||||
if dig == nil {
|
||||
return nil, PrewarmDNSMetrics{}
|
||||
}
|
||||
return dig(host, dnsList, timeout)
|
||||
}
|
||||
|
||||
func safeReadDynSet(read func() ([]string, error)) ([]string, error) {
|
||||
if read == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return read()
|
||||
}
|
||||
|
||||
func safeApplyDynSet(apply func([]string) error, ips []string) error {
|
||||
if apply == nil {
|
||||
return fmt.Errorf("apply dyn set callback is nil")
|
||||
}
|
||||
return apply(ips)
|
||||
}
|
||||
|
||||
func isGoogleLikeSafe(check func(string) bool, domain string) bool {
|
||||
if check == nil {
|
||||
return false
|
||||
}
|
||||
return check(domain)
|
||||
}
|
||||
|
||||
func trimNonEmptyUnique(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, item := range in {
|
||||
v := strings.TrimSpace(item)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
102
selective-vpn-api/app/dnscfg/smartdns.go
Normal file
102
selective-vpn-api/app/dnscfg/smartdns.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package dnscfg
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ResolveDefaultSmartDNSAddr(addrEnvValue string, configPaths []string, fallback string) string {
|
||||
if v := strings.TrimSpace(addrEnvValue); v != "" {
|
||||
if addr := NormalizeSmartDNSAddr(v); addr != "" {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
for _, path := range configPaths {
|
||||
if addr := SmartDNSAddrFromConfig(path); addr != "" {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
func SmartDNSAddrFromConfig(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, ln := range strings.Split(string(data), "\n") {
|
||||
s := strings.TrimSpace(ln)
|
||||
if s == "" || strings.HasPrefix(s, "#") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(s), "bind ") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(s)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
if addr := NormalizeSmartDNSAddr(parts[1]); addr != "" {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func NormalizeDNSUpstream(raw string, defaultPort string) string {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
s = strings.TrimPrefix(s, "udp://")
|
||||
s = strings.TrimPrefix(s, "tcp://")
|
||||
|
||||
if strings.Contains(s, "#") {
|
||||
parts := strings.SplitN(s, "#", 2)
|
||||
host := strings.Trim(strings.TrimSpace(parts[0]), "[]")
|
||||
port := strings.TrimSpace(parts[1])
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if port == "" {
|
||||
port = defaultPort
|
||||
}
|
||||
return host + "#" + port
|
||||
}
|
||||
|
||||
if host, port, err := net.SplitHostPort(s); err == nil {
|
||||
host = strings.Trim(strings.TrimSpace(host), "[]")
|
||||
port = strings.TrimSpace(port)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if port == "" {
|
||||
port = defaultPort
|
||||
}
|
||||
return host + "#" + port
|
||||
}
|
||||
|
||||
if strings.Count(s, ":") == 1 {
|
||||
parts := strings.SplitN(s, ":", 2)
|
||||
host := strings.TrimSpace(parts[0])
|
||||
port := strings.TrimSpace(parts[1])
|
||||
if host != "" && port != "" {
|
||||
return host + "#" + port
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func NormalizeSmartDNSAddr(raw string) string {
|
||||
s := NormalizeDNSUpstream(raw, "6053")
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(s, "#") {
|
||||
return s
|
||||
}
|
||||
return s + "#6053"
|
||||
}
|
||||
47
selective-vpn-api/app/dnscfg/systemd.go
Normal file
47
selective-vpn-api/app/dnscfg/systemd.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package dnscfg
|
||||
|
||||
import "strings"
|
||||
|
||||
type RunCommandFunc func(name string, args ...string) (stdout string, stderr string, exitCode int, err error)
|
||||
|
||||
type CmdResult struct {
|
||||
OK bool
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
Message string
|
||||
}
|
||||
|
||||
func UnitState(run RunCommandFunc, unit string) string {
|
||||
if run == nil {
|
||||
return "unknown"
|
||||
}
|
||||
stdout, _, _, _ := run("systemctl", "is-active", strings.TrimSpace(unit))
|
||||
st := strings.TrimSpace(stdout)
|
||||
if st == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func RunUnitAction(run RunCommandFunc, unit, action string) CmdResult {
|
||||
if run == nil {
|
||||
return CmdResult{
|
||||
OK: false,
|
||||
Message: "run command func is nil",
|
||||
}
|
||||
}
|
||||
stdout, stderr, exitCode, err := run("systemctl", strings.TrimSpace(action), strings.TrimSpace(unit))
|
||||
res := CmdResult{
|
||||
OK: err == nil && exitCode == 0,
|
||||
ExitCode: exitCode,
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
}
|
||||
if err != nil {
|
||||
res.Message = err.Error()
|
||||
} else {
|
||||
res.Message = strings.TrimSpace(unit) + " " + strings.TrimSpace(action) + " done"
|
||||
}
|
||||
return res
|
||||
}
|
||||
69
selective-vpn-api/app/dnscfg/upstreams.go
Normal file
69
selective-vpn-api/app/dnscfg/upstreams.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package dnscfg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizeUpstreams(cfg Upstreams, defaults Upstreams, normalizeUpstream func(raw string, defaultPort string) string) Upstreams {
|
||||
if normalizeUpstream != nil {
|
||||
cfg.Default1 = normalizeUpstream(cfg.Default1, "53")
|
||||
cfg.Default2 = normalizeUpstream(cfg.Default2, "53")
|
||||
cfg.Meta1 = normalizeUpstream(cfg.Meta1, "53")
|
||||
cfg.Meta2 = normalizeUpstream(cfg.Meta2, "53")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.Default1) == "" {
|
||||
cfg.Default1 = defaults.Default1
|
||||
}
|
||||
if strings.TrimSpace(cfg.Default2) == "" {
|
||||
cfg.Default2 = defaults.Default2
|
||||
}
|
||||
if strings.TrimSpace(cfg.Meta1) == "" {
|
||||
cfg.Meta1 = defaults.Meta1
|
||||
}
|
||||
if strings.TrimSpace(cfg.Meta2) == "" {
|
||||
cfg.Meta2 = defaults.Meta2
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func ParseUpstreamsConf(content string, defaults Upstreams, normalizeUpstream func(raw string, defaultPort string) string) Upstreams {
|
||||
cfg := defaults
|
||||
for _, ln := range strings.Split(content, "\n") {
|
||||
s := strings.TrimSpace(ln)
|
||||
if s == "" || strings.HasPrefix(s, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(s)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
vals := parts[1:]
|
||||
switch key {
|
||||
case "default":
|
||||
if len(vals) > 0 {
|
||||
cfg.Default1 = vals[0]
|
||||
}
|
||||
if len(vals) > 1 {
|
||||
cfg.Default2 = vals[1]
|
||||
}
|
||||
case "meta":
|
||||
if len(vals) > 0 {
|
||||
cfg.Meta1 = vals[0]
|
||||
}
|
||||
if len(vals) > 1 {
|
||||
cfg.Meta2 = vals[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return NormalizeUpstreams(cfg, defaults, normalizeUpstream)
|
||||
}
|
||||
|
||||
func RenderUpstreamsConf(cfg Upstreams) string {
|
||||
return fmt.Sprintf(
|
||||
"default %s %s\nmeta %s %s\n",
|
||||
cfg.Default1, cfg.Default2, cfg.Meta1, cfg.Meta2,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user