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.
|
||||
|
||||
@@ -93,6 +93,12 @@ func Run() {
|
||||
defer cancel()
|
||||
|
||||
ensureSeeds()
|
||||
if err := ensureAppMarksNft(); err != nil {
|
||||
log.Printf("traffic appmarks nft init warning: %v", err)
|
||||
}
|
||||
if err := restoreAppMarksFromState(); err != nil {
|
||||
log.Printf("traffic appmarks restore warning: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -144,6 +150,7 @@ func Run() {
|
||||
mux.HandleFunc("/api/v1/routes/fix-policy", handleFixPolicyRoute)
|
||||
mux.HandleFunc("/api/v1/traffic/mode", handleTrafficMode)
|
||||
mux.HandleFunc("/api/v1/traffic/mode/test", handleTrafficModeTest)
|
||||
mux.HandleFunc("/api/v1/traffic/advanced/reset", handleTrafficAdvancedReset)
|
||||
mux.HandleFunc("/api/v1/traffic/interfaces", handleTrafficInterfaces)
|
||||
mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates)
|
||||
// per-app runtime marks (systemd scope / cgroup -> fwmark)
|
||||
@@ -164,6 +171,7 @@ func Run() {
|
||||
mux.HandleFunc("/api/v1/dns-upstreams", handleDNSUpstreams)
|
||||
mux.HandleFunc("/api/v1/dns/status", handleDNSStatus)
|
||||
mux.HandleFunc("/api/v1/dns/mode", handleDNSModeSet)
|
||||
mux.HandleFunc("/api/v1/dns/benchmark", handleDNSBenchmark)
|
||||
mux.HandleFunc("/api/v1/dns/smartdns-service", handleDNSSmartdnsService)
|
||||
|
||||
// SmartDNS service
|
||||
|
||||
@@ -77,6 +77,43 @@ type DNSModeRequest struct {
|
||||
Mode DNSResolverMode `json:"mode"`
|
||||
}
|
||||
|
||||
type DNSBenchmarkUpstream struct {
|
||||
Addr string `json:"addr"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type DNSBenchmarkRequest struct {
|
||||
Upstreams []DNSBenchmarkUpstream `json:"upstreams"`
|
||||
Domains []string `json:"domains"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
Attempts int `json:"attempts"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
}
|
||||
|
||||
type DNSBenchmarkResult struct {
|
||||
Upstream string `json:"upstream"`
|
||||
Attempts int `json:"attempts"`
|
||||
OK int `json:"ok"`
|
||||
Fail int `json:"fail"`
|
||||
NXDomain int `json:"nxdomain"`
|
||||
Timeout int `json:"timeout"`
|
||||
Temporary int `json:"temporary"`
|
||||
Other int `json:"other"`
|
||||
AvgMS int `json:"avg_ms"`
|
||||
P95MS int `json:"p95_ms"`
|
||||
Score float64 `json:"score"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type DNSBenchmarkResponse struct {
|
||||
Results []DNSBenchmarkResult `json:"results"`
|
||||
DomainsUsed []string `json:"domains_used"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
AttemptsPerDomain int `json:"attempts_per_domain"`
|
||||
RecommendedDefault []string `json:"recommended_default"`
|
||||
RecommendedMeta []string `json:"recommended_meta"`
|
||||
}
|
||||
|
||||
type SmartDNSRuntimeStatusResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AppliedEnable bool `json:"applied_enabled"`
|
||||
@@ -105,6 +142,7 @@ type TrafficModeState struct {
|
||||
Mode TrafficMode `json:"mode"`
|
||||
PreferredIface string `json:"preferred_iface,omitempty"`
|
||||
AutoLocalBypass bool `json:"auto_local_bypass"`
|
||||
IngressReplyBypass bool `json:"ingress_reply_bypass"`
|
||||
ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"`
|
||||
ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"`
|
||||
ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"`
|
||||
@@ -118,6 +156,7 @@ type TrafficModeRequest struct {
|
||||
Mode TrafficMode `json:"mode"`
|
||||
PreferredIface *string `json:"preferred_iface,omitempty"`
|
||||
AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"`
|
||||
IngressReplyBypass *bool `json:"ingress_reply_bypass,omitempty"`
|
||||
ForceVPNSubnets *[]string `json:"force_vpn_subnets,omitempty"`
|
||||
ForceVPNUIDs *[]string `json:"force_vpn_uids,omitempty"`
|
||||
ForceVPNCGroups *[]string `json:"force_vpn_cgroups,omitempty"`
|
||||
@@ -131,7 +170,11 @@ type TrafficModeStatusResponse struct {
|
||||
DesiredMode TrafficMode `json:"desired_mode"`
|
||||
AppliedMode TrafficMode `json:"applied_mode"`
|
||||
PreferredIface string `json:"preferred_iface,omitempty"`
|
||||
AdvancedActive bool `json:"advanced_active"`
|
||||
AutoLocalBypass bool `json:"auto_local_bypass"`
|
||||
AutoLocalActive bool `json:"auto_local_active"`
|
||||
IngressReplyBypass bool `json:"ingress_reply_bypass"`
|
||||
IngressReplyActive bool `json:"ingress_reply_active"`
|
||||
BypassCandidates int `json:"bypass_candidates"`
|
||||
ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"`
|
||||
ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"`
|
||||
@@ -146,6 +189,8 @@ type TrafficModeStatusResponse struct {
|
||||
IfaceReason string `json:"iface_reason,omitempty"`
|
||||
RuleMark bool `json:"rule_mark"`
|
||||
RuleFull bool `json:"rule_full"`
|
||||
IngressRulePresent bool `json:"ingress_rule_present"`
|
||||
IngressNftActive bool `json:"ingress_nft_active"`
|
||||
TableDefault bool `json:"table_default"`
|
||||
ProbeOK bool `json:"probe_ok"`
|
||||
ProbeMessage string `json:"probe_message,omitempty"`
|
||||
@@ -208,7 +253,7 @@ type TrafficAppMarksRequest struct {
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
AppKey string `json:"app_key,omitempty"`
|
||||
TimeoutSec int `json:"timeout_sec,omitempty"` // only for add
|
||||
TimeoutSec int `json:"timeout_sec,omitempty"` // only for add; 0 = persistent
|
||||
}
|
||||
|
||||
type TrafficAppMarksResponse struct {
|
||||
@@ -240,7 +285,7 @@ type TrafficAppMarkItemView struct {
|
||||
AppKey string `json:"app_key,omitempty"`
|
||||
AddedAt string `json:"added_at,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
RemainingSec int `json:"remaining_sec,omitempty"`
|
||||
RemainingSec int `json:"remaining_sec,omitempty"` // -1 = persistent
|
||||
}
|
||||
|
||||
type TrafficAppMarksItemsResponse struct {
|
||||
@@ -259,8 +304,8 @@ type TrafficAppProfile struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
AppKey string `json:"app_key,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Target string `json:"target,omitempty"` // vpn|direct
|
||||
TTLSec int `json:"ttl_sec,omitempty"`
|
||||
Target string `json:"target,omitempty"` // vpn|direct
|
||||
TTLSec int `json:"ttl_sec,omitempty"` // 0 = persistent
|
||||
VPNProfile string `json:"vpn_profile,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
@@ -276,8 +321,8 @@ type TrafficAppProfileUpsertRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
AppKey string `json:"app_key,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Target string `json:"target,omitempty"` // vpn|direct
|
||||
TTLSec int `json:"ttl_sec,omitempty"`
|
||||
Target string `json:"target,omitempty"` // vpn|direct
|
||||
TTLSec int `json:"ttl_sec,omitempty"` // 0 = persistent
|
||||
VPNProfile string `json:"vpn_profile,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user