dns ui: compact tab + benchmark dialog and api endpoint

This commit is contained in:
beckline
2026-02-22 14:40:40 +03:00
parent 0b28586f31
commit a7ec4fe801
7 changed files with 1089 additions and 50 deletions

View File

@@ -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.