359 lines
7.5 KiB
Go
359 lines
7.5 KiB
Go
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
|
|
}
|