dns ui: compact tab + benchmark dialog and api endpoint
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -68,6 +69,334 @@ func handleDNSStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, makeDNSStatusResponse(mode))
|
||||
}
|
||||
|
||||
var dnsBenchmarkDefaultDomains = []string{
|
||||
"cloudflare.com",
|
||||
"google.com",
|
||||
"telegram.org",
|
||||
"github.com",
|
||||
"youtube.com",
|
||||
"twitter.com",
|
||||
}
|
||||
|
||||
func handleDNSBenchmark(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req DNSBenchmarkRequest
|
||||
if r.Body != nil {
|
||||
defer r.Body.Close()
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil && err != io.EOF {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
upstreams := normalizeBenchmarkUpstreams(req.Upstreams)
|
||||
if len(upstreams) == 0 {
|
||||
cfg := loadDNSUpstreamsConf()
|
||||
upstreams = normalizeBenchmarkUpstreamStrings([]string{
|
||||
cfg.Default1,
|
||||
cfg.Default2,
|
||||
cfg.Meta1,
|
||||
cfg.Meta2,
|
||||
})
|
||||
}
|
||||
if len(upstreams) == 0 {
|
||||
http.Error(w, "no upstreams", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
domains := normalizeBenchmarkDomains(req.Domains)
|
||||
if len(domains) == 0 {
|
||||
domains = append(domains, dnsBenchmarkDefaultDomains...)
|
||||
}
|
||||
|
||||
timeoutMS := req.TimeoutMS
|
||||
if timeoutMS <= 0 {
|
||||
timeoutMS = 1800
|
||||
}
|
||||
if timeoutMS < 300 {
|
||||
timeoutMS = 300
|
||||
}
|
||||
if timeoutMS > 5000 {
|
||||
timeoutMS = 5000
|
||||
}
|
||||
|
||||
attempts := req.Attempts
|
||||
if attempts <= 0 {
|
||||
attempts = 1
|
||||
}
|
||||
if attempts > 3 {
|
||||
attempts = 3
|
||||
}
|
||||
|
||||
concurrency := req.Concurrency
|
||||
if concurrency <= 0 {
|
||||
concurrency = 6
|
||||
}
|
||||
if concurrency < 1 {
|
||||
concurrency = 1
|
||||
}
|
||||
if concurrency > 32 {
|
||||
concurrency = 32
|
||||
}
|
||||
if concurrency > len(upstreams) {
|
||||
concurrency = len(upstreams)
|
||||
}
|
||||
|
||||
results := make([]DNSBenchmarkResult, 0, len(upstreams))
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, concurrency)
|
||||
timeout := time.Duration(timeoutMS) * time.Millisecond
|
||||
|
||||
for _, upstream := range upstreams {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(upstream string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
result := benchmarkDNSUpstream(upstream, domains, timeout, attempts)
|
||||
mu.Lock()
|
||||
results = append(results, result)
|
||||
mu.Unlock()
|
||||
}(upstream)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
if results[i].Score == results[j].Score {
|
||||
if results[i].AvgMS == results[j].AvgMS {
|
||||
if results[i].OK == results[j].OK {
|
||||
return results[i].Upstream < results[j].Upstream
|
||||
}
|
||||
return results[i].OK > results[j].OK
|
||||
}
|
||||
return results[i].AvgMS < results[j].AvgMS
|
||||
}
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
resp := DNSBenchmarkResponse{
|
||||
Results: results,
|
||||
DomainsUsed: domains,
|
||||
TimeoutMS: timeoutMS,
|
||||
AttemptsPerDomain: attempts,
|
||||
}
|
||||
resp.RecommendedDefault = benchmarkTopN(results, 2, upstreams)
|
||||
resp.RecommendedMeta = benchmarkTopN(results, 2, upstreams)
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func normalizeBenchmarkUpstreams(in []DNSBenchmarkUpstream) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(in))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range in {
|
||||
if !item.Enabled {
|
||||
continue
|
||||
}
|
||||
n := normalizeDNSUpstream(item.Addr, "53")
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeBenchmarkUpstreamStrings(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range in {
|
||||
n := normalizeDNSUpstream(raw, "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) DNSBenchmarkResult {
|
||||
res := DNSBenchmarkResult{Upstream: upstream}
|
||||
durations := make([]int, 0, len(domains)*attempts)
|
||||
|
||||
for _, host := range domains {
|
||||
for i := 0; i < attempts; i++ {
|
||||
start := time.Now()
|
||||
_, err := dnsLookupAOnce(host, upstream, timeout)
|
||||
elapsed := int(time.Since(start).Milliseconds())
|
||||
if elapsed < 1 {
|
||||
elapsed = 1
|
||||
}
|
||||
res.Attempts++
|
||||
if err != nil {
|
||||
res.Fail++
|
||||
switch classifyDNSError(err) {
|
||||
case dnsErrorNXDomain:
|
||||
res.NXDomain++
|
||||
case dnsErrorTimeout:
|
||||
res.Timeout++
|
||||
case dnsErrorTemporary:
|
||||
res.Temporary++
|
||||
default:
|
||||
res.Other++
|
||||
}
|
||||
continue
|
||||
}
|
||||
res.OK++
|
||||
durations = append(durations, elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
timeoutRate := float64(res.Timeout) / float64(total)
|
||||
nxRate := float64(res.NXDomain) / float64(total)
|
||||
avg := float64(res.AvgMS)
|
||||
if avg <= 0 {
|
||||
avg = float64(timeout.Milliseconds())
|
||||
}
|
||||
res.Score = okRate*100.0 - timeoutRate*45.0 - nxRate*12.0 - (avg / 30.0)
|
||||
}
|
||||
|
||||
timeoutRate := 0.0
|
||||
if res.Attempts > 0 {
|
||||
timeoutRate = float64(res.Timeout) / float64(res.Attempts)
|
||||
}
|
||||
switch {
|
||||
case res.OK == 0 || timeoutRate >= 0.15 || res.AvgMS > 400:
|
||||
res.Color = "red"
|
||||
case res.AvgMS < 200 && timeoutRate == 0:
|
||||
res.Color = "green"
|
||||
default:
|
||||
res.Color = "yellow"
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func dnsLookupAOnce(host string, upstream string, timeout time.Duration) ([]string, error) {
|
||||
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(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 []DNSBenchmarkResult, 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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// EN: `handleDNSModeSet` is an HTTP handler for dns mode set.
|
||||
// RU: `handleDNSModeSet` - HTTP-обработчик для dns mode set.
|
||||
|
||||
Reference in New Issue
Block a user