diff --git a/.gitignore b/.gitignore index 4ae4717..72c3fb3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Build artifacts /releases/ +# Logs +*.log + # Generated benchmark reports /dnsbench-*.html /reports/ diff --git a/README.md b/README.md index 37bed00..eaae082 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # dnsbench -dnsbench is a command-line tool that benchmarks and diagnoses recursive DNS servers **as seen from your own network**. It measures cached, uncached and recursive-path latency, packet loss, retries and stability, and it characterizes each server's behavior: DNSSEC validation, NXDOMAIN handling, DNS rebinding protection and optional modern extensions (DNS64, QNAME minimization, HTTPS records). It ranks every enabled category with equal weight by default, compares aggregate latency costs with a paired bootstrap and explains the results in plain language. +dnsbench is a command-line tool that benchmarks and diagnoses recursive DNS servers **as seen from your own network**. It measures cached, uncached and recursive-path latency, packet loss, retries and stability, and it characterizes each server's behavior: DNSSEC validation, NXDOMAIN handling and optional modern extensions (DNS64, QNAME minimization, HTTPS records). It ranks every enabled category with equal weight by default, compares aggregate latency costs with a paired bootstrap and explains the results in plain language. Every number produced by dnsbench is specific to one network, one ISP, one location and one time of day. A server that wins here can lose on another network or at another hour, so treat the ranking as a local snapshot, not a universal truth. @@ -104,7 +104,7 @@ it onto your `PATH`, drop the `./` prefix; on Windows use `.\dnsbench.exe`. # Benchmark and open a shareable HTML report in the browser when the run finishes ./dnsbench run --builtin --open -# Characterize servers (DNSSEC, NXDOMAIN, rebinding) without benchmarking +# Characterize servers (DNSSEC and NXDOMAIN) without benchmarking ./dnsbench probe --builtin --verbose # Enable the uncached category with a zone you control @@ -133,7 +133,33 @@ Keep the network idle during a run: downloads, streaming and calls distort the n Global flags: `--no-color` disables colored output; `-v, --version` prints the version. Run `dnsbench --help` for the full flag list of each command. -The built-in list ships 55 public resolver endpoints (UDP, DoT and DoH) from operators including Google Public DNS, Cloudflare, Quad9, OpenDNS, AdGuard DNS, CleanBrowsing, Control D, DigiCert UltraDNS, Gcore Public DNS, NextDNS and Comodo Secure DNS. The user list is stored as `servers.json` under your OS user config directory (for example `~/Library/Application Support/dnsbench` on macOS, `~/.config/dnsbench` on Linux, `%AppData%\dnsbench` on Windows). +The built-in list ships 57 public resolver endpoints (UDP, DoT, DoH, DoH/3 and DoQ) from operators including Google Public DNS, Cloudflare, Quad9, OpenDNS, AdGuard DNS, CleanBrowsing, Control D, Mullvad DNS and NextDNS. The user list is stored as `servers.json` under your OS user config directory (for example `~/Library/Application Support/dnsbench` on macOS, `~/.config/dnsbench` on Linux, `%AppData%\dnsbench` on Windows). + +## Transports + +dnsbench speaks six transports: plaintext `udp` and `tcp` on port 53, `dot` (DNS over TLS, RFC 7858), `doh` (DNS over HTTPS on HTTP/2, RFC 8484), `doh3` (the same DoH endpoint over HTTP/3) and `doq` (DNS over QUIC, RFC 9250). Select any combination with `--protocols`: + +```bash +# Only encrypted transports +./dnsbench run --protocols dot,doh,doh3,doq + +# Compare just the QUIC-based transports +./dnsbench run --protocols doh3,doq +``` + +This matters if your router intercepts port 53: every plaintext query is then answered by the router rather than by the resolver you meant to measure, and the ranking compares your router against itself. + +Selecting encrypted transports does not change how a hostname-only custom endpoint is bootstrapped: the system resolver still has to resolve that hostname before the encrypted connection exists. Every encrypted entry in the built-in list avoids that lookup by pinning the resolver's IP address in the `address` field and dialing it directly, while keeping the hostname for SNI and certificate validation. Add an `address` to a custom entry when you want the same behavior. + +In a server-list text file the pin is an `@address` suffix, and the two QUIC transports get their own schemes: + +``` +https://dns.google/dns-query@8.8.8.8 # DoH pinned to 8.8.8.8 +h3://dns.quad9.net/dns-query@9.9.9.9 # DoH/3 +quic://dns.adguard-dns.com@94.140.14.14 # DoQ +``` + +Because QUIC performs one handshake instead of a TCP connect followed by a TLS handshake, `doh3` and `doq` report that combined handshake as the TLS phase and leave the connect phase at zero in the phase breakdown. The popular domains used by the cached benchmark are stored in [`internal/model/cached_domains.json`](internal/model/cached_domains.json). Edit the `domains` array to review, add or remove entries, then rebuild `dnsbench`; the JSON is validated and embedded into the executable at compile time. @@ -145,7 +171,7 @@ For a full, shareable report use `--export html`, or `--open`, which writes the ## Measurement pacing -dnsbench spaces its query launches with a single global pacer (`--pace`, default 20ms) and reuses one connected socket per server, so a run does not flood the local Wi-Fi link, NAT router or uplink and then misread the resulting drops as server loss. `--pace` is where the run starts, not a bound: the pace adapts in both directions as the run progresses, shortening on clean networks to as little as a quarter of it and, when timeouts appear across several servers at once (the signature of local congestion rather than of any one server), easing off to as much as 8× it and noting the adjustment in the live view. See [docs/METHODOLOGY.md](docs/METHODOLOGY.md) for details. +During triage and benchmarking, dnsbench spaces query launches globally (`--pace`, default 20ms) and reuses one connected socket per server, so a run does not flood the local Wi-Fi link, NAT router or uplink and then misread the resulting drops as server loss. `--pace` is where the benchmark starts, not a bound: it shortens on clean networks to as little as a quarter of the configured value and, when timeouts appear across several servers at once (the signature of local congestion rather than of any one server), eases off to as much as 8× it and notes the adjustment in the live view. The preceding characterization phase has no launch pacer; its small qualitative workload reuses one persistent session per server and is bounded by `--concurrency`. See [docs/METHODOLOGY.md](docs/METHODOLOGY.md) for details. While a run is in progress dnsbench also asks the operating system not to enter idle sleep, so leaving the machine unattended (or letting the display turn off) does not suspend the process mid-run and poison the timings. It uses the native mechanism of each platform — `caffeinate` on macOS, a systemd-logind idle inhibitor lock held over D-Bus on Linux, and `SetThreadExecutionState` on Windows. @@ -217,7 +243,7 @@ release archive triggers. ## Documentation - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — package map, data model, full benchmark flow, concurrency strategy, error taxonomy -- [docs/METHODOLOGY.md](docs/METHODOLOGY.md) — what is measured and why: query categories, warmup, triage, DNSSEC, NXDOMAIN, rebinding, statistical significance +- [docs/METHODOLOGY.md](docs/METHODOLOGY.md) — what is measured and why: query categories, warmup, triage, DNSSEC, NXDOMAIN, statistical significance - [docs/FORMULAS.md](docs/FORMULAS.md) — every formula with worked numeric examples, plus the complete ranking weight tables ## License diff --git a/cmd/dnsbench/discover.go b/cmd/dnsbench/discover.go index 2484bc4..e7d6deb 100644 --- a/cmd/dnsbench/discover.go +++ b/cmd/dnsbench/discover.go @@ -17,18 +17,18 @@ import ( func newDiscoverCmd() *cobra.Command { return &cobra.Command{ Use: "discover", - Short: "Show the DNS servers configured on this system", + Short: "Show the DNS resolvers configured on this system", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() servers, _, warnings, err := sysdns.Discover(ctx) if err != nil { - return fmt.Errorf("could not discover system DNS servers: %w", err) + return fmt.Errorf("could not discover system DNS resolvers: %w", err) } out := cmd.OutOrStdout() if len(servers) == 0 { - fmt.Fprintln(out, "No system DNS servers were found.") + fmt.Fprintln(out, "No system DNS resolvers were found.") } else { fmt.Fprint(out, renderDiscoverTable(servers)) printForwarderNotes(out, servers) diff --git a/cmd/dnsbench/intro.go b/cmd/dnsbench/intro.go index d1efbd8..e004b77 100644 --- a/cmd/dnsbench/intro.go +++ b/cmd/dnsbench/intro.go @@ -34,40 +34,38 @@ func introSections() []introSection { { "What is DNS?", `DNS (Domain Name System) is the phone book of the internet. Every time you -open a website, your device asks a DNS server to translate a name such as -example.com into an IP address it can connect to. A recursive DNS server does +open a website, your device asks a DNS resolver to translate a name such as +example.com into an IP address it can connect to. A recursive DNS resolver does that lookup on your behalf, walking the chain of authoritative servers and caching the answers it finds. Almost every online action starts with one or more DNS lookups, so a slow or unreliable resolver adds delay to everything.`, }, { "What does this test measure?", - `dnsbench measures several independent things about each DNS server: + `dnsbench measures several independent things about each DNS resolver: - - Cached latency: how fast the server answers for popular names it almost + - Cached latency: how fast the resolver answers for popular names it almost certainly already has in its cache. This is the answer speed you feel most often in daily browsing. - - Uncached latency: how fast the server resolves names guaranteed not to be + - Uncached latency: how fast the resolver resolves names guaranteed not to be in its cache (this requires a zone you control, so each query is a real cache miss). - - Recursive-path latency: how fast the server walks the TLD infrastructure + - Recursive-path latency: how fast the resolver walks the TLD infrastructure for random names, exercising its full recursion path. - Loss and stability: how many queries go unanswered and how much the latency jitters between consecutive queries. - - DNSSEC validation: whether the server cryptographically validates signed + - DNSSEC validation: whether the resolver cryptographically validates signed answers and rejects forged ones. - - NXDOMAIN handling: whether the server honestly reports that a name does + - NXDOMAIN handling: whether the resolver honestly reports that a name does not exist, or intercepts the error and redirects you to ads or a search - page. - - Rebinding protection: whether the server blocks public names that resolve - to private addresses, a trick used to attack devices inside your network.`, + page.`, }, { "Why should the network be idle during the test?", `DNS queries are tiny, so any competing traffic distorts the measurement. A download, a video stream or a video call fills the link and its buffers, inflating latency and causing spurious loss that gets blamed on the DNS -server. For trustworthy numbers, run the test while nobody is using the +resolver. For trustworthy numbers, run the test while nobody is using the network for anything else.`, }, { diff --git a/cmd/dnsbench/probe.go b/cmd/dnsbench/probe.go index 4e9d769..42f9d2b 100644 --- a/cmd/dnsbench/probe.go +++ b/cmd/dnsbench/probe.go @@ -32,13 +32,13 @@ type selectionFlags struct { } func registerSelectionFlags(cmd *cobra.Command, sel *selectionFlags) { - cmd.Flags().BoolVar(&sel.system, "system", false, "include the system-configured DNS servers") - cmd.Flags().BoolVar(&sel.builtin, "builtin", false, "include the built-in public DNS servers") - cmd.Flags().BoolVar(&sel.user, "user", false, "include the user server list") - cmd.Flags().StringSliceVar(&sel.only, "only", nil, "restrict to servers matching these IDs or addresses") - cmd.Flags().StringVar(&sel.serversFile, "servers-file", "", "extra server list file to include (.json, .csv or .txt)") - cmd.Flags().BoolVar(&sel.noIPv6, "no-ipv6", false, "exclude servers with IPv6 addresses") - cmd.Flags().StringSliceVar(&sel.protocols, "protocols", nil, "only include these protocols (udp, tcp, dot, doh)") + cmd.Flags().BoolVar(&sel.system, "system", false, "include the system-configured DNS resolvers") + cmd.Flags().BoolVar(&sel.builtin, "builtin", false, "include the built-in public DNS resolvers") + cmd.Flags().BoolVar(&sel.user, "user", false, "include the user resolver list") + cmd.Flags().StringSliceVar(&sel.only, "only", nil, "restrict to resolvers matching these IDs or addresses") + cmd.Flags().StringVar(&sel.serversFile, "servers-file", "", "extra resolver list file to include (.json, .csv or .txt)") + cmd.Flags().BoolVar(&sel.noIPv6, "no-ipv6", false, "exclude resolvers with IPv6 addresses") + cmd.Flags().StringSliceVar(&sel.protocols, "protocols", nil, "only include these protocols (udp, tcp, dot, doh, doh3, doq)") } type selectedServers struct { @@ -70,7 +70,7 @@ func selectServers(ctx context.Context, sel selectionFlags) (selectedServers, er if includeUser { user, err := serverlist.LoadUser("") if err != nil { - return out, fmt.Errorf("could not load the user server list: %w", err) + return out, fmt.Errorf("could not load the user resolver list: %w", err) } lists = append(lists, user) } @@ -133,7 +133,7 @@ func loadServersFile(path string) ([]model.Server, error) { servers[i].Source = model.SourceUser } if err := serverlist.ValidateAndPrepare(&servers[i]); err != nil { - return nil, fmt.Errorf("invalid server %q in %s: %w", servers[i].DisplayName(), path, err) + return nil, fmt.Errorf("invalid resolver %q in %s: %w", servers[i].DisplayName(), path, err) } } return servers, nil @@ -166,12 +166,13 @@ func newProbeCmd() *cobra.Command { var timeout time.Duration var concurrency int var jsonPath string + base := probe.DefaultConfig() cmd := &cobra.Command{ Use: "probe", - Short: "Characterize DNS servers without benchmarking them", - Long: `Characterizes each selected DNS server: reachability, EDNS0, DNSSEC -validation, NXDOMAIN handling and DNS rebinding protection. No latency -benchmark is run; use "dnsbench run" for the full measurement.`, + Short: "Characterize DNS resolvers without benchmarking them", + Long: `Characterizes each selected DNS resolver: reachability, EDNS0, DNSSEC +validation and NXDOMAIN handling. No latency benchmark is run; use +"dnsbench run" for the full measurement.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) @@ -184,7 +185,7 @@ benchmark is run; use "dnsbench run" for the full measurement.`, return err } if len(selection.servers) == 0 { - return fmt.Errorf("no servers matched the given selection") + return fmt.Errorf("no resolvers matched the given selection") } cfg := probe.DefaultConfig() cfg.Extended = extended @@ -195,7 +196,7 @@ benchmark is run; use "dnsbench run" for the full measurement.`, cfg.Concurrency = concurrency } out := cmd.OutOrStdout() - fmt.Fprintf(out, "Probing %s...\n\n", countNoun(len(selection.servers), "server")) + fmt.Fprintf(out, "Probing %s...\n\n", countNoun(len(selection.servers), "resolver")) results := probe.Run(ctx, selection.servers, cfg) states := make(map[string]model.ServerState, len(results)) for id, r := range results { @@ -220,10 +221,10 @@ benchmark is run; use "dnsbench run" for the full measurement.`, } registerSelectionFlags(cmd, &sel) cmd.Flags().BoolVar(&extended, "extended", false, "run extended checks (DNS64, QNAME minimization, HTTPS records)") - cmd.Flags().DurationVar(&timeout, "timeout", 3*time.Second, "timeout per query") - cmd.Flags().IntVar(&concurrency, "concurrency", 8, "how many servers to probe in parallel") + cmd.Flags().DurationVar(&timeout, "timeout", base.Timeout, "timeout per query") + cmd.Flags().IntVar(&concurrency, "concurrency", base.Concurrency, "how many resolvers to probe in parallel") cmd.Flags().StringVar(&jsonPath, "json", "", "write raw probe results to this JSON file") - cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "show per-check NXDOMAIN and rebinding details") + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "show per-check NXDOMAIN details") return cmd } @@ -251,11 +252,6 @@ func printProbeDetails(out io.Writer, servers []model.Server, results map[string fmt.Fprintln(out, line) } } - fmt.Fprintf(out, " rebinding protection: v4 %s, v6 %s, overall %s\n", - r.Rebind.V4.Label(), r.Rebind.V6.Label(), r.Rebind.Overall.Label()) - for _, d := range r.Rebind.Details { - fmt.Fprintln(out, " "+d) - } if r.Extended != nil { fmt.Fprintf(out, " extended: DNS64 %s, QNAME minimization %s, HTTPS record %s\n", r.Extended.DNS64.Label(), r.Extended.QNAMEMinimization.Label(), r.Extended.HTTPSRecord.Label()) diff --git a/cmd/dnsbench/root.go b/cmd/dnsbench/root.go index 1e19cf3..fcc259a 100644 --- a/cmd/dnsbench/root.go +++ b/cmd/dnsbench/root.go @@ -2,8 +2,10 @@ package main import ( "fmt" + "io" "os" + "github.com/charmbracelet/x/term" "github.com/spf13/cobra" "dnsbench/internal/model" @@ -20,14 +22,14 @@ func usageErrorf(format string, args ...any) error { return usageErr{fmt.Errorf(format, args...)} } -const rootLong = `dnsbench benchmarks and diagnoses recursive DNS servers as seen from YOUR network. +const rootLong = `dnsbench benchmarks and diagnoses recursive DNS resolvers as seen from YOUR network. It measures cached, uncached and recursive-path latency, packet loss, stability, -DNSSEC validation, NXDOMAIN handling and DNS rebinding protection, then ranks the -servers and explains the results in plain language. +DNSSEC validation and NXDOMAIN handling, then ranks the resolvers and explains the +results in plain language. Every number produced by this tool is specific to this network, this ISP, this -location and this time of day. A server that wins here can lose on another +location and this time of day. A resolver that wins here can lose on another network or at another hour, so treat the ranking as a local snapshot, not a universal truth. @@ -38,7 +40,7 @@ func newRootCmd() *cobra.Command { var noColor bool cmd := &cobra.Command{ Use: "dnsbench", - Short: "Benchmark and diagnose recursive DNS servers from your own network", + Short: "Benchmark and diagnose recursive DNS resolvers from your own network", Long: rootLong, Version: model.AppVersion, SilenceUsage: true, @@ -62,4 +64,16 @@ func stdoutIsTTY() bool { return err == nil && info.Mode()&os.ModeCharDevice != 0 } +func terminalWidth(out io.Writer) int { + withFD, ok := out.(interface{ Fd() uintptr }) + if !ok { + return 0 + } + width, _, err := term.GetSize(withFD.Fd()) + if err != nil { + return 0 + } + return width +} + const localForwarderSentence = "This system DNS endpoint was detected successfully. It is a local/private forwarder, so only the upstream behind this endpoint is unknown; other detected DNS endpoints are listed separately." diff --git a/cmd/dnsbench/run.go b/cmd/dnsbench/run.go index 3323db0..e4c911f 100644 --- a/cmd/dnsbench/run.go +++ b/cmd/dnsbench/run.go @@ -67,8 +67,8 @@ func newRunCmd() *cobra.Command { cmd := &cobra.Command{ Use: "run", Short: "Run the full DNS benchmark and print a ranked report", - Long: `Runs the full flow: discovers the system DNS servers, characterizes every -selected server, benchmarks them across query categories, ranks the results + Long: `Runs the full flow: discovers the system DNS resolvers, characterizes every +selected resolver, benchmarks them across query categories, ranks the results and explains them. Results are specific to this network, ISP, location and time of day.`, Args: cobra.NoArgs, @@ -91,14 +91,14 @@ time of day.`, cmd.Flags().IntVar(&f.retries, "retries", base.Retries, "retries per failed query") cmd.Flags().DurationVar(&f.retryInterval, "retry-interval", base.RetryInterval, "wait between retries") cmd.Flags().IntVar(&f.concurrency, "concurrency", base.Concurrency, "maximum queries in flight at once") - cmd.Flags().DurationVar(&f.pace, "pace", base.PaceInterval, "starting spacing between any two query launches, across all servers; adapts between a quarter of it and 8x it (0 disables pacing)") - cmd.Flags().DurationVar(&f.gap, "gap", base.PerServerGap, "gap between consecutive queries to the same server") + cmd.Flags().DurationVar(&f.pace, "pace", base.PaceInterval, "starting spacing between any two query launches, across all resolvers; adapts between a quarter of it and 8x it (0 disables pacing)") + cmd.Flags().DurationVar(&f.gap, "gap", base.PerServerGap, "gap between consecutive queries to the same resolver") cmd.Flags().StringVar(&f.session, "session", string(base.Session), "connection reuse: cold or persistent") cmd.Flags().Int64Var(&f.seed, "seed", 0, "random seed (0 picks a random seed)") - cmd.Flags().BoolVar(&f.noTriage, "no-triage", false, "skip the triage phase and benchmark every server") - cmd.Flags().DurationVar(&f.triageThreshold, "triage-threshold", base.TriageThreshold, "best RTT above this benches a server during triage") - cmd.Flags().IntVar(&f.triageAttempts, "triage-attempts", base.TriageAttempts, "probes per server during triage") - cmd.Flags().BoolVar(&f.forceAll, "force-all", false, "keep slow servers active instead of benching them") + cmd.Flags().BoolVar(&f.noTriage, "no-triage", false, "skip the triage phase and benchmark every resolver") + cmd.Flags().DurationVar(&f.triageThreshold, "triage-threshold", base.TriageThreshold, "best RTT above this sidelines a built-in resolver during triage") + cmd.Flags().IntVar(&f.triageAttempts, "triage-attempts", base.TriageAttempts, "probes per resolver during triage") + cmd.Flags().BoolVar(&f.forceAll, "force-all", false, "keep slow built-in resolvers active instead of benching them") cmd.Flags().StringVar(&f.ranking, "ranking", string(model.RankLatency), "ranking mode: latency, browsing or reliability") cmd.Flags().StringVar(&f.sortKey, "sort", "median", "sort for the detailed table: cost, mean, median, p95, loss or name") cmd.Flags().StringVar(&f.category, "category", "", "category shown in the detailed table (default: first enabled)") @@ -146,7 +146,7 @@ func executeRun(cmd *cobra.Command, f *runFlags) error { return err } if len(selection.servers) == 0 { - return fmt.Errorf("no servers matched the given selection") + return fmt.Errorf("no resolvers matched the given selection") } if !f.quiet { printForwarderNotes(out, systemServers(selection)) @@ -168,7 +168,7 @@ func executeRun(cmd *cobra.Command, f *runFlags) error { var spinner *ui.Spinner if !f.quiet { fmt.Fprintln(out) - label := fmt.Sprintf("Characterizing %s — DNSSEC, NXDOMAIN, rebinding", countNoun(len(selection.servers), "server")) + label := fmt.Sprintf("Characterizing %s — DNSSEC, NXDOMAIN", countNoun(len(selection.servers), "resolver")) spinner = ui.NewSpinner(out, stdoutIsTTY(), label, len(selection.servers)) spinner.Start() } @@ -179,7 +179,7 @@ func executeRun(cmd *cobra.Command, f *runFlags) error { } }) if spinner != nil { - final := ui.Green("✓") + fmt.Sprintf(" Characterized %s in %s", countNoun(len(selection.servers), "server"), time.Since(probeStart).Round(100*time.Millisecond)) + final := ui.Green("✓") + fmt.Sprintf(" Characterized %s in %s", countNoun(len(selection.servers), "resolver"), time.Since(probeStart).Round(100*time.Millisecond)) if ctx.Err() != nil { final = ui.Yellow("⚠") + " Characterization interrupted" } @@ -526,7 +526,7 @@ func printFinalReport(out io.Writer, res *model.RunResult, f *runFlags, rankingM } fmt.Fprintln(out) fmt.Fprintf(out, "%s\n", ui.Bold("Detailed metrics — "+cat.Label()+" category")) - fmt.Fprint(out, ui.RenderMetricsTable(res, cat, f.sortKey)) + fmt.Fprint(out, ui.RenderMetricsTable(res, cat, f.sortKey, terminalWidth(out))) } fmt.Fprintln(out) fmt.Fprint(out, ui.RenderReportFooter(exportPaths, f.open)) diff --git a/cmd/dnsbench/run_test.go b/cmd/dnsbench/run_test.go index 81e23b1..ce12ce1 100644 --- a/cmd/dnsbench/run_test.go +++ b/cmd/dnsbench/run_test.go @@ -19,6 +19,16 @@ func TestRunCommandModernDefaults(t *testing.T) { if got := cmd.Flags().Lookup("ranking").DefValue; got != string(model.RankLatency) { t.Fatalf("--ranking default = %q, want %q", got, model.RankLatency) } + if got := cmd.Flags().Lookup("triage-threshold").DefValue; got != "200ms" { + t.Fatalf("--triage-threshold default = %q, want 200ms", got) + } +} + +func TestProbeCommandHasNoPacingFlag(t *testing.T) { + cmd := newProbeCmd() + if got := cmd.Flags().Lookup("pace"); got != nil { + t.Fatalf("probe unexpectedly exposes --pace: %+v", got) + } } func TestMatchSystemServerIDsByEndpoint(t *testing.T) { diff --git a/cmd/dnsbench/selection_test.go b/cmd/dnsbench/selection_test.go new file mode 100644 index 0000000..707a5b9 --- /dev/null +++ b/cmd/dnsbench/selection_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "slices" + "testing" + + "dnsbench/internal/model" +) + +func TestParseProtocolsAcceptsEverySupportedProtocol(t *testing.T) { + got, err := parseProtocols([]string{"udp", "tcp", "dot", "doh", "doh3", "doq"}) + if err != nil { + t.Fatalf("parseProtocols: %v", err) + } + want := model.AllProtocols() + if !slices.Equal(got, want) { + t.Errorf("protocols = %v, want %v", got, want) + } +} + +func TestParseProtocolsPreservesSelectedSubset(t *testing.T) { + got, err := parseProtocols([]string{"doq", "doh3"}) + if err != nil { + t.Fatalf("parseProtocols: %v", err) + } + want := []model.Protocol{model.ProtoDoQ, model.ProtoDoH3} + if !slices.Equal(got, want) { + t.Errorf("protocols = %v, want %v", got, want) + } +} + +func TestParseProtocolsRejectsUnknownProtocol(t *testing.T) { + if _, err := parseProtocols([]string{"doh4"}); err == nil { + t.Error("expected an error for an unknown protocol") + } +} + +func TestSelectionCommandsExposeProtocolsWithoutEncryptedOnly(t *testing.T) { + run := newRunCmd() + if run.Flags().Lookup("protocols") == nil { + t.Error("run command is missing --protocols") + } + if run.Flags().Lookup("encrypted-only") != nil { + t.Error("run command unexpectedly exposes --encrypted-only") + } + + probe := newProbeCmd() + if probe.Flags().Lookup("protocols") == nil { + t.Error("probe command is missing --protocols") + } + if probe.Flags().Lookup("encrypted-only") != nil { + t.Error("probe command unexpectedly exposes --encrypted-only") + } +} diff --git a/cmd/dnsbench/servers.go b/cmd/dnsbench/servers.go index cd9301b..2197a2a 100644 --- a/cmd/dnsbench/servers.go +++ b/cmd/dnsbench/servers.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "slices" "strings" "github.com/spf13/cobra" @@ -18,7 +19,7 @@ import ( func newServersCmd() *cobra.Command { cmd := &cobra.Command{ Use: "servers", - Short: "Manage the DNS server lists (system, built-in and user)", + Short: "Manage the DNS resolver lists (system, built-in and user)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() @@ -32,12 +33,10 @@ func parseProtocols(values []string) ([]model.Protocol, error) { var out []model.Protocol for _, v := range values { p := model.Protocol(strings.ToLower(strings.TrimSpace(v))) - switch p { - case model.ProtoUDP, model.ProtoTCP, model.ProtoDoT, model.ProtoDoH: - out = append(out, p) - default: - return nil, usageErrorf("invalid protocol %q (accepted: udp, tcp, dot, doh)", v) + if !slices.Contains(model.AllProtocols(), p) { + return nil, usageErrorf("invalid protocol %q (accepted: udp, tcp, dot, doh, doh3, doq)", v) } + out = append(out, p) } return out, nil } @@ -66,7 +65,7 @@ func mergedServerList(ctx context.Context, warn func(string)) ([]model.Server, e } user, err := serverlist.LoadUser("") if err != nil { - return nil, fmt.Errorf("could not load the user server list: %w", err) + return nil, fmt.Errorf("could not load the user resolver list: %w", err) } return serverlist.Merge(system, user, serverlist.Builtin()), nil } @@ -75,7 +74,7 @@ func newServersListCmd() *cobra.Command { var protocols, operators, sources []string cmd := &cobra.Command{ Use: "list", - Short: "List all known DNS servers (system + user + built-in)", + Short: "List all known DNS resolvers (system + user + built-in)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) @@ -100,17 +99,17 @@ func newServersListCmd() *cobra.Command { }) out := cmd.OutOrStdout() if len(servers) == 0 { - fmt.Fprintln(out, "No servers matched the given filters.") + fmt.Fprintln(out, "No resolvers matched the given filters.") return nil } fmt.Fprint(out, ui.RenderServersTable(servers, nil, nil)) - fmt.Fprintf(out, "\n%d servers listed.\n", len(servers)) + fmt.Fprintf(out, "\n%s listed.\n", countNoun(len(servers), "resolver")) return nil }, } - cmd.Flags().StringSliceVar(&protocols, "protocol", nil, "only show servers using these protocols (udp, tcp, dot, doh)") - cmd.Flags().StringSliceVar(&operators, "operator", nil, "only show servers run by these operators") - cmd.Flags().StringSliceVar(&sources, "source", nil, "only show servers from these sources (system, builtin, user)") + cmd.Flags().StringSliceVar(&protocols, "protocol", nil, "only show resolvers using these protocols (udp, tcp, dot, doh, doh3, doq)") + cmd.Flags().StringSliceVar(&operators, "operator", nil, "only show resolvers run by these operators") + cmd.Flags().StringSliceVar(&sources, "source", nil, "only show resolvers from these sources (system, builtin, user)") return cmd } @@ -119,7 +118,7 @@ func newServersAddCmd() *cobra.Command { var port int cmd := &cobra.Command{ Use: "add", - Short: "Add a DNS server to the user list", + Short: "Add a DNS resolver to the user list", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { server := model.Server{ @@ -138,16 +137,16 @@ func newServersAddCmd() *cobra.Command { } existing, err := serverlist.LoadUser("") if err != nil { - return fmt.Errorf("could not load the user server list: %w", err) + return fmt.Errorf("could not load the user resolver list: %w", err) } for _, s := range existing { if s.Key() == server.Key() { - return fmt.Errorf("a server with the same endpoint already exists in the user list: %s", s.DisplayName()) + return fmt.Errorf("a resolver with the same endpoint already exists in the user list: %s", s.DisplayName()) } } existing = append(existing, server) if err := serverlist.SaveUser("", existing); err != nil { - return fmt.Errorf("could not save the user server list: %w", err) + return fmt.Errorf("could not save the user resolver list: %w", err) } dir, err := serverlist.UserDir("") if err != nil { @@ -161,13 +160,13 @@ func newServersAddCmd() *cobra.Command { return nil }, } - cmd.Flags().StringVar(&name, "name", "", "display name for the server") - cmd.Flags().StringVar(&operator, "operator", "", "organization operating the server") - cmd.Flags().StringVar(&protocol, "protocol", "udp", "protocol: udp, tcp, dot or doh") - cmd.Flags().StringVar(&address, "address", "", "IP address (required for udp, tcp and dot)") + cmd.Flags().StringVar(&name, "name", "", "display name for the resolver") + cmd.Flags().StringVar(&operator, "operator", "", "organization operating the resolver") + cmd.Flags().StringVar(&protocol, "protocol", "udp", "protocol: udp, tcp, dot, doh, doh3 or doq") + cmd.Flags().StringVar(&address, "address", "", "IP address (required for udp, tcp, dot and doq; pins the bootstrap IP for doh and doh3)") cmd.Flags().IntVar(&port, "port", 0, "port (0 uses the protocol default)") - cmd.Flags().StringVar(&tlsHostname, "tls-hostname", "", "TLS server name for DoT") - cmd.Flags().StringVar(&dohURL, "doh-url", "", "https URL for DoH") + cmd.Flags().StringVar(&tlsHostname, "tls-hostname", "", "TLS hostname for DoT and DoQ") + cmd.Flags().StringVar(&dohURL, "doh-url", "", "https URL for DoH and DoH/3") cmd.Flags().StringVar(¬es, "notes", "", "free-form notes") return cmd } @@ -218,7 +217,7 @@ func newServersImportCmd() *cobra.Command { var file, format string cmd := &cobra.Command{ Use: "import", - Short: "Import DNS servers from a file into the user list", + Short: "Import DNS resolvers from a file into the user list", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { f, err := resolveFormat(file, format) @@ -235,7 +234,7 @@ func newServersImportCmd() *cobra.Command { } existing, err := serverlist.LoadUser("") if err != nil { - return fmt.Errorf("could not load the user server list: %w", err) + return fmt.Errorf("could not load the user resolver list: %w", err) } seen := make(map[string]bool, len(existing)) for _, s := range existing { @@ -246,7 +245,7 @@ func newServersImportCmd() *cobra.Command { s := imported[i] s.Source = model.SourceUser if err := serverlist.ValidateAndPrepare(&s); err != nil { - return fmt.Errorf("invalid server %q in %s: %w", s.DisplayName(), file, err) + return fmt.Errorf("invalid resolver %q in %s: %w", s.DisplayName(), file, err) } if seen[s.Key()] { skipped++ @@ -258,10 +257,10 @@ func newServersImportCmd() *cobra.Command { } if added > 0 { if err := serverlist.SaveUser("", existing); err != nil { - return fmt.Errorf("could not save the user server list: %w", err) + return fmt.Errorf("could not save the user resolver list: %w", err) } } - fmt.Fprintf(cmd.OutOrStdout(), "Imported %d servers into the user list (%d duplicates skipped).\n", added, skipped) + fmt.Fprintf(cmd.OutOrStdout(), "Imported %s into the user list (%d duplicates skipped).\n", countNoun(added, "resolver"), skipped) return nil }, } @@ -275,7 +274,7 @@ func newServersExportCmd() *cobra.Command { var file, format, source string cmd := &cobra.Command{ Use: "export", - Short: "Export the known DNS servers to a file", + Short: "Export the known DNS resolvers to a file", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) @@ -300,16 +299,16 @@ func newServersExportCmd() *cobra.Command { servers = serverlist.Filter(servers, serverlist.FilterOptions{Sources: srcs}) } if len(servers) == 0 { - return fmt.Errorf("no servers to export") + return fmt.Errorf("no resolvers to export") } data, err := encodeServers(servers, f) if err != nil { - return fmt.Errorf("could not encode the server list: %w", err) + return fmt.Errorf("could not encode the resolver list: %w", err) } if err := os.WriteFile(file, data, 0o644); err != nil { return fmt.Errorf("could not write %s: %w", file, err) } - fmt.Fprintf(cmd.OutOrStdout(), "Exported %d servers to %s.\n", len(servers), file) + fmt.Fprintf(cmd.OutOrStdout(), "Exported %s to %s.\n", countNoun(len(servers), "resolver"), file) return nil }, } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fc3ed7d..e9c43c3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,10 +6,10 @@ | --- | --- | | `cmd/dnsbench` | CLI wiring (cobra): flag parsing and validation, server selection, orchestration of probe → bench → stats → rank → report, signal handling | | `internal/model` | Core data types (`Server`, `Sample`, `QueryResult`, `Distribution`, `ProbeResult`, `Score`, `RunResult`, …), enums, defaults (`DefaultBenchConfig`), address scope classification | -| `internal/transport` | One `Querier` per protocol: UDP/53 (with TCP retry on truncation), TCP/53, DoT, DoH; message building/parsing, phase timing, error classification | +| `internal/transport` | One `Querier` per protocol: UDP/53 (with TCP retry on truncation), TCP/53, DoT, DoH, DoH/3 and DoQ; message building/parsing, phase timing, error classification | | `internal/sysdns` | Read-only discovery of system DNS servers per OS (`scutil --dns`, `/etc/resolv.conf` + `resolvectl status`, `netsh`), parsers, forwarder warnings | | `internal/serverlist` | Embedded built-in server list, user list persistence, JSON/CSV/TXT codecs, merge, filter, validation and ID generation | -| `internal/probe` | Characterization checks: reachability, EDNS0, DNSSEC, NXDOMAIN handling, rebinding protection, reverse PTR, optional extensions | +| `internal/probe` | Characterization checks: reachability, EDNS0, DNSSEC, NXDOMAIN handling, reverse PTR, optional extensions | | `internal/bench` | Benchmark engine: triage, warmup and measured rounds, seeded scheduling, sessions, pause gate, connectivity watchdog | | `internal/stats` | Semantic DNS-validity filtering, per-category `Distribution` computation, phase averages and statistical primitives | | `internal/rank` | Latency-cost computation (weighted base + penalties), rank assignment, presets and paired bootstrap comparisons | @@ -39,10 +39,10 @@ Arrows point from importer to imported. Every internal package except `power` de ## Data model -- **`Server`** — one benchmark target: identity (`ID`, `Name`, `Operator`), endpoint (`Address`, `Port`, `TLSHostname`, `DoHURL`), `Protocol` (`udp`, `tcp`, `dot`, `doh`), `Source` (`system`, `builtin`, `user`), and system metadata (`Interface`, `SystemRole`). `Key()` (protocol|address|port|DoH URL) de-duplicates servers across lists. +- **`Server`** — one benchmark target: identity (`ID`, `Name`, `Operator`), endpoint (`Address`, `Port`, `TLSHostname`, `DoHURL`), `Protocol` (`udp`, `tcp`, `dot`, `doh`, `doh3`, `doq`), `Source` (`system`, `builtin`, `user`), and system metadata (`Interface`, `SystemRole`). `Key()` (protocol|address|port|DoH URL) de-duplicates servers across lists. - **`QueryResult`** — the outcome of a single DNS exchange: `RTT`, per-phase timings (`Connect`, `TLSHandshake`, `HTTPSetup`, `Query`), `Rcode`, flags (`Truncated`, `AD`, `UsedTCP`, `Reused`), EDNS advertised size, wire size, parsed `Answers`, and `Err` (`nil` means answered). - **`Sample`** — one benchmark measurement: which server, `Category` (`cached`, `uncached`, `tld`), `Round`, `Warmup` flag, query name/type, attempt/failed-attempt/timeout counters, end-to-end `Elapsed` time, timestamp, and the final `QueryResult`. -- **`ProbeResult`** — the characterization of one server: reachability, A/AAAA support, EDNS0, `DNSSECInfo` (the DO/CD matrix verdicts and the derived `Validating`), the `NXCheck` list and derived `NXInterception`, `RebindInfo` (v4/v6/overall), reverse PTR name, optional `ExtendedInfo` (DNS64, QNAME minimization, HTTPS record) and errors. Verdicts are `yes` / `no` / `partial` / `unknown`. +- **`ProbeResult`** — the characterization of one server: reachability, A/AAAA support, EDNS0, `DNSSECInfo` (the DO/CD matrix verdicts and the derived `Validating`), the `NXCheck` list and derived `NXInterception`, reverse PTR name, optional `ExtendedInfo` (DNS64, QNAME minimization, HTTPS record) and errors. Verdicts are `yes` / `no` / `partial` / `unknown`. - **`Distribution`** — per server × category statistics over non-warmup samples: transport-answer and semantically-valid counts, attempt/retry counts, timeouts, errors, SERVFAILs, invalid/truncated responses, percentages, min/max/mean/median, sample variance and stddev, P50/P90/P95/P99, 95% CI bounds, jitter, and optional chronological valid `SamplesMs`. - **`Score`** — the internal representation of one server's user-facing latency cost in one mode: renormalized weighted `BaseMs`, a named `Penalties` map (ms each), `TotalMs = BaseMs + Σ penalties`, and the assigned `Rank` (ties within 0.01 ms share a rank). - **`RunResult`** — the complete run artifact serialized by the exporters: `RunInfo` (version, OS/arch, start time, duration, interfaces), `BenchConfig`, the selected ranking, weights for all three modes, selected servers, discovered system servers, matching selected system IDs, probes, triage results, stats, scores per mode, aggregate comparisons and optional raw samples. @@ -53,8 +53,8 @@ Supporting types: `TriageResult` (attempts, valid responses, best RTT, resulting 1. **Discovery and selection** — `sysdns.Discover` always identifies the system resolvers. Source flags only control which system, user and built-in endpoints (plus `--servers-file`) enter the benchmark; selected endpoints are merged by key and filtered (`--protocols`, `--no-ipv6`, `--only`). If no source flag is given, all three sources are included. 2. **Registration** — selected servers carry stable IDs. Discovered system resolvers are retained separately, and any selected endpoint with the same transport/address/port key is marked as current DNS even if its selected record came from the built-in or user list. -3. **Characterization (probe)** — unless `--skip-probe`, every server is probed concurrently: reachability, baseline (A/AAAA, EDNS0), DNSSEC matrix, NXDOMAIN checks, rebinding checks, reverse PTR, and extended checks with `--extended`. -4. **Triage** — unless `--no-triage`, each server answers up to `--triage-attempts` (default 10) cached-domain queries, launched through the same global pacer and in-flight semaphore as benchmark queries. Only valid A/CNAME answers count. A server with no valid answers becomes `offline`; a server whose best RTT exceeds `--triage-threshold` (default 50ms) becomes `benched` ("out of contention") unless `--force-all`. Only `active` servers proceed. For the default `--session persistent`, one session per server is opened now; servers whose session fails to open become `error`. +3. **Characterization (probe)** — unless `--skip-probe`, every server is probed concurrently: reachability, baseline (A/AAAA, EDNS0), DNSSEC matrix, NXDOMAIN checks, reverse PTR, and extended checks with `--extended`. One persistent `Querier` is reused per server, and `--concurrency` bounds simultaneous server checks. Characterization has no launch pacer. +4. **Triage** — unless `--no-triage`, each server answers up to `--triage-attempts` (default 10) cached-domain queries, launched through the same global pacer and in-flight semaphore as benchmark queries. Only valid A/CNAME answers count. A built-in server whose best RTT exceeds `--triage-threshold` (default 200ms) becomes `benched` ("out of contention") unless `--force-all`; custom, user and system resolvers are checked only for availability and ignore the latency cutoff. A server with no valid answers becomes `offline`. Only `active` servers proceed. For the default `--session persistent`, one session per server is opened now; servers whose session fails to open become `error`. 5. **Warmup** — `--warmup` (default 3) full rounds run exactly like measured rounds but with `Warmup: true`; ranked distributions discard them, while connection-phase reporting may retain their initial cold-start timing separately. 6. **Interleaved rounds** — `--rounds` per category (mode default: quick 50, standard 250, precise 500). Each round: server order is reshuffled (seeded), each server runs its categories in per-server shuffled order, and a finish-to-start `--gap` (default 40ms) applies across category and round boundaries. Every launch waits for the global `--pace` tick, and servers are bounded by the `--concurrency` in-flight semaphore. Failed queries are retried with the full sequence retained as one end-to-end sample. 7. **Statistics** — samples are grouped by server and category; `stats.Compute` admits only category-correct DNS results into latency distributions and calculates transport, validity and retry rates. `stats.ComputePhases` keeps cold-start and steady-state measurements separate. @@ -71,7 +71,7 @@ Interruption: the first Ctrl+C cancels the context, the engine stops cleanly and - **Semaphore** — a channel of capacity `--concurrency` bounds how many queries are in flight at once (shared by triage and rounds); it acts as backpressure when many servers stop answering. - **Per-round barrier** — a `sync.WaitGroup` closes every round: no server starts round N+1 until every server has finished round N, keeping rounds time-aligned across servers. - **Seeded shuffling** — all randomness derives from one run seed (`--seed`, random when 0, recorded in exports). The per-round server order uses `PCG(seed, seq<<1|1)`; each server's per-round RNG uses `PCG(seed, seq<<24 | stableIdx<<1)` for category order and random labels. The same seed reproduces the same schedule and query names. -- **Cold vs persistent sessions** — persistent (default): one `Querier` per server is opened before warmup and reused (TCP/DoT keep the connection; DoH keeps HTTP keep-alives; UDP keeps one connected socket per server, so a whole run consumes one NAT/conntrack entry per server instead of one per query — small router state tables are a real-world loss source). A persistent UDP socket survives timeouts and drains stale late replies by DNS message ID before accepting an answer. Cold mode explicitly builds and tears down a querier per query. The two modes are never mixed within a run. +- **Cold vs persistent sessions** — persistent (default): one `Querier` per server is opened before warmup and reused (TCP/DoT keep the connection; DoH keeps HTTP keep-alives; UDP keeps one connected socket per server, so a whole run consumes one NAT/conntrack entry per server instead of one per query — small router state tables are a real-world loss source). A persistent UDP socket survives timeouts and drains stale late replies by DNS message ID before accepting an answer. DoQ likewise cancels a timed-out transaction at stream scope and preserves a healthy QUIC connection; framing violations remain connection-fatal. Cold mode explicitly builds and tears down a querier per query. The two modes are never mixed within a run. - **Per-server pacing** — the engine records each server's last query completion under a mutex. Before every subsequent query it waits until `lastQueryEnd + PerServerGap`, so round barriers cannot accidentally bypass pacing. - **Context cancellation** — one `context.Context` flows from the signal handler through triage, rounds, queries and sleeps; canceled queries are marked `canceled` and not recorded as samples. - **Pause/resume** — the engine has a pause gate (`Engine.Pause`/`Engine.Resume`); workers block at round and query boundaries while paused. In the current CLI the gate is driven by the connectivity watchdog. @@ -91,9 +91,9 @@ Transport errors are classified into six kinds: | --- | --- | --- | | `timeout` | No response within `--timeout` (context deadline or socket deadline) | Counted in `Timeouts`; contributes to `loss_pct`; drives triage "offline" reasons and the watchdog | | `network` | Dial/read/write failure (refused, unreachable, reset, …) | Counted in `Errors`; contributes to `loss_pct` | -| `tls` | TLS handshake or certificate failure (DoT/DoH) | Counted in `Errors`; contributes to `loss_pct` | -| `http` | Non-200 status or HTTP request failure (DoH) | Counted in `Errors`; contributes to `loss_pct` | -| `protocol` | Malformed DNS message, ID mismatch, empty message, wrong Content-Type | Counted in `Errors`; contributes to `loss_pct`; on persistent streams it also invalidates the session's connection | +| `tls` | TLS handshake or certificate failure (DoT/DoH/DoH3/DoQ, including QUIC crypto errors) | Counted in `Errors`; contributes to `loss_pct` | +| `http` | Non-200 status or HTTP request failure (DoH/DoH3) | Counted in `Errors`; contributes to `loss_pct` | +| `protocol` | Malformed DNS message, ID mismatch, empty message, wrong Content-Type, HTTP/3 connection or stream error | Counted in `Errors`; contributes to `loss_pct`; on persistent streams it also invalidates the session's connection | | `canceled` | The run's context was canceled (Ctrl+C) | The sample is discarded, not recorded; never pollutes statistics | Transport responses (no error) increment `Answered`, but latency accepts only `QueryResult.ValidFor(category)`: cached/uncached need `NOERROR` with A or CNAME; TLD needs `NXDOMAIN`. `SERVFAIL` increments `Servfails`; other semantically wrong responses increment `Invalid`. Neither enters latency samples. Truncated UDP answers are retried over TCP transparently (`UsedTCP`). `loss_pct = (count − answered) / count × 100`; SERVFAIL and invalid replies are distinct reliability signals, and retried queries are tracked even when their final response succeeds. diff --git a/docs/FORMULAS.md b/docs/FORMULAS.md index 142e65a..1d53449 100644 --- a/docs/FORMULAS.md +++ b/docs/FORMULAS.md @@ -135,8 +135,6 @@ retry = PenaltyPerRetryPctMs · (retried / totalQueries jitter = JitterWeight · ( Σ_{c∈C} jitter_c / |C| ) nxdomain-interception = PenaltyNXInterceptionMs if NX interception verdict = yes no-dnssec = PenaltyNoDNSSECMs if DNSSEC validating verdict ≠ yes -no-rebind-protection = PenaltyNoRebindMs if rebind overall = no - = PenaltyNoRebindMs / 2 if rebind overall = partial ``` ``` @@ -155,18 +153,17 @@ Servers are sorted by ascending total; totals within **0.01 ms** of the previous | `PenaltyPerRetryPctMs` | ms added per percentage point of queries that required more than one attempt | | `PenaltyNXInterceptionMs` | flat ms, applied once | | `PenaltyNoDNSSECMs` | flat ms, applied once | -| `PenaltyNoRebindMs` | flat ms, applied once (halved for partial protection) | | `JitterWeight` | dimensionless multiplier on the mean per-category jitter (ms → ms) | ## Default weight tables (from `internal/rank/presets.go`) -| Mode | cached | uncached | tld | Metric | Loss | SERVFAIL | Invalid | Retry | NX interception | No DNSSEC | No rebind | Jitter | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `latency` (default) | 1/3 | 1/3 | 1/3 | median | 5 | 5 | 5 | 2 | 5 | 5 | 5 | 0.25 | -| `browsing` | 0.30 | 0.45 | 0.25 | median | 10 | 10 | 10 | 5 | 15 | 10 | 10 | 0.5 | -| `reliability` | 1/3 | 1/3 | 1/3 | p95 | 25 | 25 | 25 | 10 | 25 | 20 | 20 | 1.0 | +| Mode | cached | uncached | tld | Metric | Loss | SERVFAIL | Invalid | Retry | NX interception | No DNSSEC | Jitter | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `latency` (default) | 1/3 | 1/3 | 1/3 | median | 5 | 5 | 5 | 2 | 5 | 5 | 0.25 | +| `browsing` | 0.30 | 0.45 | 0.25 | median | 10 | 10 | 10 | 5 | 15 | 10 | 0.5 | +| `reliability` | 1/3 | 1/3 | 1/3 | p95 | 25 | 25 | 25 | 10 | 25 | 20 | 1.0 | -The Loss, SERVFAIL, Invalid and Retry columns are milliseconds per percentage point; the next three are flat milliseconds; Jitter is dimensionless. +The Loss, SERVFAIL, Invalid and Retry columns are milliseconds per percentage point; the next two are flat milliseconds; Jitter is dimensionless. Custom weights can be merged over these presets per mode with `--weights `. diff --git a/docs/METHODOLOGY.md b/docs/METHODOLOGY.md index 85ce6cb..34fe240 100644 --- a/docs/METHODOLOGY.md +++ b/docs/METHODOLOGY.md @@ -14,12 +14,18 @@ tld: qname = randomLabel(rng, 16) + "." + tldZone # default zone: randomLabel(rng, 16) = 16 characters drawn from [a-z0-9] ``` -- **cached** — a curated set of 72 popular domains on generic TLDs, ranked globally rather than for one country. 66 of them come from the [Semrush most visited websites in the world](https://www.semrush.com/trending-websites/global/all) top 100 for May 2026; the other 6 are carried over from the previous set because they were already vetted and still widely used. Exclusions: adult and gambling domains, so resolvers that intentionally filter those categories are not penalized; **every country-edition domain** (`.br`, `.de`, `.it`, `.co.uk`, `.pt`, `.co.jp`, `.in`, `.vn`, `.ru`, `.su`), because a national storefront or edition measures one country rather than the global web — a brand on a generic TLD such as `globo.com` is kept, so the rule is about the extension, not the company; region-specific Asian services on generic TLDs (naver, rakuten, note); volatile piracy aggregators whose traffic appears and vanishes between snapshots; and any domain owned by a resolver dnsbench benchmarks (Google, Cloudflare, Cisco/OpenDNS, DigiCert, Comodo, Gcore, NextDNS, AdGuard, Quad9, Control D) to avoid a home-domain advantage. `.ai`, `.io`, `.net`, `.org`, `.tv` and `.us` are kept: those domains are the service's global home, not a national edition. A maintainer pass then trims the filtered result by hand, dropping redundant corporate properties, redirect-only short-link domains, and entries whose traffic came from a single event — so the file is smaller than the ranking it derives from, on purpose. Rank order inside the file is informational only — `buildQName` cycles the list uniformly, so every domain is queried equally often, and the count is therefore not a tuning knob. The editable source is [`internal/model/cached_domains.json`](../internal/model/cached_domains.json); rebuild the executable after changing it because the file is embedded at compile time. Measures the hot-cache answer path you feel most often while browsing. +- **cached** — a curated set of 72 popular domains on generic TLDs, ranked globally rather than for one country. 66 of them come from the [Semrush most visited websites in the world](https://www.semrush.com/trending-websites/global/all) top 100 for May 2026; the other 6 are carried over from the previous set because they were already vetted and still widely used. Exclusions: adult and gambling domains, so resolvers that intentionally filter those categories are not penalized; **every country-edition domain** (`.br`, `.de`, `.it`, `.co.uk`, `.pt`, `.co.jp`, `.in`, `.vn`, `.ru`, `.su`), because a national storefront or edition measures one country rather than the global web — a brand on a generic TLD such as `globo.com` is kept, so the rule is about the extension, not the company; region-specific Asian services on generic TLDs (naver, rakuten, note); volatile piracy aggregators whose traffic appears and vanishes between snapshots; and any domain owned by a resolver dnsbench benchmarks (Google, Cloudflare, Cisco/OpenDNS, Mullvad, NextDNS, AdGuard, Quad9, Control D) to avoid a home-domain advantage. `.ai`, `.io`, `.net`, `.org`, `.tv` and `.us` are kept: those domains are the service's global home, not a national edition. A maintainer pass then trims the filtered result by hand, dropping redundant corporate properties, redirect-only short-link domains, and entries whose traffic came from a single event — so the file is smaller than the ranking it derives from, on purpose. Rank order inside the file is informational only — `buildQName` cycles the list uniformly, so every domain is queried equally often, and the count is therefore not a tuning knob. The editable source is [`internal/model/cached_domains.json`](../internal/model/cached_domains.json); rebuild the executable after changing it because the file is embedded at compile time. Measures the hot-cache answer path you feel most often while browsing. - **uncached** — a random label under a zone **you control**, so every query is a guaranteed cache miss and forces a real recursion to your zone's authoritative server. Disabled unless `--uncached-zone` is set (dnsbench prints a notice and drops the category rather than fake it against zones you do not own). - **tld (recursive/TLD)** — a random label under a public TLD (default `com`, configurable with `--tld-zone`). Virtually all of these names do not exist, so the resolver must walk its recursion path down to the TLD infrastructure and return NXDOMAIN. This exercises the resolver's recursion machinery without needing infrastructure of your own. Default categories are `cached` and `tld`; passing `--uncached-zone` enables all three, and `--categories` selects any subset explicitly. +## Characterization sessions + +Before latency measurement, the characterization phase checks reachability, DNSSEC and NXDOMAIN handling. It creates one persistent `Querier` per server and reuses that session for every check. This matters most for DoT, DoH, DoH/3 and DoQ: opening a fresh encrypted connection for every qualitative check would turn a normal probe into a burst of TCP, TLS or QUIC handshakes and could trigger NAT pressure or server-side connection limits. + +Characterization has no launch pacer: its small qualitative workload is already bounded by the `--concurrency` limit (default 8), and its timing is never admitted into benchmark latency distributions. + ## What counts as a valid resolution A packet arriving from a resolver is not automatically a successful DNS lookup. dnsbench records transport responses separately from semantically valid resolutions: @@ -46,7 +52,7 @@ Instead of testing server A to completion and then server B (which would let net - The **server order is reshuffled every round** with a seeded PCG generator, so no server systematically benefits from always being measured first or last. - Each server runs its **categories in per-server shuffled order**. A `--gap` pause (default 40ms) is enforced from the completion of one query to the start of the next query to that server, including across category and round boundaries. -- Every query launch — across all servers, including triage probes and retries — passes through a **global pacer**: launches are spaced at least `--pace` apart (default 20ms, plus a small seeded jitter; `--pace 0` disables). Pacing bounds the instantaneous packet burst to one query per tick regardless of hardware, so the benchmark cannot congest the local Wi-Fi link, NAT router or uplink and then misattribute the resulting drops and queueing delay as server loss and latency. +- Every benchmark query launch — across all servers, including triage probes and retries — passes through a **global pacer**: launches are spaced at least `--pace` apart (default 20ms, plus a small seeded jitter; `--pace 0` disables). Pacing bounds the instantaneous packet burst to one query per tick regardless of hardware, so the benchmark cannot congest the local Wi-Fi link, NAT router or uplink and then misattribute the resulting drops and queueing delay as server loss and latency. - The pace **adapts AIMD-style** while the run progresses. Speed-up: every 50 answered queries with no congestion signal shorten the interval by an eighth of `--pace`, down to a floor of a quarter of `--pace`, so clean networks earn back wall-clock time. Back-off: when timeouts land on **3 or more distinct servers that had previously answered** within a 2-second window — the signature of local congestion, not of any one server — the interval doubles, up to a ceiling of 8× `--pace`, and further backoffs are suppressed for one query-timeout period so the tail of the same event is not double-counted. Servers that never answered do not count toward the signature, so dead or blackholed endpoints (e.g. unroutable IPv6) cannot slow the run. Each backoff is reported live as a pacing notice. - Cross-server parallelism is additionally capped by the `--concurrency` in-flight limit (default 8), which acts as a safety valve: when many servers stop answering, at most that many queries wait on timeouts at once, which naturally throttles the stream. @@ -56,11 +62,11 @@ All randomness (orders, labels) derives from a single seed. `--seed 0` (the defa Before the benchmark, triage cheaply removes servers that would waste the run's time budget (enabled by default; skip with `--no-triage`): -- Each server gets up to `--triage-attempts` probes (default 10) for the first cached domain, stopping early as soon as one valid A/CNAME answer arrives at or below `--triage-threshold` (default 50ms). Triage probes go through the same global pacer and in-flight cap as benchmark queries, so triage cannot flood the local network and sideline servers for self-induced loss. -- **States**: `active` (valid answer within threshold) proceeds to the benchmark; `offline` (zero valid answers — the reason records the dominant error kind, e.g. "8 of 10 probes timed out"); `benched`, shown as **"out of contention"** (valid answers arrived but the best RTT exceeded the threshold — the server works, it is just too slow to compete at this vantage point). -- `--force-all` keeps slow servers `active` instead of benching them. +- Each server gets up to `--triage-attempts` probes (default 10) for the first cached domain. Built-in resolvers stop early as soon as one valid A/CNAME answer arrives at or below `--triage-threshold` (default 200ms). Custom, user-list and system resolvers stop on the first valid answer regardless of RTT. Triage probes go through the same global pacer and in-flight cap as benchmark queries, so triage cannot flood the local network and sideline servers for self-induced loss. +- **States**: `active` proceeds to the benchmark; `offline` means zero valid answers (the reason records the dominant error kind, e.g. "8 of 10 probes timed out"); `benched`, shown as **"out of contention"**, applies only to built-in resolvers whose valid answers all exceeded the threshold. +- `--force-all` keeps slow built-in resolvers `active` instead of benching them. -**Out-of-contention is reversible.** Triage decisions apply to a single run and are never persisted. To include a benched server, re-run with a higher `--triage-threshold`, with `--force-all`, or with `--no-triage`. +**Out-of-contention is reversible.** Triage decisions apply to a single run and are never persisted. To include a benched built-in server, re-run with a higher `--triage-threshold`, with `--force-all`, or with `--no-triage`. ## Retries and timeouts @@ -68,14 +74,30 @@ Every query attempt has a hard `--timeout` (default 3s), enforced through contex If all attempts fail, the query contributes to loss. `SERVFAIL` is a transport response rather than loss, but it is not a valid resolution and receives its own reliability penalty. Retried queries receive a smaller separate penalty because a final success does not erase the delay and fragility the user experienced. -## Cold versus persistent connections (DoH/DoT) +## Cold versus persistent connections (DoT/DoH/DoH3/DoQ) Encrypted transports pay a connection cost (TCP + TLS handshake, plus HTTP setup for DoH) that plain UDP does not. dnsbench keeps two regimes strictly separate via `--session`: -- **persistent** (default) — one session per server is opened before the rounds and reused throughout: DoT/TCP reuse the established connection, DoH keeps HTTP keep-alives (HTTP/2 where negotiated). Warmup absorbs initial setup, and the measured rounds represent the steady-state behavior used by modern operating systems and applications. -- **cold** — every query opens a fresh connection and tears it down: for DoT a full TCP+TLS handshake per query; for DoH keep-alives are disabled and idle connections dropped after each request. Use this explicitly to study worst-case first-contact cost. +- **persistent** (default) — one session per server is opened before the rounds and reused throughout: DoT/TCP reuse the established connection, DoH keeps HTTP keep-alives (HTTP/2 where negotiated), and DoH/3 and DoQ keep the QUIC connection, opening one new stream per query. Warmup absorbs initial setup, and the measured rounds represent the steady-state behavior used by modern operating systems and applications. +- **cold** — every query opens a fresh connection and tears it down: for DoT a full TCP+TLS handshake per query; for DoH keep-alives are disabled and idle connections dropped after each request; for DoH/3 and DoQ a fresh QUIC handshake per query. Use this explicitly to study worst-case first-contact cost. + +DoQ timeouts and caller cancellation abort only the affected stream with `DOQ_REQUEST_CANCELLED`; a healthy persistent QUIC connection remains available for the next query. A response is accepted only after exactly one framed DNS message followed by the stream FIN. Missing FIN, trailing response bytes, malformed DNS and message-ID violations are protocol errors and invalidate the connection with `DOQ_PROTOCOL_ERROR`. Ordinary EDNS-enabled DoQ queries also carry EDNS Padding to a 128-byte DNS-message boundary. + +The mode applies to latency-measurement samples; cold and persistent numbers must never be compared as if they measured the same workload. Characterization is qualitative, always reuses one session per server and bounds simultaneous server checks with its concurrency limit. Per-phase timings (connect, TLS handshake, HTTP setup, query), cold-start latency, steady-state latency and cold/reused sample counts are retained so encrypted transport setup remains inspectable without contaminating the default steady-state ranking. + +QUIC establishes the transport and the cryptographic handshake together, so `doh3` and `doq` report that single handshake under the TLS phase and leave the connect phase at zero. A QUIC transport is therefore expected to show a lower total setup cost than DoT or DoH at the same round-trip distance; that difference is real, not an artifact of how the phases are attributed. + +## Bootstrap resolution + +An encrypted endpoint identified by hostname has a chicken-and-egg problem: the hostname has to be resolved before the encrypted connection exists, and that lookup travels in plaintext to whatever resolver the system is configured with. On a network that intercepts port 53 — a common router default — those lookups are answered by the interceptor, so a run that appears to measure encrypted resolvers still emits plaintext DNS. -The mode applies to the entire run; cold and persistent numbers must never be compared as if they measured the same workload. Per-phase timings (connect, TLS handshake, HTTP setup, query), cold-start latency, steady-state latency and cold/reused sample counts are retained so encrypted transport setup remains inspectable without contaminating the default steady-state ranking. +dnsbench avoids this by pinning: every encrypted endpoint in the built-in list carries the resolver's IP address, dials it directly, and uses the hostname only for SNI and certificate validation. No name resolution happens before an encrypted query. + +Use `--protocols dot,doh,doh3,doq` to select only encrypted transports. Protocol selection does not exclude hostname-only custom entries, so it is a transport filter rather than a guarantee about their bootstrap lookup. + +When an explicitly configured DoH/3 endpoint is not pinned, its hostname is resolved by the HTTP/3 transport with the query context. The same cancellation or deadline that bounds the request therefore also bounds bootstrap resolution. + +Pinning fixes the IP for endpoints that would otherwise be selected by the operator's own DNS-based steering. For the anycast addresses that public resolvers publish this is the intended behavior — it is the same address their plaintext and DoT endpoints use — but it means a provider that steers clients to per-region hosts is measured at its published address rather than at whatever host its DNS would have returned for your location. ## Test zones: setting up your own wildcard zone for the uncached category @@ -153,17 +175,6 @@ The combined `validating` verdict: The default test domains live in `internal/probe/config.go` (`Config.SignedDomain`, `Config.BogusDomain`; `Config.UnsignedDomain` is also defined). The CLI does not currently expose flags for them; to change them, edit `probe.DefaultConfig` or pass a custom `probe.Config` when using the probe package as a library. -## Rebinding protection - -DNS rebinding attacks use public hostnames that resolve to private addresses to reach devices inside your network. The probe uses the public `sslip.io` service, whose hostnames encode the address they resolve to, and checks whether the server blocks such answers: - -- IPv4 (A): `127.0.0.1.sslip.io` → 127.0.0.1, `10.10.10.10.sslip.io` → 10.10.10.10, `172.16.1.1.sslip.io` → 172.16.1.1, `192.168.1.1.sslip.io` → 192.168.1.1 -- IPv6 (AAAA): `--1.sslip.io` → ::1, `fd00--1.sslip.io` → fd00::1 - -Per case: NXDOMAIN, REFUSED or an empty NOERROR count as **blocked**; an answer containing the expected private address counts as **unprotected**; timeouts and unexpected answers are **undetermined**. Per family the verdict is yes (all determined cases blocked), no (none blocked), or partial; the overall verdict combines v4 and v6. - -**Guarantee: only DNS responses are examined.** dnsbench never opens an HTTP, TLS or any other connection to the returned addresses — the check is purely about what the resolver answers, and no traffic is ever sent to 127.0.0.1, 192.168.1.1 or any other resolved address. - ## Modern extensions (optional, outside the ranking) With `--extended` (on `probe` or `run`), three informational checks run. Their results are reported but **never affect latency cost or order**: diff --git a/go.mod b/go.mod index c0d0dea..950a0fd 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.26.5 require ( github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/x/term v0.2.1 github.com/godbus/dbus/v5 v5.2.2 github.com/miekg/dns v1.1.72 + github.com/quic-go/quic-go v0.61.0 github.com/spf13/cobra v1.10.2 - golang.org/x/sys v0.39.0 + golang.org/x/sys v0.47.0 ) require ( @@ -16,7 +18,6 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect @@ -26,12 +27,14 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index a933cb8..065aef2 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -37,6 +39,14 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -45,23 +55,31 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/bench/engine_test.go b/internal/bench/engine_test.go index 711117d..24fae62 100644 --- a/internal/bench/engine_test.go +++ b/internal/bench/engine_test.go @@ -131,7 +131,7 @@ func TestTriageClassification(t *testing.T) { cfg.TriageThreshold = 50 * time.Millisecond cfg.WarmupRounds = 0 cfg.Rounds = 1 - e := NewEngine(testServers("fast", "slow", "silent", "refused"), cfg, f.factory) + e := NewEngine(builtinTestServers("fast", "slow", "silent", "refused"), cfg, f.factory) events, err := runEngine(t, e, context.Background()) if err != nil { t.Fatalf("Run failed: %v", err) @@ -191,7 +191,7 @@ func TestTriageForceAllKeepsSlowActive(t *testing.T) { cfg.ForceAll = true cfg.WarmupRounds = 0 cfg.Rounds = 1 - e := NewEngine(testServers("fast", "slow", "silent"), cfg, f.factory) + e := NewEngine(builtinTestServers("fast", "slow", "silent"), cfg, f.factory) if _, err := runEngine(t, e, context.Background()); err != nil { t.Fatalf("Run failed: %v", err) } @@ -211,6 +211,64 @@ func TestTriageForceAllKeepsSlowActive(t *testing.T) { } } +func TestTriageThresholdAppliesOnlyToBuiltins(t *testing.T) { + f := newFakeFactory() + for id, rtt := range map[string]time.Duration{ + "builtin-at-limit": 200 * time.Millisecond, + "builtin-over-limit": 201 * time.Millisecond, + "custom-slow": 250 * time.Millisecond, + } { + f.script(id, staticResult(okResult(rtt))) + } + f.script("custom-recovers", func(_ context.Context, n int, _ transport.Question) model.QueryResult { + if n == 0 { + return timeoutResult() + } + return okResult(300 * time.Millisecond) + }) + f.script("custom-offline", staticResult(timeoutResult())) + servers := builtinTestServers("builtin-at-limit", "builtin-over-limit") + servers = append(servers, testServers("custom-slow", "custom-recovers", "custom-offline")...) + cfg := testConfig() + cfg.TriageEnabled = true + cfg.TriageAttempts = 3 + cfg.TriageThreshold = 200 * time.Millisecond + cfg.WarmupRounds = 0 + cfg.Rounds = 1 + cfg.Categories = []model.Category{model.CatCached} + e := NewEngine(servers, cfg, f.factory) + if _, err := runEngine(t, e, context.Background()); err != nil { + t.Fatalf("Run failed: %v", err) + } + samples, triage, states, _ := e.Result() + if tr := triage["builtin-at-limit"]; tr.State != model.StateActive || tr.Attempts != 1 { + t.Errorf("builtin at limit = %+v, want active after one attempt", tr) + } + if tr := triage["builtin-over-limit"]; tr.State != model.StateBenched || tr.Attempts != cfg.TriageAttempts { + t.Errorf("builtin over limit = %+v, want benched after %d attempts", tr, cfg.TriageAttempts) + } + if tr := triage["custom-slow"]; tr.State != model.StateActive || tr.Attempts != 1 { + t.Errorf("custom slow = %+v, want active after one valid answer", tr) + } + if tr := triage["custom-recovers"]; tr.State != model.StateActive || tr.Attempts != 2 || tr.Responses != 1 { + t.Errorf("custom recovered = %+v, want active after its first valid answer", tr) + } + if tr := triage["custom-offline"]; tr.State != model.StateOffline || tr.Attempts != cfg.TriageAttempts || tr.Responses != 0 { + t.Errorf("custom offline = %+v, want offline after every attempt failed", tr) + } + if states["custom-slow"] != model.StateActive || states["custom-recovers"] != model.StateActive { + t.Errorf("custom states = slow:%s recovered:%s, want active", states["custom-slow"], states["custom-recovers"]) + } + seen := map[string]bool{} + for _, sample := range samples { + seen[sample.ServerID] = true + } + if !seen["builtin-at-limit"] || !seen["custom-slow"] || !seen["custom-recovers"] || + seen["builtin-over-limit"] || seen["custom-offline"] { + t.Fatalf("sampled resolvers = %v", seen) + } +} + func TestTriageRejectsSemanticallyInvalidDNSResponses(t *testing.T) { f := newFakeFactory() f.script("invalid", staticResult(model.QueryResult{Rcode: 2})) diff --git a/internal/bench/fake_test.go b/internal/bench/fake_test.go index 5c9379e..e54dcfd 100644 --- a/internal/bench/fake_test.go +++ b/internal/bench/fake_test.go @@ -121,6 +121,14 @@ func testServers(ids ...string) []model.Server { return servers } +func builtinTestServers(ids ...string) []model.Server { + servers := testServers(ids...) + for i := range servers { + servers[i].Source = model.SourceBuiltin + } + return servers +} + func testConfig() model.BenchConfig { return model.BenchConfig{ Rounds: 3, diff --git a/internal/bench/triage.go b/internal/bench/triage.go index df80dd9..9e7e120 100644 --- a/internal/bench/triage.go +++ b/internal/bench/triage.go @@ -92,7 +92,7 @@ func (e *Engine) triageServer(ctx context.Context, s model.Server) *model.Triage if tr.BestRTT == 0 || res.RTT < tr.BestRTT { tr.BestRTT = res.RTT } - if res.RTT <= e.cfg.TriageThreshold { + if s.Source != model.SourceBuiltin || res.RTT <= e.cfg.TriageThreshold { break } } else if res.Err != nil { @@ -107,11 +107,11 @@ func (e *Engine) triageServer(ctx context.Context, s model.Server) *model.Triage } } } - e.classifyTriage(tr, counts, msgs) + e.classifyTriage(s, tr, counts, msgs) return tr } -func (e *Engine) classifyTriage(tr *model.TriageResult, counts map[model.ErrKind]int, msgs map[model.ErrKind]string) { +func (e *Engine) classifyTriage(s model.Server, tr *model.TriageResult, counts map[model.ErrKind]int, msgs map[model.ErrKind]string) { if tr.Attempts == 0 { tr.State = model.StateOffline tr.Reason = "triage was interrupted before any probe completed" @@ -122,7 +122,7 @@ func (e *Engine) classifyTriage(tr *model.TriageResult, counts map[model.ErrKind tr.Reason = offlineReason(tr.Attempts, counts, msgs) return } - if tr.BestRTT > e.cfg.TriageThreshold { + if s.Source == model.SourceBuiltin && tr.BestRTT > e.cfg.TriageThreshold { if e.cfg.ForceAll { tr.State = model.StateActive tr.Reason = fmt.Sprintf("best RTT %s is above the triage threshold %s; kept active by force-all", diff --git a/internal/model/cached_domains.json b/internal/model/cached_domains.json index 391927c..b3bd938 100644 --- a/internal/model/cached_domains.json +++ b/internal/model/cached_domains.json @@ -1,7 +1,7 @@ { "source": "Semrush most visited websites in the world, ranks 1-100 for May 2026: 66 entries kept, plus 6 entries carried over from the earlier Brazil-localized set, after a maintainer curation pass over the filtered result", "snapshot": "2026-05", - "notes": "Global ranking on generic TLDs only. Excluded: adult and gambling sites; every country-edition domain (.br, .de, .it, .co.uk, .pt, .co.jp, .in, .vn, .ru, .su), since a national storefront or edition measures one country rather than the global web -- a brand on a generic TLD such as globo.com is kept, so the rule is about the extension, not the company; region-specific Asian services (naver, rakuten, note); volatile piracy aggregators; and domains owned by any resolver dnsbench benchmarks (Google, Cloudflare, Cisco/OpenDNS, DigiCert, Comodo, Gcore, NextDNS, AdGuard, Quad9, Control D) to avoid a home-domain advantage. Generic-in-practice TLDs (.ai, .io, .net, .org, .tv, .us) are kept because those domains are the service's global home, not a national edition. A maintainer pass then trimmed the filtered result further, dropping redundant corporate properties, redirect-only short-link domains, and entries whose traffic was driven by a single event.", + "notes": "Global ranking on generic TLDs only. Excluded: adult and gambling sites; every country-edition domain (.br, .de, .it, .co.uk, .pt, .co.jp, .in, .vn, .ru, .su), since a national storefront or edition measures one country rather than the global web -- a brand on a generic TLD such as globo.com is kept, so the rule is about the extension, not the company; region-specific Asian services (naver, rakuten, note); volatile piracy aggregators; and domains owned by any resolver dnsbench benchmarks (Google, Cloudflare, Cisco/OpenDNS, Mullvad, NextDNS, AdGuard, Quad9, Control D) to avoid a home-domain advantage. Generic-in-practice TLDs (.ai, .io, .net, .org, .tv, .us) are kept because those domains are the service's global home, not a national edition. A maintainer pass then trimmed the filtered result further, dropping redundant corporate properties, redirect-only short-link domains, and entries whose traffic was driven by a single event.", "domains": [ "facebook.com", "instagram.com", diff --git a/internal/model/config_test.go b/internal/model/config_test.go index f70d297..298405d 100644 --- a/internal/model/config_test.go +++ b/internal/model/config_test.go @@ -3,6 +3,7 @@ package model import ( "strings" "testing" + "time" ) func TestDefaultBenchConfigUsesPersistentSession(t *testing.T) { @@ -12,6 +13,13 @@ func TestDefaultBenchConfigUsesPersistentSession(t *testing.T) { } } +func TestDefaultBenchConfigUses200MillisecondTriageThreshold(t *testing.T) { + cfg := DefaultBenchConfig(ModeStandard) + if cfg.TriageThreshold != 200*time.Millisecond { + t.Fatalf("TriageThreshold = %s, want 200ms", cfg.TriageThreshold) + } +} + func TestDefaultCachedDomainsAreCuratedTopDomains(t *testing.T) { domains := DefaultCachedDomains() if got := len(domains); got < 50 { diff --git a/internal/model/model.go b/internal/model/model.go index a210609..e7e5242 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -3,36 +3,63 @@ package model import ( "fmt" "net/netip" + "net/url" "strings" "time" ) const ( AppName = "dnsbench" - AppVersion = "0.5.0" + AppVersion = "0.6.0" ) type Protocol string const ( - ProtoUDP Protocol = "udp" - ProtoTCP Protocol = "tcp" - ProtoDoT Protocol = "dot" - ProtoDoH Protocol = "doh" + ProtoUDP Protocol = "udp" + ProtoTCP Protocol = "tcp" + ProtoDoT Protocol = "dot" + ProtoDoH Protocol = "doh" + ProtoDoH3 Protocol = "doh3" + ProtoDoQ Protocol = "doq" ) +func AllProtocols() []Protocol { + return []Protocol{ProtoUDP, ProtoTCP, ProtoDoT, ProtoDoH, ProtoDoH3, ProtoDoQ} +} + +func EncryptedProtocols() []Protocol { + var out []Protocol + for _, p := range AllProtocols() { + if p.Encrypted() { + out = append(out, p) + } + } + return out +} + func (p Protocol) DefaultPort() int { switch p { - case ProtoDoT: + case ProtoDoT, ProtoDoQ: return 853 - case ProtoDoH: + case ProtoDoH, ProtoDoH3: return 443 default: return 53 } } -func (p Protocol) Encrypted() bool { return p == ProtoDoT || p == ProtoDoH } +func (p Protocol) Encrypted() bool { + switch p { + case ProtoDoT, ProtoDoH, ProtoDoH3, ProtoDoQ: + return true + } + return false +} + +func (p Protocol) UsesURL() bool { return p == ProtoDoH || p == ProtoDoH3 } + +func (p Protocol) OverQUIC() bool { return p == ProtoDoH3 || p == ProtoDoQ } func (p Protocol) Label() string { switch p { @@ -44,6 +71,10 @@ func (p Protocol) Label() string { return "DoT" case ProtoDoH: return "DoH" + case ProtoDoH3: + return "DoH/3" + case ProtoDoQ: + return "DoQ" } return string(p) } @@ -124,7 +155,7 @@ func (s Server) EffectivePort() int { } func (s Server) Endpoint() string { - if s.Protocol == ProtoDoH && s.DoHURL != "" { + if s.Protocol.UsesURL() && s.DoHURL != "" { return s.DoHURL } addr := s.Address @@ -156,12 +187,30 @@ func (s Server) DisplayName() string { if s.Name != "" { return s.Name } - if s.Protocol == ProtoDoH && s.DoHURL != "" { + if s.Protocol.UsesURL() && s.DoHURL != "" { return s.DoHURL } return s.Address } +func (s Server) BootstrapPinned() bool { + if !s.Protocol.Encrypted() { + return true + } + if !s.Protocol.UsesURL() { + return s.Address != "" + } + if s.Address != "" { + return true + } + host := s.DoHURL + if u, err := url.Parse(s.DoHURL); err == nil && u.Host != "" { + host = u.Hostname() + } + _, err := netip.ParseAddr(host) + return err == nil +} + type ErrKind string const ( @@ -351,13 +400,6 @@ type DNSSECInfo struct { Validating Verdict `json:"validating"` } -type RebindInfo struct { - V4 Verdict `json:"v4"` - V6 Verdict `json:"v6"` - Overall Verdict `json:"overall"` - Details []string `json:"details,omitempty"` -} - type ExtendedInfo struct { DNS64 Verdict `json:"dns64"` QNAMEMinimization Verdict `json:"qname_minimization"` @@ -376,7 +418,6 @@ type ProbeResult struct { DNSSEC DNSSECInfo `json:"dnssec"` NXChecks []NXCheck `json:"nx_checks"` NXInterception Verdict `json:"nx_interception"` - Rebind RebindInfo `json:"rebind"` ReverseName string `json:"reverse_name,omitempty"` Extended *ExtendedInfo `json:"extended,omitempty"` Errors []string `json:"errors,omitempty"` @@ -536,7 +577,6 @@ type Weights struct { PenaltyPerRetryPctMs float64 `json:"penalty_per_retry_pct_ms"` PenaltyNXInterceptionMs float64 `json:"penalty_nx_interception_ms"` PenaltyNoDNSSECMs float64 `json:"penalty_no_dnssec_ms"` - PenaltyNoRebindMs float64 `json:"penalty_no_rebind_ms"` JitterWeight float64 `json:"jitter_weight"` } @@ -621,7 +661,7 @@ func DefaultBenchConfig(m Mode) BenchConfig { Session: SessionPersistent, TriageEnabled: true, TriageAttempts: 10, - TriageThreshold: 50 * time.Millisecond, + TriageThreshold: 200 * time.Millisecond, ConnectivityWatch: true, } } diff --git a/internal/model/protocol_test.go b/internal/model/protocol_test.go new file mode 100644 index 0000000..9ec104d --- /dev/null +++ b/internal/model/protocol_test.go @@ -0,0 +1,81 @@ +package model + +import "testing" + +func TestProtocolProperties(t *testing.T) { + cases := []struct { + p Protocol + port int + label string + encrypted bool + usesURL bool + overQUIC bool + }{ + {ProtoUDP, 53, "UDP/53", false, false, false}, + {ProtoTCP, 53, "TCP/53", false, false, false}, + {ProtoDoT, 853, "DoT", true, false, false}, + {ProtoDoH, 443, "DoH", true, true, false}, + {ProtoDoH3, 443, "DoH/3", true, true, true}, + {ProtoDoQ, 853, "DoQ", true, false, true}, + } + for _, tc := range cases { + if got := tc.p.DefaultPort(); got != tc.port { + t.Errorf("%s DefaultPort() = %d, want %d", tc.p, got, tc.port) + } + if got := tc.p.Label(); got != tc.label { + t.Errorf("%s Label() = %q, want %q", tc.p, got, tc.label) + } + if got := tc.p.Encrypted(); got != tc.encrypted { + t.Errorf("%s Encrypted() = %v, want %v", tc.p, got, tc.encrypted) + } + if got := tc.p.UsesURL(); got != tc.usesURL { + t.Errorf("%s UsesURL() = %v, want %v", tc.p, got, tc.usesURL) + } + if got := tc.p.OverQUIC(); got != tc.overQUIC { + t.Errorf("%s OverQUIC() = %v, want %v", tc.p, got, tc.overQUIC) + } + } + if len(AllProtocols()) != len(cases) { + t.Errorf("AllProtocols() has %d entries, want %d", len(AllProtocols()), len(cases)) + } + if len(EncryptedProtocols()) != 4 { + t.Errorf("EncryptedProtocols() = %v, want 4 entries", EncryptedProtocols()) + } +} + +func TestBootstrapPinned(t *testing.T) { + cases := []struct { + name string + s Server + want bool + }{ + {"plaintext needs no pin", Server{Protocol: ProtoUDP, Address: "8.8.8.8"}, true}, + {"dot with address", Server{Protocol: ProtoDoT, Address: "9.9.9.9", TLSHostname: "dns.quad9.net"}, true}, + {"doq with address", Server{Protocol: ProtoDoQ, Address: "9.9.9.9", TLSHostname: "dns.quad9.net"}, true}, + {"dot without address", Server{Protocol: ProtoDoT, TLSHostname: "dns.quad9.net"}, false}, + {"doh with bootstrap", Server{Protocol: ProtoDoH, DoHURL: "https://dns.google/dns-query", Address: "8.8.8.8"}, true}, + {"doh with hostname only", Server{Protocol: ProtoDoH, DoHURL: "https://dns.google/dns-query"}, false}, + {"doh with IP literal URL", Server{Protocol: ProtoDoH, DoHURL: "https://1.1.1.1/dns-query"}, true}, + {"doh3 with IPv6 literal URL", Server{Protocol: ProtoDoH3, DoHURL: "https://[2620:fe::fe]/dns-query"}, true}, + {"doh3 with hostname only", Server{Protocol: ProtoDoH3, DoHURL: "https://dns.quad9.net/dns-query"}, false}, + } + for _, tc := range cases { + if got := tc.s.BootstrapPinned(); got != tc.want { + t.Errorf("%s: BootstrapPinned() = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestEndpointAndDisplayNameForURLProtocols(t *testing.T) { + s := Server{Protocol: ProtoDoH3, DoHURL: "https://dns.quad9.net/dns-query", Address: "9.9.9.9", Port: 443} + if got := s.Endpoint(); got != "https://dns.quad9.net/dns-query" { + t.Errorf("Endpoint() = %q, want the DoH URL", got) + } + if got := s.DisplayName(); got != "https://dns.quad9.net/dns-query" { + t.Errorf("DisplayName() = %q, want the DoH URL", got) + } + doq := Server{Protocol: ProtoDoQ, Address: "2620:fe::fe", TLSHostname: "dns.quad9.net"} + if got := doq.Endpoint(); got != "[2620:fe::fe]:853" { + t.Errorf("DoQ Endpoint() = %q, want [2620:fe::fe]:853", got) + } +} diff --git a/internal/probe/config.go b/internal/probe/config.go index 7f25356..58680da 100644 --- a/internal/probe/config.go +++ b/internal/probe/config.go @@ -6,18 +6,11 @@ import ( "dnsbench/internal/transport" ) -type RebindCase struct { - Host string - Expected string -} - type Config struct { ReachabilityDomain string SignedDomain string BogusDomain string UnsignedDomain string - RebindV4 []RebindCase - RebindV6 []RebindCase NXTLDZone string UncachedZone string Timeout time.Duration @@ -34,20 +27,10 @@ func DefaultConfig() Config { SignedDomain: "cloudflare.com", BogusDomain: "dnssec-failed.org", UnsignedDomain: "github.com", - RebindV4: []RebindCase{ - {Host: "127.0.0.1.sslip.io", Expected: "127.0.0.1"}, - {Host: "10.10.10.10.sslip.io", Expected: "10.10.10.10"}, - {Host: "172.16.1.1.sslip.io", Expected: "172.16.1.1"}, - {Host: "192.168.1.1.sslip.io", Expected: "192.168.1.1"}, - }, - RebindV6: []RebindCase{ - {Host: "--1.sslip.io", Expected: "::1"}, - {Host: "fd00--1.sslip.io", Expected: "fd00::1"}, - }, - NXTLDZone: "com", - Timeout: 3 * time.Second, - Retries: 1, - Concurrency: 8, - Extended: false, + NXTLDZone: "com", + Timeout: 3 * time.Second, + Retries: 1, + Concurrency: 8, + Extended: false, } } diff --git a/internal/probe/mock_test.go b/internal/probe/mock_test.go index bc865c1..8503755 100644 --- a/internal/probe/mock_test.go +++ b/internal/probe/mock_test.go @@ -2,7 +2,6 @@ package probe import ( "net" - "net/netip" "strings" "testing" "time" @@ -15,14 +14,11 @@ type profile int const ( profileValidating profile = iota profileInterceptor - profileRebindBlocking - profileRebindUnprotected ) const ( interceptIP = "203.0.113.99" interceptCNAME = "landing.intercept.test." - rebindSuffix = ".rebind.test." ) func aRR(name, ip string) dns.RR { @@ -83,44 +79,6 @@ func httpsRR(name string) dns.RR { }} } -func embeddedIP(host string) string { - if _, err := netip.ParseAddr(host); err == nil { - return host - } - v6 := strings.ReplaceAll(host, "-", ":") - if _, err := netip.ParseAddr(v6); err == nil { - return v6 - } - return "" -} - -func serveRebind(m *dns.Msg, q dns.Question, p profile) { - host := strings.TrimSuffix(strings.ToLower(q.Name), rebindSuffix) - switch p { - case profileRebindBlocking: - return - case profileInterceptor: - m.Answer = append(m.Answer, aRR(q.Name, interceptIP)) - return - case profileValidating: - if q.Qtype == dns.TypeA { - return - } - } - ip := embeddedIP(host) - if ip == "" { - m.Rcode = dns.RcodeNameError - return - } - isV6 := strings.Contains(ip, ":") - if q.Qtype == dns.TypeA && !isV6 { - m.Answer = append(m.Answer, aRR(q.Name, ip)) - } - if q.Qtype == dns.TypeAAAA && isV6 { - m.Answer = append(m.Answer, aaaaRR(q.Name, ip)) - } -} - func serveUnknown(m *dns.Msg, q dns.Question, p profile) { switch p { case profileInterceptor: @@ -129,7 +87,6 @@ func serveUnknown(m *dns.Msg, q dns.Question, p profile) { return } m.Answer = append(m.Answer, aRR(q.Name, interceptIP)) - case profileRebindBlocking: default: m.Rcode = dns.RcodeNameError } @@ -137,14 +94,12 @@ func serveUnknown(m *dns.Msg, q dns.Question, p profile) { func serveBogus(m *dns.Msg, q dns.Question, req *dns.Msg, p profile) { switch p { - case profileValidating, profileRebindBlocking: + case profileValidating: if req.CheckingDisabled { m.Answer = append(m.Answer, aRR(q.Name, "192.0.2.66")) return } m.Rcode = dns.RcodeServerFailure - case profileRebindUnprotected: - m.Rcode = dns.RcodeNameError default: m.Answer = append(m.Answer, aRR(q.Name, "192.0.2.66")) } @@ -176,8 +131,6 @@ func mockHandler(p profile) dns.HandlerFunc { } case name == "bogus.test.": serveBogus(m, q, req, p) - case strings.HasSuffix(name, rebindSuffix): - serveRebind(m, q, p) case name == "1.0.0.127.in-addr.arpa." && q.Qtype == dns.TypePTR: m.Answer = append(m.Answer, ptrRR(q.Name, "resolver.test.")) case name == "ipv4only.arpa." && q.Qtype == dns.TypeAAAA: diff --git a/internal/probe/probe.go b/internal/probe/probe.go index 9abc4f9..905fe5e 100644 --- a/internal/probe/probe.go +++ b/internal/probe/probe.go @@ -46,7 +46,7 @@ func Run(ctx context.Context, servers []model.Server, cfg Config) map[string]*mo func probeServer(ctx context.Context, srv model.Server, cfg Config, factory transport.Factory) *model.ProbeResult { res := newResult(srv.ID) - querier, err := factory(srv, transport.Options{Timeout: cfg.Timeout}) + querier, err := factory(srv, transport.Options{Timeout: cfg.Timeout, Persistent: true}) if err != nil { res.Errors = append(res.Errors, "could not set up transport: "+err.Error()) return res @@ -72,11 +72,6 @@ func newResult(serverID string) *model.ProbeResult { Validating: model.VerdictUnknown, }, NXInterception: model.VerdictUnknown, - Rebind: model.RebindInfo{ - V4: model.VerdictUnknown, - V6: model.VerdictUnknown, - Overall: model.VerdictUnknown, - }, } } @@ -104,10 +99,6 @@ func (p *prober) run(ctx context.Context) { if p.canceled(ctx) { return } - p.checkRebind(ctx) - if p.canceled(ctx) { - return - } p.checkReverseName(ctx) if p.cfg.Extended && !p.canceled(ctx) { p.checkExtended(ctx) diff --git a/internal/probe/probe_test.go b/internal/probe/probe_test.go index 1985b55..87e8e7e 100644 --- a/internal/probe/probe_test.go +++ b/internal/probe/probe_test.go @@ -8,6 +8,7 @@ import ( "time" "dnsbench/internal/model" + "dnsbench/internal/transport" "github.com/miekg/dns" ) @@ -18,14 +19,6 @@ func testConfig(uncachedZone string) Config { cfg.SignedDomain = "signed.test" cfg.BogusDomain = "bogus.test" cfg.UnsignedDomain = "unsigned.test" - cfg.RebindV4 = []RebindCase{ - {Host: "127.0.0.1.rebind.test", Expected: "127.0.0.1"}, - {Host: "192.168.1.1.rebind.test", Expected: "192.168.1.1"}, - } - cfg.RebindV6 = []RebindCase{ - {Host: "--1.rebind.test", Expected: "::1"}, - {Host: "fd00--1.rebind.test", Expected: "fd00::1"}, - } cfg.NXTLDZone = "com" cfg.UncachedZone = uncachedZone cfg.Timeout = 2 * time.Second @@ -78,24 +71,6 @@ func TestDefaultConfig(t *testing.T) { if cfg.UnsignedDomain != "github.com" { t.Errorf("UnsignedDomain = %q", cfg.UnsignedDomain) } - if len(cfg.RebindV4) != 4 { - t.Fatalf("len(RebindV4) = %d, want 4", len(cfg.RebindV4)) - } - if cfg.RebindV4[0] != (RebindCase{Host: "127.0.0.1.sslip.io", Expected: "127.0.0.1"}) { - t.Errorf("RebindV4[0] = %+v", cfg.RebindV4[0]) - } - if cfg.RebindV4[3] != (RebindCase{Host: "192.168.1.1.sslip.io", Expected: "192.168.1.1"}) { - t.Errorf("RebindV4[3] = %+v", cfg.RebindV4[3]) - } - if len(cfg.RebindV6) != 2 { - t.Fatalf("len(RebindV6) = %d, want 2", len(cfg.RebindV6)) - } - if cfg.RebindV6[0] != (RebindCase{Host: "--1.sslip.io", Expected: "::1"}) { - t.Errorf("RebindV6[0] = %+v", cfg.RebindV6[0]) - } - if cfg.RebindV6[1] != (RebindCase{Host: "fd00--1.sslip.io", Expected: "fd00::1"}) { - t.Errorf("RebindV6[1] = %+v", cfg.RebindV6[1]) - } if cfg.NXTLDZone != "com" { t.Errorf("NXTLDZone = %q", cfg.NXTLDZone) } @@ -119,6 +94,42 @@ func TestDefaultConfig(t *testing.T) { } } +type probeTestQuerier struct { + onQuery func() +} + +func (q *probeTestQuerier) Query(context.Context, transport.Question) model.QueryResult { + if q.onQuery != nil { + q.onQuery() + } + return model.QueryResult{ + Rcode: dns.RcodeSuccess, + Answers: []model.RR{{Type: "A", Data: "192.0.2.1"}}, + } +} + +func (*probeTestQuerier) Protocol() model.Protocol { return model.ProtoUDP } +func (*probeTestQuerier) Close() error { return nil } + +func TestRunUsesPersistentQuerierForCharacterization(t *testing.T) { + cfg := testConfig("") + var got transport.Options + cfg.Factory = func(_ model.Server, opts transport.Options) (transport.Querier, error) { + got = opts + return &probeTestQuerier{}, nil + } + results := Run(context.Background(), []model.Server{{ID: "mock"}}, cfg) + if results["mock"] == nil { + t.Fatal("missing probe result") + } + if !got.Persistent { + t.Error("probe factory received Persistent = false, want one reused session per server") + } + if got.Timeout != cfg.Timeout { + t.Errorf("probe factory Timeout = %v, want %v", got.Timeout, cfg.Timeout) + } +} + func TestValidatingResolverProfile(t *testing.T) { cfg := testConfig("") cfg.Extended = true @@ -169,12 +180,6 @@ func TestValidatingResolverProfile(t *testing.T) { t.Errorf("www-nonexistent qname = %q", pr.NXChecks[2].QName) } assertVerdict(t, "NXInterception", pr.NXInterception, model.VerdictNo) - assertVerdict(t, "Rebind.V4", pr.Rebind.V4, model.VerdictYes) - assertVerdict(t, "Rebind.V6", pr.Rebind.V6, model.VerdictNo) - assertVerdict(t, "Rebind.Overall", pr.Rebind.Overall, model.VerdictPartial) - if len(pr.Rebind.Details) != 4 { - t.Errorf("len(Rebind.Details) = %d, want 4", len(pr.Rebind.Details)) - } if pr.ReverseName != "resolver.test" { t.Errorf("ReverseName = %q, want %q", pr.ReverseName, "resolver.test") } @@ -230,9 +235,6 @@ func TestInterceptorProfile(t *testing.T) { t.Errorf("controlled-zone qname = %q", byLabel["controlled-zone"].QName) } assertVerdict(t, "NXInterception", pr.NXInterception, model.VerdictYes) - assertVerdict(t, "Rebind.V4", pr.Rebind.V4, model.VerdictUnknown) - assertVerdict(t, "Rebind.V6", pr.Rebind.V6, model.VerdictUnknown) - assertVerdict(t, "Rebind.Overall", pr.Rebind.Overall, model.VerdictUnknown) if pr.ReverseName != "resolver.test" { t.Errorf("ReverseName = %q", pr.ReverseName) } @@ -244,58 +246,6 @@ func TestInterceptorProfile(t *testing.T) { assertVerdict(t, "Extended.HTTPSRecord", pr.Extended.HTTPSRecord, model.VerdictNo) } -func TestRebindBlockingProfile(t *testing.T) { - pr := runProbe(t, profileRebindBlocking, testConfig("")) - if !pr.Reachable { - t.Fatalf("expected reachable, errors: %v", pr.Errors) - } - assertVerdict(t, "DNSSEC.BogusServfail", pr.DNSSEC.BogusServfail, model.VerdictYes) - assertVerdict(t, "DNSSEC.ReturnsRRSIG", pr.DNSSEC.ReturnsRRSIG, model.VerdictNo) - assertVerdict(t, "DNSSEC.ADOnSigned", pr.DNSSEC.ADOnSigned, model.VerdictNo) - assertVerdict(t, "DNSSEC.Validating", pr.DNSSEC.Validating, model.VerdictPartial) - for _, c := range pr.NXChecks { - if c.Behavior != model.NXNoErrorEmpty { - t.Errorf("check %s behavior = %q, want %q", c.Label, c.Behavior, model.NXNoErrorEmpty) - } - } - assertVerdict(t, "NXInterception", pr.NXInterception, model.VerdictNo) - assertVerdict(t, "Rebind.V4", pr.Rebind.V4, model.VerdictYes) - assertVerdict(t, "Rebind.V6", pr.Rebind.V6, model.VerdictYes) - assertVerdict(t, "Rebind.Overall", pr.Rebind.Overall, model.VerdictYes) - for _, d := range pr.Rebind.Details { - if !strings.Contains(d, "blocked") { - t.Errorf("detail %q should mention blocked", d) - } - } - if pr.Extended != nil { - t.Error("extended info should be nil when Extended is false") - } -} - -func TestRebindUnprotectedProfile(t *testing.T) { - pr := runProbe(t, profileRebindUnprotected, testConfig("")) - if !pr.Reachable { - t.Fatalf("expected reachable, errors: %v", pr.Errors) - } - assertVerdict(t, "DNSSEC.BogusServfail", pr.DNSSEC.BogusServfail, model.VerdictUnknown) - assertVerdict(t, "DNSSEC.BogusWithCDResolves", pr.DNSSEC.BogusWithCDResolves, model.VerdictNo) - assertVerdict(t, "DNSSEC.Validating", pr.DNSSEC.Validating, model.VerdictUnknown) - for _, c := range pr.NXChecks { - if c.Behavior != model.NXExpected { - t.Errorf("check %s behavior = %q, want %q", c.Label, c.Behavior, model.NXExpected) - } - } - assertVerdict(t, "NXInterception", pr.NXInterception, model.VerdictNo) - assertVerdict(t, "Rebind.V4", pr.Rebind.V4, model.VerdictNo) - assertVerdict(t, "Rebind.V6", pr.Rebind.V6, model.VerdictNo) - assertVerdict(t, "Rebind.Overall", pr.Rebind.Overall, model.VerdictNo) - for _, d := range pr.Rebind.Details { - if !strings.Contains(d, "no rebind protection") { - t.Errorf("detail %q should mention missing protection", d) - } - } -} - func TestUnreachableServer(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -325,7 +275,6 @@ func TestUnreachableServer(t *testing.T) { assertVerdict(t, "EDNS0", pr.EDNS0, model.VerdictUnknown) assertVerdict(t, "DNSSEC.Validating", pr.DNSSEC.Validating, model.VerdictUnknown) assertVerdict(t, "NXInterception", pr.NXInterception, model.VerdictUnknown) - assertVerdict(t, "Rebind.Overall", pr.Rebind.Overall, model.VerdictUnknown) if len(pr.NXChecks) != 0 { t.Errorf("len(NXChecks) = %d, want 0", len(pr.NXChecks)) } diff --git a/internal/probe/rebind.go b/internal/probe/rebind.go deleted file mode 100644 index 355d400..0000000 --- a/internal/probe/rebind.go +++ /dev/null @@ -1,109 +0,0 @@ -package probe - -import ( - "context" - "net/netip" - "strings" - - "dnsbench/internal/model" - - "github.com/miekg/dns" -) - -type rebindOutcome int - -const ( - rebindBlocked rebindOutcome = iota - rebindUnprotected - rebindUndetermined -) - -func (p *prober) checkRebind(ctx context.Context) { - var details []string - v4 := p.rebindFamily(ctx, p.cfg.RebindV4, dns.TypeA, "A", &details) - v6 := p.rebindFamily(ctx, p.cfg.RebindV6, dns.TypeAAAA, "AAAA", &details) - p.res.Rebind = model.RebindInfo{ - V4: v4, - V6: v6, - Overall: rebindOverall(v4, v6), - Details: details, - } -} - -func (p *prober) rebindFamily(ctx context.Context, cases []RebindCase, qtype uint16, rrType string, details *[]string) model.Verdict { - blocked := 0 - determined := 0 - for _, c := range cases { - outcome, detail := classifyRebind(p.query(ctx, c.Host, qtype, false, false), c.Expected, rrType) - *details = append(*details, c.Host+": "+detail) - if outcome != rebindUndetermined { - determined++ - } - if outcome == rebindBlocked { - blocked++ - } - } - switch { - case determined == 0: - return model.VerdictUnknown - case blocked == determined: - return model.VerdictYes - case blocked == 0: - return model.VerdictNo - } - return model.VerdictPartial -} - -func classifyRebind(r model.QueryResult, expected, rrType string) (rebindOutcome, string) { - if r.Err != nil { - if r.Err.IsTimeout() { - return rebindUndetermined, "timed out (undetermined)" - } - return rebindUndetermined, "query failed (" + string(r.Err.Kind) + ")" - } - switch r.Rcode { - case dns.RcodeNameError: - return rebindBlocked, "blocked (NXDOMAIN)" - case dns.RcodeRefused: - return rebindBlocked, "blocked (REFUSED)" - } - if answersContainIP(r, rrType, expected) { - return rebindUnprotected, "resolved to private address " + expected + " (no rebind protection)" - } - if r.Rcode == dns.RcodeSuccess { - if len(r.Answers) == 0 { - return rebindBlocked, "blocked (empty answer)" - } - return rebindUndetermined, "unexpected answer: " + answerSummary(r) - } - return rebindUndetermined, "unexpected rcode " + rcodeName(r.Rcode) -} - -func answersContainIP(r model.QueryResult, rrType, expected string) bool { - want, err := netip.ParseAddr(expected) - if err != nil { - return false - } - for _, a := range r.Answers { - if a.Type != rrType { - continue - } - got, err := netip.ParseAddr(strings.TrimSpace(a.Data)) - if err == nil && got == want { - return true - } - } - return false -} - -func rebindOverall(v4, v6 model.Verdict) model.Verdict { - switch { - case v4 == model.VerdictYes && v6 == model.VerdictYes: - return model.VerdictYes - case v4 == model.VerdictNo && v6 == model.VerdictNo: - return model.VerdictNo - case v4 == model.VerdictUnknown && v6 == model.VerdictUnknown: - return model.VerdictUnknown - } - return model.VerdictPartial -} diff --git a/internal/rank/presets.go b/internal/rank/presets.go index 2608861..c0e1056 100644 --- a/internal/rank/presets.go +++ b/internal/rank/presets.go @@ -17,7 +17,6 @@ func Presets() map[model.RankMode]model.Weights { PenaltyPerRetryPctMs: 2, PenaltyNXInterceptionMs: 5, PenaltyNoDNSSECMs: 5, - PenaltyNoRebindMs: 5, JitterWeight: 0.25, }, model.RankBrowsing: { @@ -33,7 +32,6 @@ func Presets() map[model.RankMode]model.Weights { PenaltyPerRetryPctMs: 5, PenaltyNXInterceptionMs: 15, PenaltyNoDNSSECMs: 10, - PenaltyNoRebindMs: 10, JitterWeight: 0.5, }, model.RankReliability: { @@ -49,7 +47,6 @@ func Presets() map[model.RankMode]model.Weights { PenaltyPerRetryPctMs: 10, PenaltyNXInterceptionMs: 25, PenaltyNoDNSSECMs: 20, - PenaltyNoRebindMs: 20, JitterWeight: 1.0, }, } diff --git a/internal/rank/rank.go b/internal/rank/rank.go index baf9536..809acb8 100644 --- a/internal/rank/rank.go +++ b/internal/rank/rank.go @@ -16,7 +16,6 @@ const ( penaltyJitter = "jitter" penaltyNXInterception = "nxdomain-interception" penaltyNoDNSSEC = "no-dnssec" - penaltyNoRebind = "no-rebind-protection" ) func ScoreServers(stats map[string]*model.ServerStats, probes map[string]*model.ProbeResult, categories []model.Category, w model.Weights, mode model.RankMode) []model.Score { @@ -132,12 +131,6 @@ func computePenalties(st *model.ServerStats, cats []model.Category, probe *model if probe.DNSSEC.Validating != model.VerdictYes { addPenalty(pens, penaltyNoDNSSEC, w.PenaltyNoDNSSECMs) } - switch probe.Rebind.Overall { - case model.VerdictNo: - addPenalty(pens, penaltyNoRebind, w.PenaltyNoRebindMs) - case model.VerdictPartial: - addPenalty(pens, penaltyNoRebind, w.PenaltyNoRebindMs/2) - } } if len(pens) == 0 { return nil diff --git a/internal/rank/rank_test.go b/internal/rank/rank_test.go index 3837d40..ac5b612 100644 --- a/internal/rank/rank_test.go +++ b/internal/rank/rank_test.go @@ -33,7 +33,6 @@ func cleanProbe(id string) *model.ProbeResult { Reachable: true, NXInterception: model.VerdictNo, DNSSEC: model.DNSSECInfo{Validating: model.VerdictYes}, - Rebind: model.RebindInfo{Overall: model.VerdictYes}, } } @@ -274,48 +273,6 @@ func TestNoDNSSECPenaltyExact(t *testing.T) { } } -func TestNoRebindPenaltyExact(t *testing.T) { - w := singleCatWeights("median") - w.PenaltyNoRebindMs = 10 - stats := map[string]*model.ServerStats{ - "s1": activeStats("s1", map[model.Category]*model.Distribution{ - model.CatCached: {Count: 10, Answered: 10, MedianMs: 10}, - }), - } - probe := cleanProbe("s1") - probe.Rebind.Overall = model.VerdictNo - probes := map[string]*model.ProbeResult{"s1": probe} - scores := ScoreServers(stats, probes, []model.Category{model.CatCached}, w, model.RankBrowsing) - s := findScore(t, scores, "s1") - if !almostEqual(s.Penalties["no-rebind-protection"], 10) { - t.Errorf("expected no-rebind-protection penalty 10, got %v", s.Penalties["no-rebind-protection"]) - } - if !almostEqual(s.TotalMs, 20) { - t.Errorf("expected total 20, got %v", s.TotalMs) - } -} - -func TestPartialRebindIsHalf(t *testing.T) { - w := singleCatWeights("median") - w.PenaltyNoRebindMs = 10 - stats := map[string]*model.ServerStats{ - "s1": activeStats("s1", map[model.Category]*model.Distribution{ - model.CatCached: {Count: 10, Answered: 10, MedianMs: 10}, - }), - } - probe := cleanProbe("s1") - probe.Rebind.Overall = model.VerdictPartial - probes := map[string]*model.ProbeResult{"s1": probe} - scores := ScoreServers(stats, probes, []model.Category{model.CatCached}, w, model.RankBrowsing) - s := findScore(t, scores, "s1") - if !almostEqual(s.Penalties["no-rebind-protection"], 5) { - t.Errorf("expected no-rebind-protection penalty 5, got %v", s.Penalties["no-rebind-protection"]) - } - if !almostEqual(s.TotalMs, 15) { - t.Errorf("expected total 15, got %v", s.TotalMs) - } -} - func TestZeroPenaltiesStayOutOfMap(t *testing.T) { w := singleCatWeights("median") w.PenaltyPerLossPctMs = 5 diff --git a/internal/report/export_html.go b/internal/report/export_html.go index d237c4c..6108bbb 100644 --- a/internal/report/export_html.go +++ b/internal/report/export_html.go @@ -394,7 +394,7 @@ func presentHTMLPenaltyKeys(scores []model.Score) []string { } } var keys []string - for _, k := range []string{"loss", "servfail", "invalid-response", "retry", "jitter", "nxdomain-interception", "no-dnssec", "no-rebind-protection"} { + for _, k := range []string{"loss", "servfail", "invalid-response", "retry", "jitter", "nxdomain-interception", "no-dnssec"} { if present[k] { keys = append(keys, k) delete(present, k) @@ -432,7 +432,7 @@ func statusDotCell(v model.Verdict, goodWhenYes bool, yesLabel, noLabel string) func writeHTMLCharacteristics(b *strings.Builder, res *model.RunResult) { b.WriteString("
\n

Server characteristics

\n") - b.WriteString("
\n\n\n") + b.WriteString("
resolverprotocolstatusdnssec validationnxdomain honestyrebind protection
\n\n\n") for i := range res.Servers { s := &res.Servers[i] state := stateOf(res, s.ID) @@ -446,14 +446,13 @@ func writeHTMLCharacteristics(b *strings.Builder, res *model.RunResult) { stateCell = "" + esc(state.Label()) } p := res.Probes[s.ID] - dnssec, nx, rebind := "not probed", "not probed", "not probed" + dnssec, nx := "not probed", "not probed" if p != nil { dnssec = statusDotCell(p.DNSSEC.Validating, true, "validating", "not validating") nx = statusDotCell(p.NXInterception, false, "intercepts", "honest") - rebind = statusDotCell(p.Rebind.Overall, true, "protected", "unprotected") } - fmt.Fprintf(b, "\n", - esc(s.DisplayName()), esc(s.Protocol.Label()), stateCell, dnssec, nx, rebind) + fmt.Fprintf(b, "\n", + esc(s.DisplayName()), esc(s.Protocol.Label()), stateCell, dnssec, nx) } b.WriteString("
resolverprotocolstatusdnssec validationnxdomain honesty
%s%s%s%s%s%s
%s%s%s%s%s
\n
\n") } diff --git a/internal/report/export_text.go b/internal/report/export_text.go index bb6eb98..8114ac2 100644 --- a/internal/report/export_text.go +++ b/internal/report/export_text.go @@ -65,11 +65,11 @@ func weightsLine(wts model.Weights) string { if metric == "" { metric = "-" } - return fmt.Sprintf("Weights: categories %s; latency metric %s; penalties in ms: per loss pct %.1f, per SERVFAIL pct %.1f, per invalid-response pct %.1f, per retry pct %.1f, NX interception %.1f, no DNSSEC %.1f, no rebind protection %.1f; jitter weight %.2f\n", + return fmt.Sprintf("Weights: categories %s; latency metric %s; penalties in ms: per loss pct %.1f, per SERVFAIL pct %.1f, per invalid-response pct %.1f, per retry pct %.1f, NX interception %.1f, no DNSSEC %.1f; jitter weight %.2f\n", catPart, metric, wts.PenaltyPerLossPctMs, wts.PenaltyPerServfailPctMs, wts.PenaltyPerInvalidPctMs, wts.PenaltyPerRetryPctMs, wts.PenaltyNXInterceptionMs, - wts.PenaltyNoDNSSECMs, wts.PenaltyNoRebindMs, wts.JitterWeight) + wts.PenaltyNoDNSSECMs, wts.JitterWeight) } func summaryTable(res *model.RunResult, sel model.RankMode) string { diff --git a/internal/report/report_test.go b/internal/report/report_test.go index 34a7651..e229ba7 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -79,21 +79,18 @@ func testResult() *model.RunResult { Reachable: true, NXInterception: model.VerdictNo, DNSSEC: model.DNSSECInfo{Validating: model.VerdictNo}, - Rebind: model.RebindInfo{Overall: model.VerdictNo}, }, "steady": { ServerID: "steady", Reachable: true, NXInterception: model.VerdictNo, DNSSEC: model.DNSSECInfo{Validating: model.VerdictYes}, - Rebind: model.RebindInfo{Overall: model.VerdictYes}, }, "speedy": { ServerID: "speedy", Reachable: true, NXInterception: model.VerdictYes, DNSSEC: model.DNSSECInfo{Validating: model.VerdictYes}, - Rebind: model.RebindInfo{Overall: model.VerdictYes}, NXChecks: []model.NXCheck{ {Label: "random subdomain", QName: "x.example.invalid", Behavior: model.NXInterceptedIP, Detail: "returned 203.0.113.80"}, }, @@ -135,7 +132,6 @@ func testResult() *model.RunResult { PenaltyPerServfailPctMs: 3.0, PenaltyNXInterceptionMs: 10.0, PenaltyNoDNSSECMs: 5.0, - PenaltyNoRebindMs: 5.0, JitterWeight: 0.2, } } diff --git a/internal/serverlist/builtin_servers.json b/internal/serverlist/builtin_servers.json index 5e77c91..1eaae34 100644 --- a/internal/serverlist/builtin_servers.json +++ b/internal/serverlist/builtin_servers.json @@ -54,12 +54,24 @@ "id": "google-doh", "name": "Google (DoH)", "operator": "Google Public DNS", + "address": "8.8.8.8", "port": 443, "doh_url": "https://dns.google/dns-query", "protocol": "doh", "source": "builtin", "enabled": true }, + { + "id": "google-doh3", + "name": "Google (DoH/3)", + "operator": "Google Public DNS", + "address": "8.8.8.8", + "port": 443, + "doh_url": "https://dns.google/dns-query", + "protocol": "doh3", + "source": "builtin", + "enabled": true + }, { "id": "cloudflare-udp4-1111", "name": "Cloudflare (1.1.1.1)", @@ -115,12 +127,24 @@ "id": "cloudflare-doh", "name": "Cloudflare (DoH)", "operator": "Cloudflare", + "address": "1.1.1.1", "port": 443, "doh_url": "https://cloudflare-dns.com/dns-query", "protocol": "doh", "source": "builtin", "enabled": true }, + { + "id": "cloudflare-doh3", + "name": "Cloudflare (DoH/3)", + "operator": "Cloudflare", + "address": "1.1.1.1", + "port": 443, + "doh_url": "https://cloudflare-dns.com/dns-query", + "protocol": "doh3", + "source": "builtin", + "enabled": true + }, { "id": "quad9-udp4-9999", "name": "Quad9 (9.9.9.9)", @@ -181,6 +205,7 @@ "id": "quad9-doh", "name": "Quad9 (DoH)", "operator": "Quad9", + "address": "9.9.9.9", "port": 443, "doh_url": "https://dns.quad9.net/dns-query", "protocol": "doh", @@ -188,6 +213,30 @@ "source": "builtin", "enabled": true }, + { + "id": "quad9-doh3", + "name": "Quad9 (DoH/3)", + "operator": "Quad9", + "address": "9.9.9.9", + "port": 443, + "doh_url": "https://dns.quad9.net/dns-query", + "protocol": "doh3", + "notes": "Blocks malicious domains", + "source": "builtin", + "enabled": true + }, + { + "id": "quad9-doq", + "name": "Quad9 (DoQ dns.quad9.net)", + "operator": "Quad9", + "address": "9.9.9.9", + "port": 853, + "tls_hostname": "dns.quad9.net", + "protocol": "doq", + "notes": "Blocks malicious domains", + "source": "builtin", + "enabled": true + }, { "id": "opendns-udp4-222", "name": "OpenDNS (208.67.222.222)", @@ -243,6 +292,7 @@ "id": "opendns-doh", "name": "OpenDNS (DoH)", "operator": "OpenDNS", + "address": "146.112.41.2", "port": 443, "doh_url": "https://doh.opendns.com/dns-query", "protocol": "doh", @@ -287,6 +337,7 @@ "id": "adguard-doh", "name": "AdGuard DNS (DoH)", "operator": "AdGuard DNS", + "address": "94.140.14.14", "port": 443, "doh_url": "https://dns.adguard-dns.com/dns-query", "protocol": "doh", @@ -294,6 +345,30 @@ "source": "builtin", "enabled": true }, + { + "id": "adguard-doh3", + "name": "AdGuard DNS (DoH/3)", + "operator": "AdGuard DNS", + "address": "94.140.14.14", + "port": 443, + "doh_url": "https://dns.adguard-dns.com/dns-query", + "protocol": "doh3", + "notes": "Blocks ads and trackers", + "source": "builtin", + "enabled": true + }, + { + "id": "adguard-doq", + "name": "AdGuard DNS (DoQ dns.adguard-dns.com)", + "operator": "AdGuard DNS", + "address": "94.140.14.14", + "port": 853, + "tls_hostname": "dns.adguard-dns.com", + "protocol": "doq", + "notes": "Blocks ads and trackers", + "source": "builtin", + "enabled": true + }, { "id": "cleanbrowsing-udp4-168", "name": "CleanBrowsing Security (185.228.168.9)", @@ -354,6 +429,7 @@ "id": "cleanbrowsing-doh", "name": "CleanBrowsing Security (DoH)", "operator": "CleanBrowsing", + "address": "185.228.168.10", "port": 443, "doh_url": "https://doh.cleanbrowsing.org/doh/security-filter/", "protocol": "doh", @@ -409,6 +485,7 @@ "id": "controld-doh", "name": "Control D p0 (DoH)", "operator": "Control D", + "address": "76.76.2.11", "port": 443, "doh_url": "https://freedns.controld.com/p0", "protocol": "doh", @@ -417,82 +494,50 @@ "enabled": true }, { - "id": "ultradns-udp4-64646", - "name": "UltraDNS Public (64.6.64.6)", - "operator": "DigiCert UltraDNS", - "address": "64.6.64.6", - "port": 53, - "protocol": "udp", - "source": "builtin", - "enabled": true - }, - { - "id": "ultradns-udp4-64656", - "name": "UltraDNS Public (64.6.65.6)", - "operator": "DigiCert UltraDNS", - "address": "64.6.65.6", - "port": 53, - "protocol": "udp", - "source": "builtin", - "enabled": true - }, - { - "id": "ultradns-udp6-1b", - "name": "UltraDNS Public (v6 1b::1:1)", - "operator": "DigiCert UltraDNS", - "address": "2620:74:1b::1:1", - "port": 53, - "protocol": "udp", - "source": "builtin", - "enabled": true - }, - { - "id": "ultradns-udp6-1c", - "name": "UltraDNS Public (v6 1c::2:2)", - "operator": "DigiCert UltraDNS", - "address": "2620:74:1c::2:2", - "port": 53, - "protocol": "udp", - "source": "builtin", - "enabled": true - }, - { - "id": "gcore-udp4-9585", - "name": "Gcore (95.85.95.85)", - "operator": "Gcore Public DNS", - "address": "95.85.95.85", - "port": 53, - "protocol": "udp", + "id": "controld-doh3", + "name": "Control D p0 (DoH/3)", + "operator": "Control D", + "address": "76.76.2.11", + "port": 443, + "doh_url": "https://freedns.controld.com/p0", + "protocol": "doh3", + "notes": "Blocks malware (p0 profile)", "source": "builtin", "enabled": true }, { - "id": "gcore-udp4-2562", - "name": "Gcore (2.56.220.2)", - "operator": "Gcore Public DNS", - "address": "2.56.220.2", - "port": 53, - "protocol": "udp", + "id": "controld-doq", + "name": "Control D p0 (DoQ)", + "operator": "Control D", + "address": "76.76.2.11", + "port": 853, + "tls_hostname": "p0.freedns.controld.com", + "protocol": "doq", + "notes": "Blocks malware (p0 profile)", "source": "builtin", "enabled": true }, { - "id": "gcore-udp6-999d", - "name": "Gcore (v6 999d::1)", - "operator": "Gcore Public DNS", - "address": "2a03:90c0:999d::1", - "port": 53, - "protocol": "udp", + "id": "mullvad-dot", + "name": "Mullvad DNS (DoT dns.mullvad.net)", + "operator": "Mullvad DNS", + "address": "194.242.2.2", + "port": 853, + "tls_hostname": "dns.mullvad.net", + "protocol": "dot", + "notes": "Unfiltered public encrypted DNS", "source": "builtin", "enabled": true }, { - "id": "gcore-udp6-9992", - "name": "Gcore (v6 9992::1)", - "operator": "Gcore Public DNS", - "address": "2a03:90c0:9992::1", - "port": 53, - "protocol": "udp", + "id": "mullvad-doh", + "name": "Mullvad DNS (DoH)", + "operator": "Mullvad DNS", + "address": "194.242.2.2", + "port": 443, + "doh_url": "https://dns.mullvad.net/dns-query", + "protocol": "doh", + "notes": "Unfiltered public encrypted DNS", "source": "builtin", "enabled": true }, @@ -556,6 +601,7 @@ "id": "nextdns-doh", "name": "NextDNS (DoH)", "operator": "NextDNS", + "address": "45.90.28.0", "port": 443, "doh_url": "https://dns.nextdns.io", "protocol": "doh", @@ -564,24 +610,26 @@ "enabled": true }, { - "id": "comodo-udp4-826", - "name": "Comodo Secure DNS (8.26.56.26)", - "operator": "Comodo Secure DNS", - "address": "8.26.56.26", - "port": 53, - "protocol": "udp", - "notes": "Security-focused resolver; may redirect NXDOMAIN responses", + "id": "nextdns-doh3", + "name": "NextDNS (DoH/3)", + "operator": "NextDNS", + "address": "45.90.28.0", + "port": 443, + "doh_url": "https://dns.nextdns.io", + "protocol": "doh3", + "notes": "Unfiltered without a configuration ID", "source": "builtin", "enabled": true }, { - "id": "comodo-udp4-820", - "name": "Comodo Secure DNS (8.20.247.20)", - "operator": "Comodo Secure DNS", - "address": "8.20.247.20", - "port": 53, - "protocol": "udp", - "notes": "Security-focused resolver; may redirect NXDOMAIN responses", + "id": "nextdns-doq", + "name": "NextDNS (DoQ dns.nextdns.io)", + "operator": "NextDNS", + "address": "45.90.28.0", + "port": 853, + "tls_hostname": "dns.nextdns.io", + "protocol": "doq", + "notes": "Unfiltered without a configuration ID", "source": "builtin", "enabled": true } diff --git a/internal/serverlist/builtin_test.go b/internal/serverlist/builtin_test.go index 97737e7..77019d0 100644 --- a/internal/serverlist/builtin_test.go +++ b/internal/serverlist/builtin_test.go @@ -32,8 +32,7 @@ func TestBuiltinLoads(t *testing.T) { func TestBuiltinCoversExpectedOperators(t *testing.T) { want := []string{ "Google Public DNS", "Cloudflare", "Quad9", "OpenDNS", "AdGuard DNS", - "CleanBrowsing", "Control D", "DigiCert UltraDNS", - "Gcore Public DNS", "NextDNS", "Comodo Secure DNS", + "CleanBrowsing", "Control D", "Mullvad DNS", "NextDNS", } seen := make(map[string]bool) for _, s := range Builtin() { @@ -49,8 +48,10 @@ func TestBuiltinCoversExpectedOperators(t *testing.T) { func TestBuiltinExcludesRemovedOperators(t *testing.T) { removed := map[string]bool{ "Alibaba Cloud Public DNS": true, + "Comodo Secure DNS": true, + "DigiCert UltraDNS": true, "DNS.SB": true, - "Mullvad": true, + "Gcore Public DNS": true, } for _, s := range Builtin() { if removed[s.Operator] { @@ -59,6 +60,26 @@ func TestBuiltinExcludesRemovedOperators(t *testing.T) { } } +func TestBuiltinMullvadProtocols(t *testing.T) { + got := make(map[model.Protocol]int) + for _, s := range Builtin() { + if s.Operator == "Mullvad DNS" { + got[s.Protocol]++ + } + } + + want := []model.Protocol{model.ProtoDoT, model.ProtoDoH} + for _, protocol := range want { + if got[protocol] != 1 { + t.Errorf("Mullvad %s endpoints = %d, want 1", protocol.Label(), got[protocol]) + } + delete(got, protocol) + } + for protocol, count := range got { + t.Errorf("Mullvad has %d unexpected %s endpoint(s)", count, protocol.Label()) + } +} + func TestBuiltinExcludesKnownUnreachableEndpoints(t *testing.T) { excluded := map[string]bool{ "adguard-udp6-ad1": true, @@ -81,20 +102,34 @@ func TestBuiltinExcludesKnownUnreachableEndpoints(t *testing.T) { func TestBuiltinAddressesParse(t *testing.T) { for _, s := range Builtin() { switch s.Protocol { - case model.ProtoUDP, model.ProtoTCP, model.ProtoDoT: + case model.ProtoUDP, model.ProtoTCP, model.ProtoDoT, model.ProtoDoQ: if _, err := netip.ParseAddr(s.Address); err != nil { t.Errorf("server %q has invalid address %q: %v", s.ID, s.Address, err) } } - if s.Protocol == model.ProtoDoT && s.TLSHostname == "" { - t.Errorf("DoT server %q has no TLS hostname", s.ID) + switch s.Protocol { + case model.ProtoDoT, model.ProtoDoQ: + if s.TLSHostname == "" { + t.Errorf("%s server %q has no TLS hostname", s.Protocol.Label(), s.ID) + } + } + } +} + +func TestBuiltinEncryptedServersArePinned(t *testing.T) { + for _, s := range Builtin() { + if !s.Protocol.Encrypted() { + continue + } + if !s.BootstrapPinned() { + t.Errorf("%s server %q has no pinned bootstrap address", s.Protocol.Label(), s.ID) } } } func TestBuiltinDoHURLsParse(t *testing.T) { for _, s := range Builtin() { - if s.Protocol != model.ProtoDoH { + if !s.Protocol.UsesURL() { continue } u, err := url.Parse(s.DoHURL) diff --git a/internal/serverlist/codec.go b/internal/serverlist/codec.go index 296e4f1..4d8a46d 100644 --- a/internal/serverlist/codec.go +++ b/internal/serverlist/codec.go @@ -161,16 +161,26 @@ func EncodeText(servers []model.Server) ([]byte, error) { func encodeTextLine(s model.Server) (string, error) { var endpoint string switch s.Protocol { - case model.ProtoDoH: + case model.ProtoDoH, model.ProtoDoH3: if s.DoHURL == "" { - return "", fmt.Errorf("cannot encode DoH server %q as text: missing URL", s.DisplayName()) + return "", fmt.Errorf("cannot encode %s server %q as text: missing URL", s.Protocol.Label(), s.DisplayName()) } endpoint = s.DoHURL - case model.ProtoDoT: + if s.Protocol == model.ProtoDoH3 { + endpoint = "h3://" + strings.TrimPrefix(endpoint, "https://") + } + if s.Address != "" { + endpoint += "@" + formatTextAddr(s.Address, s.Port, s.Protocol) + } + case model.ProtoDoT, model.ProtoDoQ: if s.TLSHostname == "" { - return "", fmt.Errorf("cannot encode DoT server %q as text: missing TLS hostname", s.DisplayName()) + return "", fmt.Errorf("cannot encode %s server %q as text: missing TLS hostname", s.Protocol.Label(), s.DisplayName()) } - endpoint = "tls://" + s.TLSHostname + "@" + formatTextAddr(s.Address, s.Port, s.Protocol) + scheme := "tls://" + if s.Protocol == model.ProtoDoQ { + scheme = "quic://" + } + endpoint = scheme + s.TLSHostname + "@" + formatTextAddr(s.Address, s.Port, s.Protocol) case model.ProtoUDP: endpoint = formatTextAddr(s.Address, s.Port, s.Protocol) default: @@ -235,23 +245,37 @@ func parseTextLine(line string) (model.Server, error) { func parseTextEndpoint(endpoint string) (model.Server, error) { switch { - case strings.HasPrefix(endpoint, "https://"): - u, err := url.Parse(endpoint) + case strings.HasPrefix(endpoint, "https://"), strings.HasPrefix(endpoint, "h3://"): + proto := model.ProtoDoH + rawURL := endpoint + if strings.HasPrefix(endpoint, "h3://") { + proto = model.ProtoDoH3 + rawURL = "https://" + strings.TrimPrefix(endpoint, "h3://") + } + rawURL, addr, port, err := splitBootstrap(rawURL) + if err != nil { + return model.Server{}, err + } + u, err := url.Parse(rawURL) if err != nil || u.Host == "" { - return model.Server{}, fmt.Errorf("invalid DoH URL %q", endpoint) + return model.Server{}, fmt.Errorf("invalid %s URL %q", proto.Label(), endpoint) + } + return model.Server{Protocol: proto, DoHURL: rawURL, Address: addr, Port: port}, nil + case strings.HasPrefix(endpoint, "tls://"), strings.HasPrefix(endpoint, "quic://"): + proto, scheme := model.ProtoDoT, "tls://" + if strings.HasPrefix(endpoint, "quic://") { + proto, scheme = model.ProtoDoQ, "quic://" } - return model.Server{Protocol: model.ProtoDoH, DoHURL: endpoint}, nil - case strings.HasPrefix(endpoint, "tls://"): - rest := strings.TrimPrefix(endpoint, "tls://") + rest := strings.TrimPrefix(endpoint, scheme) hostname, addrPart, found := strings.Cut(rest, "@") if !found || hostname == "" || addrPart == "" { - return model.Server{}, fmt.Errorf("invalid DoT entry %q: expected tls://hostname@address", endpoint) + return model.Server{}, fmt.Errorf("invalid %s entry %q: expected %shostname@address", proto.Label(), endpoint, scheme) } addr, port, err := parseTextAddr(addrPart) if err != nil { return model.Server{}, err } - return model.Server{Protocol: model.ProtoDoT, Address: addr, Port: port, TLSHostname: hostname}, nil + return model.Server{Protocol: proto, Address: addr, Port: port, TLSHostname: hostname}, nil default: addr, port, err := parseTextAddr(endpoint) if err != nil { @@ -261,6 +285,23 @@ func parseTextEndpoint(endpoint string) (model.Server, error) { } } +// splitBootstrap preserves URL userinfo unless the final @ suffix is an IP address. +func splitBootstrap(rawURL string) (string, string, int, error) { + idx := strings.LastIndex(rawURL, "@") + if idx < 0 { + return rawURL, "", 0, nil + } + head, tail := rawURL[:idx], rawURL[idx+1:] + addr, port, err := parseTextAddr(tail) + if err != nil { + return rawURL, "", 0, nil + } + if head == "" { + return "", "", 0, fmt.Errorf("invalid entry %q: missing URL before the bootstrap address", rawURL) + } + return head, addr, port, nil +} + func parseTextAddr(s string) (string, int, error) { if strings.HasPrefix(s, "[") { closing := strings.Index(s, "]") diff --git a/internal/serverlist/codec_test.go b/internal/serverlist/codec_test.go index 352e6c7..bd49935 100644 --- a/internal/serverlist/codec_test.go +++ b/internal/serverlist/codec_test.go @@ -159,6 +159,13 @@ func TestTextRoundtrip(t *testing.T) { {Protocol: model.ProtoDoH, DoHURL: "https://dns.example.com/dns-query", Enabled: true}, {Protocol: model.ProtoUDP, Address: "1.1.1.1", Name: "Cloudflare Primary", Operator: "Cloudflare", Enabled: true}, {Protocol: model.ProtoDoH, DoHURL: "https://dns.google/dns-query", Name: "Google DoH", Operator: "Google Public DNS", Enabled: true}, + {Protocol: model.ProtoDoH, DoHURL: "https://dns.google/dns-query", Address: "8.8.8.8", Enabled: true}, + {Protocol: model.ProtoDoH, DoHURL: "https://dns.example.com/dns-query", Address: "192.0.2.1", Port: 8443, Enabled: true}, + {Protocol: model.ProtoDoH, DoHURL: "https://dns.example.com/dns-query", Address: "2620:fe::fe", Enabled: true}, + {Protocol: model.ProtoDoH3, DoHURL: "https://dns.quad9.net/dns-query", Address: "9.9.9.9", Enabled: true}, + {Protocol: model.ProtoDoH3, DoHURL: "https://dns.example.com/dns-query", Name: "Example DoH3", Enabled: true}, + {Protocol: model.ProtoDoQ, Address: "94.140.14.14", TLSHostname: "dns.adguard-dns.com", Enabled: true}, + {Protocol: model.ProtoDoQ, Address: "2620:fe::fe", Port: 8853, TLSHostname: "dns.quad9.net", Enabled: true}, } data, err := EncodeText(servers) if err != nil { @@ -217,6 +224,36 @@ func TestDecodeTextGrammar(t *testing.T) { } } +func TestDecodeTextEncryptedGrammar(t *testing.T) { + input := "h3://dns.quad9.net/dns-query | Quad9 DoH3 | Quad9\n" + + "https://dns.google/dns-query@8.8.8.8\n" + + "quic://dns.adguard-dns.com@94.140.14.14 | AdGuard DoQ\n" + + "https://user@dns.example.com/dns-query\n" + servers, err := DecodeText([]byte(input)) + if err != nil { + t.Fatal(err) + } + if len(servers) != 4 { + t.Fatalf("got %d servers, want 4: %+v", len(servers), servers) + } + h3 := servers[0] + if h3.Protocol != model.ProtoDoH3 || h3.DoHURL != "https://dns.quad9.net/dns-query" || h3.Operator != "Quad9" { + t.Errorf("h3:// entry parsed as %+v", h3) + } + pinned := servers[1] + if pinned.Protocol != model.ProtoDoH || pinned.DoHURL != "https://dns.google/dns-query" || pinned.Address != "8.8.8.8" { + t.Errorf("pinned DoH entry parsed as %+v", pinned) + } + doq := servers[2] + if doq.Protocol != model.ProtoDoQ || doq.TLSHostname != "dns.adguard-dns.com" || doq.Address != "94.140.14.14" { + t.Errorf("quic:// entry parsed as %+v", doq) + } + userinfo := servers[3] + if userinfo.DoHURL != "https://user@dns.example.com/dns-query" || userinfo.Address != "" { + t.Errorf("URL with userinfo parsed as %+v", userinfo) + } +} + func TestDecodeTextErrors(t *testing.T) { cases := []string{ "banana", @@ -228,6 +265,11 @@ func TestDecodeTextErrors(t *testing.T) { "tls://dns.example.com@", "tls://dns.example.com@not-an-ip", "https://", + "h3://", + "quic://@9.9.9.9", + "quic://dns.example.com@", + "quic://dns.example.com@not-an-ip", + "https://@8.8.8.8", } for _, line := range cases { if _, err := DecodeText([]byte(line + "\n")); err == nil { diff --git a/internal/serverlist/registry.go b/internal/serverlist/registry.go index 3691d56..1ffdefa 100644 --- a/internal/serverlist/registry.go +++ b/internal/serverlist/registry.go @@ -71,17 +71,22 @@ func containsFold(list []string, v string) bool { func ValidateAndPrepare(s *model.Server) error { switch s.Protocol { - case model.ProtoUDP, model.ProtoTCP, model.ProtoDoT: + case model.ProtoUDP, model.ProtoTCP, model.ProtoDoT, model.ProtoDoQ: if _, err := netip.ParseAddr(s.Address); err != nil { return fmt.Errorf("%s server requires a valid IP address, got %q", s.Protocol.Label(), s.Address) } - case model.ProtoDoH: + case model.ProtoDoH, model.ProtoDoH3: u, err := url.Parse(s.DoHURL) if err != nil || u.Scheme != "https" || u.Host == "" { - return fmt.Errorf("DoH server requires a valid https URL, got %q", s.DoHURL) + return fmt.Errorf("%s server requires a valid https URL, got %q", s.Protocol.Label(), s.DoHURL) + } + if s.Address != "" { + if _, err := netip.ParseAddr(s.Address); err != nil { + return fmt.Errorf("%s server bootstrap address must be an IP, got %q", s.Protocol.Label(), s.Address) + } } default: - return fmt.Errorf("unknown protocol %q (expected udp, tcp, dot or doh)", string(s.Protocol)) + return fmt.Errorf("unknown protocol %q (expected udp, tcp, dot, doh, doh3 or doq)", string(s.Protocol)) } if s.Port < 0 || s.Port > 65535 { return fmt.Errorf("port %d is out of range 0-65535", s.Port) @@ -96,7 +101,7 @@ func ValidateAndPrepare(s *model.Server) error { func generateID(s model.Server) string { base := s.Name if base == "" { - if s.Protocol == model.ProtoDoH { + if s.Protocol.UsesURL() { base = strings.TrimPrefix(s.DoHURL, "https://") } else { base = s.Address diff --git a/internal/transport/doh.go b/internal/transport/doh.go index e89a483..007b966 100644 --- a/internal/transport/doh.go +++ b/internal/transport/doh.go @@ -22,9 +22,11 @@ import ( const dohContentType = "application/dns-message" type dohQuerier struct { + proto model.Protocol url string opts Options client *http.Client + closer func() error } func newDoHQuerier(s model.Server, o Options) *dohQuerier { @@ -38,31 +40,44 @@ func newDoHQuerier(s model.Server, o Options) *dohQuerier { DisableKeepAlives: !o.Persistent, } if s.Address != "" { - targetHost := s.Address - portOverride := s.Port + pinned := pinnedHostPort(s) var d net.Dialer tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - _, port, err := net.SplitHostPort(addr) - if err != nil { - port = strconv.Itoa(model.ProtoDoH.DefaultPort()) - } - if portOverride != 0 { - port = strconv.Itoa(portOverride) - } - return d.DialContext(ctx, network, net.JoinHostPort(targetHost, port)) + return d.DialContext(ctx, network, pinned(addr)) } } return &dohQuerier{ + proto: model.ProtoDoH, url: s.DoHURL, opts: o, client: &http.Client{Transport: tr}, } } -func (d *dohQuerier) Protocol() model.Protocol { return model.ProtoDoH } +// pinnedHostPort redirects dialing without changing the URL host used for SNI. +func pinnedHostPort(s model.Server) func(addr string) string { + host := s.Address + override := s.Port + fallback := strconv.Itoa(s.Protocol.DefaultPort()) + return func(addr string) string { + port := fallback + if _, p, err := net.SplitHostPort(addr); err == nil { + port = p + } + if override != 0 { + port = strconv.Itoa(override) + } + return net.JoinHostPort(host, port) + } +} + +func (d *dohQuerier) Protocol() model.Protocol { return d.proto } func (d *dohQuerier) Close() error { d.client.CloseIdleConnections() + if d.closer != nil { + return d.closer() + } return nil } @@ -92,7 +107,8 @@ func (d *dohQuerier) Query(ctx context.Context, q Question) model.QueryResult { }, ConnectDone: func(string, string, error) { mu.Lock() - if !connectStart.IsZero() { + // HTTP/3 fires both hooks for one QUIC handshake; attribute it only to TLS. + if !connectStart.IsZero() && !d.proto.OverQUIC() { ph.Connect = time.Since(connectStart) } mu.Unlock() @@ -136,7 +152,7 @@ func (d *dohQuerier) Query(ctx context.Context, q Question) model.QueryResult { mu.Lock() phases := ph mu.Unlock() - return errorResult(start, phases, classify(ctx, err)) + return errorResult(start, phases, d.classify(ctx, err)) } data, readErr := io.ReadAll(resp.Body) resp.Body.Close() @@ -149,7 +165,7 @@ func (d *dohQuerier) Query(ctx context.Context, q Question) model.QueryResult { mu.Unlock() d.dropIdle() if readErr != nil { - return errorResult(start, phases, classify(ctx, readErr)) + return errorResult(start, phases, d.classify(ctx, readErr)) } if resp.StatusCode != http.StatusOK { return errorResult(start, phases, &model.QueryError{Kind: model.ErrHTTP, Msg: fmt.Sprintf("unexpected HTTP status %s", resp.Status)}) @@ -168,6 +184,13 @@ func (d *dohQuerier) Query(ctx context.Context, q Question) model.QueryResult { return res } +func (d *dohQuerier) classify(ctx context.Context, err error) *model.QueryError { + if d.proto.OverQUIC() { + return classifyQUIC(ctx, err) + } + return classify(ctx, err) +} + func (d *dohQuerier) dropIdle() { if !d.opts.Persistent { d.client.CloseIdleConnections() diff --git a/internal/transport/doh3.go b/internal/transport/doh3.go new file mode 100644 index 0000000..c609b5c --- /dev/null +++ b/internal/transport/doh3.go @@ -0,0 +1,50 @@ +package transport + +import ( + "context" + "crypto/tls" + "net" + "net/http" + + "dnsbench/internal/model" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" +) + +func newDoH3Querier(s model.Server, o Options) *dohQuerier { + var tlsCfg *tls.Config + if o.TLSConfig != nil { + tlsCfg = o.TLSConfig.Clone() + } + tr := &http3.Transport{ + TLSClientConfig: tlsCfg, + QUICConfig: &quic.Config{}, + } + closer := tr.Close + if s.Address != "" { + dialer := &quicDialer{} + pinned := pinnedHostPort(s) + tr.Dial = func(ctx context.Context, addr string, cfg *tls.Config, qcfg *quic.Config) (*quic.Conn, error) { + udpAddr, err := net.ResolveUDPAddr("udp", pinned(addr)) + if err != nil { + return nil, err + } + return dialer.dialTraced(ctx, udpAddr, cfg, qcfg) + } + closer = func() error { + err := tr.Close() + if cerr := dialer.close(); err == nil { + err = cerr + } + return err + } + } + return &dohQuerier{ + proto: model.ProtoDoH3, + url: s.DoHURL, + opts: o, + client: &http.Client{Transport: tr}, + closer: closer, + } +} diff --git a/internal/transport/doq.go b/internal/transport/doq.go new file mode 100644 index 0000000..b365de2 --- /dev/null +++ b/internal/transport/doq.go @@ -0,0 +1,314 @@ +package transport + +import ( + "context" + "crypto/tls" + "errors" + "io" + "net" + "sync" + "time" + + "dnsbench/internal/model" + + "github.com/miekg/dns" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" +) + +const doqALPN = "doq" + +// RFC 9250 §4.3 error codes. +const ( + doqNoError quic.ApplicationErrorCode = 0x0 + doqInternalError quic.ApplicationErrorCode = 0x1 + doqProtocolError quic.ApplicationErrorCode = 0x2 + doqRequestCancelled quic.ApplicationErrorCode = 0x3 +) + +const doqPaddingBlockSize = 128 + +type doqQuerier struct { + opts Options + addr *net.UDPAddr + tlsCfg *tls.Config + dialer *quicDialer + mu sync.Mutex + conn *quic.Conn +} + +func newDoQQuerier(s model.Server, o Options) (*doqQuerier, error) { + addr, err := net.ResolveUDPAddr("udp", s.Endpoint()) + if err != nil { + return nil, err + } + tlsCfg := &tls.Config{} + if o.TLSConfig != nil { + tlsCfg = o.TLSConfig.Clone() + } + tlsCfg.NextProtos = []string{doqALPN} + if tlsCfg.ServerName == "" { + tlsCfg.ServerName = s.TLSHostname + if tlsCfg.ServerName == "" { + tlsCfg.ServerName = s.Address + } + } + return &doqQuerier{opts: o, addr: addr, tlsCfg: tlsCfg, dialer: &quicDialer{}}, nil +} + +func (d *doqQuerier) Protocol() model.Protocol { return model.ProtoDoQ } + +func (d *doqQuerier) Close() error { + d.mu.Lock() + d.dropConn(doqNoError) + d.mu.Unlock() + return d.dialer.close() +} + +// Caller must hold d.mu. +func (d *doqQuerier) dropConn(code quic.ApplicationErrorCode) { + if d.conn != nil { + d.conn.CloseWithError(code, "") + d.conn = nil + } +} + +func (d *doqQuerier) Query(ctx context.Context, q Question) model.QueryResult { + ctx, cancel := withTimeout(ctx, d.opts.Timeout) + defer cancel() + d.mu.Lock() + defer d.mu.Unlock() + start := time.Now() + msg := buildDoQMsg(q) + if d.opts.Persistent && d.conn != nil { + queryStart := time.Now() + resp, size, err := exchangeDoQ(ctx, d.conn, msg) + ph := model.QueryPhases{Query: time.Since(queryStart)} + if err == nil { + return d.finish(start, ph, resp, size, true) + } + drop, code := doqConnectionFailure(ctx, err) + if drop { + d.dropConn(code) + } + if !drop || ctx.Err() != nil || isProtocolErr(err) { + return errorResult(start, ph, classifyQUIC(ctx, err)) + } + } + var ph model.QueryPhases + handshakeStart := time.Now() + conn, err := d.dialer.dial(ctx, d.addr, d.tlsCfg, &quic.Config{}) + if err != nil { + return errorResult(start, ph, classifyQUIC(ctx, err)) + } + // QUIC reports its combined transport/TLS handshake as TLS. + ph.TLSHandshake = time.Since(handshakeStart) + if d.opts.Persistent { + // A canceled stream must not discard the healthy connection. + d.conn = conn + } + queryStart := time.Now() + resp, size, err := exchangeDoQ(ctx, conn, msg) + ph.Query = time.Since(queryStart) + if err != nil { + drop, code := doqConnectionFailure(ctx, err) + if d.opts.Persistent { + if drop { + d.dropConn(code) + } + } else if drop { + conn.CloseWithError(code, "") + } else { + conn.CloseWithError(doqNoError, "") + } + return errorResult(start, ph, classifyQUIC(ctx, err)) + } + if !d.opts.Persistent { + conn.CloseWithError(doqNoError, "") + } + return d.finish(start, ph, resp, size, false) +} + +func (d *doqQuerier) finish(start time.Time, ph model.QueryPhases, resp *dns.Msg, size int, reused bool) model.QueryResult { + res := resultFromMsg(resp, size) + res.Phases = ph + res.Reused = reused + res.RTT = time.Since(start) + return res +} + +// buildDoQMsg pads EDNS-enabled queries to 128-byte blocks (RFC 9250 §5.4). +func buildDoQMsg(q Question) *dns.Msg { + msg := buildMsg(q, true) + opt := msg.IsEdns0() + if opt == nil { + return msg + } + wire, err := msg.Pack() + if err != nil { + return msg + } + const optionHeaderSize = 4 + paddingLen := (doqPaddingBlockSize - (len(wire)+optionHeaderSize)%doqPaddingBlockSize) % doqPaddingBlockSize + opt.Option = append(opt.Option, &dns.EDNS0_PADDING{Padding: make([]byte, paddingLen)}) + return msg +} + +// exchangeDoQ requires one FIN-terminated response per stream (RFC 9250 §4.2). +func exchangeDoQ(ctx context.Context, conn *quic.Conn, msg *dns.Msg) (*dns.Msg, int, error) { + str, err := conn.OpenStreamSync(ctx) + if err != nil { + return nil, 0, err + } + stopCancel := context.AfterFunc(ctx, func() { + str.CancelWrite(quic.StreamErrorCode(doqRequestCancelled)) + str.CancelRead(quic.StreamErrorCode(doqRequestCancelled)) + }) + defer stopCancel() + if deadline, ok := ctx.Deadline(); ok { + str.SetDeadline(deadline) + } + if err := writeFramed(str, msg); err != nil { + cancelDoQStream(str, doqStreamFailureCode(ctx, err)) + return nil, 0, err + } + if err := str.Close(); err != nil { + cancelDoQStream(str, doqStreamFailureCode(ctx, err)) + return nil, 0, err + } + resp, size, err := readFramed(str) + if err != nil { + err = doqResponseReadError(ctx, err) + cancelDoQStream(str, doqStreamFailureCode(ctx, err)) + return nil, 0, err + } + if err := readDoQFIN(ctx, str); err != nil { + cancelDoQStream(str, doqStreamFailureCode(ctx, err)) + return nil, 0, err + } + if resp.Id != msg.Id { + err := idMismatchErr() + cancelDoQStream(str, quic.StreamErrorCode(doqProtocolError)) + return nil, 0, err + } + return resp, size, nil +} + +func cancelDoQStream(str *quic.Stream, code quic.StreamErrorCode) { + str.CancelWrite(code) + str.CancelRead(code) +} + +func doqStreamFailureCode(ctx context.Context, err error) quic.StreamErrorCode { + if isProtocolErr(err) { + return quic.StreamErrorCode(doqProtocolError) + } + if doqRequestWasCancelled(ctx, err) { + return quic.StreamErrorCode(doqRequestCancelled) + } + return quic.StreamErrorCode(doqInternalError) +} + +func doqResponseReadError(ctx context.Context, err error) error { + if ctx != nil && ctx.Err() != nil { + return err + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return &model.QueryError{Kind: model.ErrProtocol, Msg: "server sent an incomplete DNS response"} + } + return err +} + +func readDoQFIN(ctx context.Context, str *quic.Stream) error { + var trailing [1]byte + for { + n, err := str.Read(trailing[:]) + if n > 0 { + return &model.QueryError{Kind: model.ErrProtocol, Msg: "server sent data after the DNS response"} + } + if errors.Is(err, io.EOF) { + return nil + } + if err == nil { + continue + } + if ctx != nil && errors.Is(ctx.Err(), context.Canceled) { + return context.Canceled + } + return &model.QueryError{ + Kind: model.ErrProtocol, + Msg: "server did not terminate the DNS response stream with FIN: " + err.Error(), + } + } +} + +// Request cancellation is stream-scoped; connection failures evict the cached session. +func doqConnectionFailure(ctx context.Context, err error) (drop bool, code quic.ApplicationErrorCode) { + if isProtocolErr(err) { + return true, doqProtocolError + } + if isQUICConnectionError(err) { + return true, doqInternalError + } + if doqRequestWasCancelled(ctx, err) { + return false, doqNoError + } + var streamErr *quic.StreamError + if errors.As(err, &streamErr) { + return false, doqNoError + } + return true, doqInternalError +} + +func doqRequestWasCancelled(ctx context.Context, err error) bool { + if ctx != nil && ctx.Err() != nil { + return true + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || isDeadlineErr(err) { + return true + } + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} + +func isQUICConnectionError(err error) bool { + var transportErr *quic.TransportError + var appErr *quic.ApplicationError + var idleErr *quic.IdleTimeoutError + var handshakeErr *quic.HandshakeTimeoutError + var resetErr *quic.StatelessResetError + var versionErr *quic.VersionNegotiationError + return errors.As(err, &transportErr) || + errors.As(err, &appErr) || + errors.As(err, &idleErr) || + errors.As(err, &handshakeErr) || + errors.As(err, &resetErr) || + errors.As(err, &versionErr) || + errors.Is(err, net.ErrClosed) +} + +func classifyQUIC(ctx context.Context, err error) *model.QueryError { + if isProtocolErr(err) { + return classify(ctx, err) + } + if ctx != nil && ctx.Err() != nil { + return classify(ctx, ctx.Err()) + } + var transportErr *quic.TransportError + if errors.As(err, &transportErr) && transportErr.ErrorCode.IsCryptoError() { + return &model.QueryError{Kind: model.ErrTLS, Msg: err.Error()} + } + var h3Err *http3.Error + if errors.As(err, &h3Err) { + return &model.QueryError{Kind: model.ErrProtocol, Msg: err.Error()} + } + var appErr *quic.ApplicationError + if errors.As(err, &appErr) { + return &model.QueryError{Kind: model.ErrProtocol, Msg: err.Error()} + } + var streamErr *quic.StreamError + if errors.As(err, &streamErr) { + return &model.QueryError{Kind: model.ErrProtocol, Msg: err.Error()} + } + return classify(ctx, err) +} diff --git a/internal/transport/factory.go b/internal/transport/factory.go index c4a1b8b..08c105e 100644 --- a/internal/transport/factory.go +++ b/internal/transport/factory.go @@ -26,10 +26,20 @@ func New(s model.Server, o Options) (Querier, error) { } return newDoTQuerier(s, o), nil case model.ProtoDoH: - if s.DoHURL == "" { - return nil, fmt.Errorf("transport: server %q uses DoH but has no DoH URL", s.DisplayName()) + if err := requireURL(s); err != nil { + return nil, err } return newDoHQuerier(s, o), nil + case model.ProtoDoH3: + if err := requireURL(s); err != nil { + return nil, err + } + return newDoH3Querier(s, o), nil + case model.ProtoDoQ: + if err := requireAddress(s); err != nil { + return nil, err + } + return newDoQQuerier(s, o) default: return nil, fmt.Errorf("transport: unknown protocol %q for server %q", s.Protocol, s.DisplayName()) } @@ -41,3 +51,10 @@ func requireAddress(s model.Server) error { } return nil } + +func requireURL(s model.Server) error { + if s.DoHURL == "" { + return fmt.Errorf("transport: server %q uses %s but has no DoH URL", s.DisplayName(), s.Protocol.Label()) + } + return nil +} diff --git a/internal/transport/msg.go b/internal/transport/msg.go index 2facc07..bab0971 100644 --- a/internal/transport/msg.go +++ b/internal/transport/msg.go @@ -19,9 +19,10 @@ import ( const defaultEDNSBufSize = 1232 -func buildMsg(q Question, doh bool) *dns.Msg { +// DoH prefers zero IDs for cacheability; DoQ requires them (RFC 9250 §4.2.1). +func buildMsg(q Question, zeroID bool) *dns.Msg { m := new(dns.Msg) - if !doh { + if !zeroID { m.Id = dns.Id() } m.RecursionDesired = true @@ -123,18 +124,27 @@ func exchangeUDPDraining(c net.Conn, m *dns.Msg) (*dns.Msg, int, error) { } func exchangeStream(c net.Conn, m *dns.Msg) (*dns.Msg, int, error) { + if err := writeFramed(c, m); err != nil { + return nil, 0, err + } + return readFramed(c) +} + +func writeFramed(w io.Writer, m *dns.Msg) error { out, err := m.Pack() if err != nil { - return nil, 0, malformedQueryErr(err) + return malformedQueryErr(err) } framed := make([]byte, 2+len(out)) binary.BigEndian.PutUint16(framed, uint16(len(out))) copy(framed[2:], out) - if _, err := c.Write(framed); err != nil { - return nil, 0, err - } + _, err = w.Write(framed) + return err +} + +func readFramed(r io.Reader) (*dns.Msg, int, error) { var lenBuf [2]byte - if _, err := io.ReadFull(c, lenBuf[:]); err != nil { + if _, err := io.ReadFull(r, lenBuf[:]); err != nil { return nil, 0, err } n := int(binary.BigEndian.Uint16(lenBuf[:])) @@ -142,7 +152,7 @@ func exchangeStream(c net.Conn, m *dns.Msg) (*dns.Msg, int, error) { return nil, 0, &model.QueryError{Kind: model.ErrProtocol, Msg: "server sent an empty DNS message"} } body := make([]byte, n) - if _, err := io.ReadFull(c, body); err != nil { + if _, err := io.ReadFull(r, body); err != nil { return nil, 0, err } resp := new(dns.Msg) diff --git a/internal/transport/quic.go b/internal/transport/quic.go new file mode 100644 index 0000000..2b03c90 --- /dev/null +++ b/internal/transport/quic.go @@ -0,0 +1,80 @@ +package transport + +import ( + "context" + "crypto/tls" + "net" + "net/http/httptrace" + "sync" + + "github.com/quic-go/quic-go" +) + +type quicDialer struct { + mu sync.Mutex + pc net.PacketConn + tr *quic.Transport + dead bool +} + +func (d *quicDialer) transport() (*quic.Transport, error) { + d.mu.Lock() + defer d.mu.Unlock() + if d.dead { + return nil, net.ErrClosed + } + if d.tr != nil { + return d.tr, nil + } + pc, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, err + } + d.pc = pc + d.tr = &quic.Transport{Conn: pc} + return d.tr, nil +} + +func (d *quicDialer) dial(ctx context.Context, addr *net.UDPAddr, tlsCfg *tls.Config, qcfg *quic.Config) (*quic.Conn, error) { + tr, err := d.transport() + if err != nil { + return nil, err + } + return tr.Dial(ctx, addr, tlsCfg, qcfg) +} + +// A custom HTTP/3 dial bypasses quic-go's trace hooks, so pinned dials emit them here. +func (d *quicDialer) dialTraced(ctx context.Context, addr *net.UDPAddr, tlsCfg *tls.Config, qcfg *quic.Config) (*quic.Conn, error) { + trace := httptrace.ContextClientTrace(ctx) + if trace != nil && trace.TLSHandshakeStart != nil { + trace.TLSHandshakeStart() + } + tr, err := d.transport() + if err != nil { + return nil, err + } + conn, err := tr.DialEarly(ctx, addr, tlsCfg, qcfg) + if trace != nil && trace.TLSHandshakeDone != nil { + var state tls.ConnectionState + if conn != nil { + state = conn.ConnectionState().TLS + } + trace.TLSHandshakeDone(state, err) + } + return conn, err +} + +func (d *quicDialer) close() error { + d.mu.Lock() + defer d.mu.Unlock() + d.dead = true + if d.tr == nil { + return nil + } + err := d.tr.Close() + if cerr := d.pc.Close(); err == nil { + err = cerr + } + d.tr, d.pc = nil, nil + return err +} diff --git a/internal/transport/quic_test.go b/internal/transport/quic_test.go new file mode 100644 index 0000000..521b371 --- /dev/null +++ b/internal/transport/quic_test.go @@ -0,0 +1,365 @@ +package transport + +import ( + "context" + "crypto/tls" + "crypto/x509" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "dnsbench/internal/model" + + "github.com/miekg/dns" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" +) + +func doqServer(t *testing.T, o Options) Querier { + t.Helper() + port, pool := startDoQServer(t, testHandler(nil)) + srv := model.Server{Protocol: model.ProtoDoQ, Address: "127.0.0.1", Port: port, TLSHostname: "dnsbench.test"} + o.TLSConfig = &tls.Config{RootCAs: pool} + return mustNew(t, srv, o) +} + +func TestDoQQueryAndPersistence(t *testing.T) { + q := doqServer(t, Options{Timeout: 5 * time.Second, Persistent: true}) + r1 := q.Query(context.Background(), Question{Name: "a.test", Qtype: dns.TypeA}) + mustAnswer(t, r1) + if r1.Reused { + t.Error("first query Reused = true, want false") + } + if len(r1.Answers) != 1 || r1.Answers[0].Data != "192.0.2.1" { + t.Errorf("Answers = %+v, want one A 192.0.2.1", r1.Answers) + } + if r1.Phases.TLSHandshake <= 0 { + t.Errorf("Phases.TLSHandshake = %v, want > 0", r1.Phases.TLSHandshake) + } + if r1.Phases.Connect != 0 { + t.Errorf("Phases.Connect = %v, want 0 for a QUIC transport", r1.Phases.Connect) + } + if r1.Phases.Query <= 0 { + t.Errorf("Phases.Query = %v, want > 0", r1.Phases.Query) + } + r2 := q.Query(context.Background(), Question{Name: "a.test", Qtype: dns.TypeA}) + mustAnswer(t, r2) + if !r2.Reused { + t.Error("second query Reused = false, want true") + } + if r2.Phases.TLSHandshake != 0 { + t.Errorf("reused query Phases.TLSHandshake = %v, want 0", r2.Phases.TLSHandshake) + } +} + +func TestDoQColdSessionRedials(t *testing.T) { + q := doqServer(t, Options{Timeout: 5 * time.Second}) + for i := 0; i < 2; i++ { + res := q.Query(context.Background(), Question{Name: "a.test", Qtype: dns.TypeA}) + mustAnswer(t, res) + if res.Reused { + t.Errorf("query %d Reused = true, want false in cold mode", i) + } + if res.Phases.TLSHandshake <= 0 { + t.Errorf("query %d Phases.TLSHandshake = %v, want > 0", i, res.Phases.TLSHandshake) + } + } +} + +func TestDoQUsesZeroMessageID(t *testing.T) { + var seen atomic.Int32 + seen.Store(-1) + handler := dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) { + seen.Store(int32(req.Id)) + m := new(dns.Msg) + m.SetReply(req) + m.Answer = append(m.Answer, aRecord(req.Question[0].Name, "192.0.2.7", 30)) + w.WriteMsg(m) + }) + port, pool := startDoQServer(t, handler) + srv := model.Server{Protocol: model.ProtoDoQ, Address: "127.0.0.1", Port: port, TLSHostname: "dnsbench.test"} + q := mustNew(t, srv, Options{Timeout: 5 * time.Second, TLSConfig: &tls.Config{RootCAs: pool}}) + mustAnswer(t, q.Query(context.Background(), Question{Name: "a.test", Qtype: dns.TypeA})) + if got := seen.Load(); got != 0 { + t.Errorf("server saw message ID %d, want 0", got) + } +} + +func TestDoQNXDOMAIN(t *testing.T) { + q := doqServer(t, Options{Timeout: 5 * time.Second, Persistent: true}) + res := q.Query(context.Background(), Question{Name: "nope.test", Qtype: dns.TypeA}) + mustAnswer(t, res) + if res.Rcode != dns.RcodeNameError { + t.Errorf("Rcode = %d, want %d", res.Rcode, dns.RcodeNameError) + } +} + +func TestDoQTimeout(t *testing.T) { + cancelCause := make(chan error, 1) + hook := func(str *quic.Stream, req, _ *dns.Msg) bool { + if !strings.EqualFold(req.Question[0].Name, "cancel.test.") { + return false + } + <-str.Context().Done() + cancelCause <- context.Cause(str.Context()) + return true + } + port, pool := startDoQServerWithHook(t, testHandler(nil), hook) + srv := model.Server{Protocol: model.ProtoDoQ, Address: "127.0.0.1", Port: port, TLSHostname: "dnsbench.test"} + q := mustNew(t, srv, Options{ + Timeout: 100 * time.Millisecond, + Persistent: true, + TLSConfig: &tls.Config{RootCAs: pool}, + }) + + res := q.Query(context.Background(), Question{Name: "cancel.test", Qtype: dns.TypeA}) + if res.Err == nil { + t.Fatal("expected a timeout, got success") + } + if res.Err.Kind != model.ErrTimeout { + t.Errorf("Err.Kind = %q, want %q", res.Err.Kind, model.ErrTimeout) + } + next := q.Query(context.Background(), Question{Name: "a.test", Qtype: dns.TypeA}) + mustAnswer(t, next) + if !next.Reused { + t.Error("query after a stream timeout Reused = false, want the QUIC connection preserved") + } + select { + case cause := <-cancelCause: + streamErr, ok := cause.(*quic.StreamError) + if !ok { + t.Fatalf("server stream cancellation = %T %v, want *quic.StreamError", cause, cause) + } + if streamErr.ErrorCode != quic.StreamErrorCode(doqRequestCancelled) || !streamErr.Remote { + t.Errorf("server stream cancellation = %+v, want remote DOQ_REQUEST_CANCELLED", streamErr) + } + case <-time.After(time.Second): + t.Fatal("server did not observe the client stream cancellation") + } +} + +func TestDoQBadCertificate(t *testing.T) { + port, _ := startDoQServer(t, testHandler(nil)) + srv := model.Server{Protocol: model.ProtoDoQ, Address: "127.0.0.1", Port: port, TLSHostname: "dnsbench.test"} + q := mustNew(t, srv, Options{Timeout: 3 * time.Second, TLSConfig: &tls.Config{RootCAs: x509.NewCertPool()}}) + res := q.Query(context.Background(), Question{Name: "a.test", Qtype: dns.TypeA}) + if res.Err == nil { + t.Fatal("expected error, got success") + } + if res.Err.Kind != model.ErrTLS { + t.Errorf("Err.Kind = %q, want %q", res.Err.Kind, model.ErrTLS) + } +} + +func TestDoQEDNSOptionsReachTheServer(t *testing.T) { + var ( + wireSize atomic.Int32 + hasPadding atomic.Bool + ) + base := testHandler(nil) + handler := dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) { + if wire, err := req.Pack(); err == nil { + wireSize.Store(int32(len(wire))) + } + if opt := req.IsEdns0(); opt != nil { + for _, option := range opt.Option { + if option.Option() == dns.EDNS0PADDING { + hasPadding.Store(true) + } + } + } + base.ServeDNS(w, req) + }) + port, pool := startDoQServer(t, handler) + srv := model.Server{Protocol: model.ProtoDoQ, Address: "127.0.0.1", Port: port, TLSHostname: "dnsbench.test"} + q := mustNew(t, srv, Options{ + Timeout: 5 * time.Second, + Persistent: true, + TLSConfig: &tls.Config{RootCAs: pool}, + }) + res := q.Query(context.Background(), Question{Name: "echo.test", Qtype: dns.TypeTXT, DO: true, CD: true, UDPBufSize: 1400}) + mustAnswer(t, res) + if len(res.Answers) != 1 { + t.Fatalf("len(Answers) = %d, want 1", len(res.Answers)) + } + for _, want := range []string{"do=1", "cd=1", "edns=1", "bufsize=1400"} { + if !strings.Contains(res.Answers[0].Data, want) { + t.Errorf("answer data %q does not contain %q", res.Answers[0].Data, want) + } + } + if !hasPadding.Load() { + t.Error("server did not receive an EDNS Padding option") + } + if got := wireSize.Load(); got == 0 || got%doqPaddingBlockSize != 0 { + t.Errorf("server received a %d-byte DNS query, want a non-zero multiple of %d", got, doqPaddingBlockSize) + } +} + +func TestDoQRejectsExtraResponseData(t *testing.T) { + hook := func(str *quic.Stream, req, resp *dns.Msg) bool { + if !strings.EqualFold(req.Question[0].Name, "extra.test.") { + return false + } + if err := writeFramed(str, resp); err != nil { + return true + } + _, _ = str.Write([]byte{0}) + _ = str.Close() + return true + } + port, pool := startDoQServerWithHook(t, testHandler(nil), hook) + srv := model.Server{Protocol: model.ProtoDoQ, Address: "127.0.0.1", Port: port, TLSHostname: "dnsbench.test"} + q := mustNew(t, srv, Options{ + Timeout: time.Second, + Persistent: true, + TLSConfig: &tls.Config{RootCAs: pool}, + }) + + res := q.Query(context.Background(), Question{Name: "extra.test", Qtype: dns.TypeA}) + if res.Err == nil || res.Err.Kind != model.ErrProtocol { + t.Fatalf("extra response data error = %v, want protocol", res.Err) + } + next := q.Query(context.Background(), Question{Name: "a.test", Qtype: dns.TypeA}) + mustAnswer(t, next) + if next.Reused { + t.Error("query after a framing violation Reused = true, want a new QUIC connection") + } +} + +func TestDoQRejectsResponseWithoutFIN(t *testing.T) { + hook := func(str *quic.Stream, req, resp *dns.Msg) bool { + if !strings.EqualFold(req.Question[0].Name, "nofin.test.") { + return false + } + if err := writeFramed(str, resp); err != nil { + return true + } + <-str.Context().Done() + return true + } + port, pool := startDoQServerWithHook(t, testHandler(nil), hook) + srv := model.Server{Protocol: model.ProtoDoQ, Address: "127.0.0.1", Port: port, TLSHostname: "dnsbench.test"} + q := mustNew(t, srv, Options{ + Timeout: 100 * time.Millisecond, + Persistent: true, + TLSConfig: &tls.Config{RootCAs: pool}, + }) + + res := q.Query(context.Background(), Question{Name: "nofin.test", Qtype: dns.TypeA}) + if res.Err == nil || res.Err.Kind != model.ErrProtocol { + t.Fatalf("missing FIN error = %v, want protocol", res.Err) + } + if !strings.Contains(res.Err.Msg, "FIN") { + t.Errorf("missing FIN error = %q, want FIN detail", res.Err.Msg) + } + next := q.Query(context.Background(), Question{Name: "a.test", Qtype: dns.TypeA}) + mustAnswer(t, next) + if next.Reused { + t.Error("query after a missing FIN Reused = true, want a new QUIC connection") + } +} + +func TestDoQErrorCodesMatchRFC9250(t *testing.T) { + for name, test := range map[string]struct { + got uint64 + want uint64 + }{ + "no error": {uint64(doqNoError), 0x0}, + "internal error": {uint64(doqInternalError), 0x1}, + "protocol error": {uint64(doqProtocolError), 0x2}, + "request cancelled": {uint64(doqRequestCancelled), 0x3}, + } { + t.Run(name, func(t *testing.T) { + if test.got != test.want { + t.Fatalf("code = %#x, want %#x", test.got, test.want) + } + }) + } +} + +func TestDoH3QueryAndPersistence(t *testing.T) { + url, pool := startDoH3Server(t, testHandler(nil)) + srv := model.Server{Protocol: model.ProtoDoH3, DoHURL: url, Address: "127.0.0.1"} + q := mustNew(t, srv, Options{Timeout: 5 * time.Second, Persistent: true, TLSConfig: &tls.Config{RootCAs: pool}}) + r1 := q.Query(context.Background(), Question{Name: "a.test", Qtype: dns.TypeA}) + mustAnswer(t, r1) + if r1.Reused { + t.Error("first query Reused = true, want false") + } + if len(r1.Answers) != 1 || r1.Answers[0].Data != "192.0.2.1" { + t.Errorf("Answers = %+v, want one A 192.0.2.1", r1.Answers) + } + if r1.Phases.TLSHandshake <= 0 { + t.Errorf("Phases.TLSHandshake = %v, want > 0", r1.Phases.TLSHandshake) + } + if r1.Phases.Connect != 0 { + t.Errorf("Phases.Connect = %v, want 0 for a QUIC transport", r1.Phases.Connect) + } + if r1.ResponseSize <= 0 { + t.Errorf("ResponseSize = %d, want > 0", r1.ResponseSize) + } + r2 := q.Query(context.Background(), Question{Name: "a.test", Qtype: dns.TypeA}) + mustAnswer(t, r2) + if !r2.Reused { + t.Error("second query Reused = false, want true") + } +} + +func TestDoH3OverridesDialOnlyForPinnedAddresses(t *testing.T) { + for _, test := range []struct { + name string + address string + wantCustom bool + }{ + {name: "hostname uses context-aware default resolver"}, + {name: "pinned IP uses custom dialer", address: "192.0.2.1", wantCustom: true}, + } { + t.Run(test.name, func(t *testing.T) { + q := newDoH3Querier(model.Server{ + Protocol: model.ProtoDoH3, + DoHURL: "https://dns.example/dns-query", + Address: test.address, + }, Options{}) + tr, ok := q.client.Transport.(*http3.Transport) + if !ok { + t.Fatalf("transport = %T, want *http3.Transport", q.client.Transport) + } + if got := tr.Dial != nil; got != test.wantCustom { + t.Errorf("custom Dial configured = %v, want %v", got, test.wantCustom) + } + if err := q.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + } +} + +func TestDoH3BadCertificate(t *testing.T) { + url, _ := startDoH3Server(t, testHandler(nil)) + srv := model.Server{Protocol: model.ProtoDoH3, DoHURL: url, Address: "127.0.0.1"} + q := mustNew(t, srv, Options{Timeout: 3 * time.Second, TLSConfig: &tls.Config{RootCAs: x509.NewCertPool()}}) + res := q.Query(context.Background(), Question{Name: "a.test", Qtype: dns.TypeA}) + if res.Err == nil { + t.Fatal("expected error, got success") + } + if res.Err.Kind != model.ErrTLS { + t.Errorf("Err.Kind = %q, want %q", res.Err.Kind, model.ErrTLS) + } +} + +func TestClassifyHTTP3ErrorAsProtocol(t *testing.T) { + err := &url.Error{ + Op: "Post", + URL: "https://dns.example/dns-query", + Err: &http3.Error{Remote: true, ErrorCode: http3.ErrCodeGeneralProtocolError}, + } + got := classifyQUIC(context.Background(), err) + if got.Kind != model.ErrProtocol { + t.Fatalf("Err.Kind = %q, want %q", got.Kind, model.ErrProtocol) + } + if !strings.Contains(got.Msg, "H3_GENERAL_PROTOCOL_ERROR") { + t.Errorf("Err.Msg = %q, want the HTTP/3 error detail", got.Msg) + } +} diff --git a/internal/transport/testserver_test.go b/internal/transport/testserver_test.go index cf59a92..b1e6507 100644 --- a/internal/transport/testserver_test.go +++ b/internal/transport/testserver_test.go @@ -1,12 +1,14 @@ package transport import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "errors" "fmt" "io" "math/big" @@ -19,6 +21,8 @@ import ( "time" "github.com/miekg/dns" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" ) func boolFlag(b bool) int { @@ -213,6 +217,141 @@ func startDoHServer(t *testing.T) *httptest.Server { return srv } +type captureWriter struct { + remote net.Addr + msg *dns.Msg + closed bool +} + +func (w *captureWriter) LocalAddr() net.Addr { return w.remote } +func (w *captureWriter) RemoteAddr() net.Addr { return w.remote } +func (w *captureWriter) WriteMsg(m *dns.Msg) error { + w.msg = m + return nil +} +func (w *captureWriter) Write([]byte) (int, error) { return 0, errors.New("not supported") } +func (w *captureWriter) Close() error { w.closed = true; return nil } +func (w *captureWriter) TsigStatus() error { return nil } +func (w *captureWriter) TsigTimersOnly(bool) {} +func (w *captureWriter) Hijack() {} + +func handleWith(h dns.Handler, query *dns.Msg) *dns.Msg { + // Keep shared handlers from applying UDP-only behavior such as truncation. + w := &captureWriter{remote: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 12345}} + h.ServeDNS(w, query) + if w.closed { + return nil + } + return w.msg +} + +func startDoQServer(t *testing.T, h dns.Handler) (int, *x509.CertPool) { + return startDoQServerWithHook(t, h, nil) +} + +type doqResponseHook func(*quic.Stream, *dns.Msg, *dns.Msg) (handled bool) + +func startDoQServerWithHook(t *testing.T, h dns.Handler, hook doqResponseHook) (int, *x509.CertPool) { + t.Helper() + cert, pool := selfSignedCert(t) + ln, err := quic.ListenAddr("127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{cert}, + NextProtos: []string{doqALPN}, + }, &quic.Config{}) + if err != nil { + t.Fatalf("quic listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + go func() { + for { + conn, err := ln.Accept(context.Background()) + if err != nil { + return + } + go serveDoQConn(conn, h, hook) + } + }() + return ln.Addr().(*net.UDPAddr).Port, pool +} + +func serveDoQConn(conn *quic.Conn, h dns.Handler, hook doqResponseHook) { + for { + str, err := conn.AcceptStream(context.Background()) + if err != nil { + return + } + go func() { + req, _, err := readFramed(str) + if err != nil { + str.CancelWrite(0) + return + } + resp := handleWith(h, req) + if resp == nil { + str.CancelWrite(0) + return + } + if hook != nil && hook(str, req, resp) { + return + } + if err := writeFramed(str, resp); err != nil { + str.CancelWrite(0) + return + } + str.Close() + }() + } +} + +func startDoH3Server(t *testing.T, h dns.Handler) (string, *x509.CertPool) { + t.Helper() + cert, pool := selfSignedCert(t) + srv := &http3.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serveDoHRequest(w, r, h) }), + TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, + } + ln, err := quic.ListenAddr("127.0.0.1:0", http3.ConfigureTLSConfig(srv.TLSConfig), &quic.Config{}) + if err != nil { + t.Fatalf("quic listen: %v", err) + } + go srv.ServeListener(ln) + t.Cleanup(func() { + srv.Close() + ln.Close() + }) + port := ln.Addr().(*net.UDPAddr).Port + return fmt.Sprintf("https://dnsbench.test:%d/dns-query", port), pool +} + +func serveDoHRequest(w http.ResponseWriter, r *http.Request, h dns.Handler) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + req := new(dns.Msg) + if err := req.Unpack(body); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + resp := handleWith(h, req) + if resp == nil { + http.Error(w, "dropped", http.StatusInternalServerError) + return + } + out, err := resp.Pack() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", dohContentType) + w.Write(out) +} + func dohPool(t *testing.T, srv *httptest.Server) *x509.CertPool { t.Helper() pool := x509.NewCertPool() diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go index 633350e..6d488ea 100644 --- a/internal/transport/transport_test.go +++ b/internal/transport/transport_test.go @@ -404,13 +404,15 @@ func TestFactoryValidation(t *testing.T) { if _, err := New(model.Server{Protocol: "weird", Address: "127.0.0.1"}, Options{}); err == nil { t.Error("unknown protocol: expected error") } - for _, p := range []model.Protocol{model.ProtoUDP, model.ProtoTCP, model.ProtoDoT} { + for _, p := range []model.Protocol{model.ProtoUDP, model.ProtoTCP, model.ProtoDoT, model.ProtoDoQ} { if _, err := New(model.Server{Protocol: p}, Options{}); err == nil { t.Errorf("%s without address: expected error", p) } } - if _, err := New(model.Server{Protocol: model.ProtoDoH}, Options{}); err == nil { - t.Error("doh without URL: expected error") + for _, p := range []model.Protocol{model.ProtoDoH, model.ProtoDoH3} { + if _, err := New(model.Server{Protocol: p}, Options{}); err == nil { + t.Errorf("%s without URL: expected error", p) + } } cases := []struct { s model.Server @@ -420,6 +422,8 @@ func TestFactoryValidation(t *testing.T) { {model.Server{Protocol: model.ProtoTCP, Address: "127.0.0.1"}, model.ProtoTCP}, {model.Server{Protocol: model.ProtoDoT, Address: "127.0.0.1"}, model.ProtoDoT}, {model.Server{Protocol: model.ProtoDoH, DoHURL: "https://example.com/dns-query"}, model.ProtoDoH}, + {model.Server{Protocol: model.ProtoDoH3, DoHURL: "https://example.com/dns-query"}, model.ProtoDoH3}, + {model.Server{Protocol: model.ProtoDoQ, Address: "127.0.0.1"}, model.ProtoDoQ}, } for _, tc := range cases { q, err := New(tc.s, Options{}) diff --git a/internal/ui/chart.go b/internal/ui/chart.go index 33859af..f3bcef6 100644 --- a/internal/ui/chart.go +++ b/internal/ui/chart.go @@ -56,10 +56,11 @@ func RenderRankingList(res *model.RunResult, mode model.RankMode, tieA, tieB str b.WriteString(Bold("Ranked by "+mode.Label()) + Gray(" — latency cost in ms, lower is better") + "\n") writeRankingLegend(&b, visible, system, tieA) if len(scores) == 0 { - b.WriteString(Gray(" no servers completed the benchmark") + "\n") + b.WriteString(Gray(" no resolvers completed the benchmark") + "\n") b.WriteString(renderUnrankedSummary(res, 0)) return b.String() } + b.WriteString(rankingHeader() + "\n") best := scores[0].TotalMs worst := 0.0 for _, sc := range scores { @@ -113,9 +114,11 @@ func writeRankingLegend(b *strings.Builder, scores []model.Score, system map[str func rankingRow(res *model.RunResult, sc model.Score, best, worst float64, cats []model.Category, system map[string]bool, tieA, tieB string) string { nameCell := TruncatePad(serverName(res, sc.ServerID), rankNameWidth) rankCell := padLeft(fmt.Sprintf("%d", sc.Rank), 4) + currentCell := " " switch { case system[sc.ServerID]: nameCell = Cyan(nameCell) + currentCell = Cyan("●") case sc.Rank == 1: nameCell = Bold(nameCell) } @@ -142,7 +145,18 @@ func rankingRow(res *model.RunResult, sc model.Score, best, worst float64, cats if sc.ServerID == tieA || sc.ServerID == tieB { marker = Dim(" ≈ tied") } - return fmt.Sprintf("%s %s %s%s%s %s%s", rankCell, nameCell, bar, scoreCell, penCell, lossCell, marker) + return fmt.Sprintf("%s %s %s %s%s%s %s%s", currentCell, rankCell, nameCell, bar, scoreCell, penCell, lossCell, marker) +} + +func rankingHeader() string { + return Bold(fmt.Sprintf( + " %s %s %s%s %s", + padLeft("#", 4), + padRight("resolver", rankNameWidth), + strings.Repeat(" ", rankBarWidth), + padLeft("cost", 10), + padLeft("loss", 7), + )) } func penaltyTotal(sc model.Score) float64 { diff --git a/internal/ui/chart_test.go b/internal/ui/chart_test.go index 0279849..c28fd1a 100644 --- a/internal/ui/chart_test.go +++ b/internal/ui/chart_test.go @@ -103,6 +103,7 @@ func TestRenderRankingList(t *testing.T) { "latency cost in ms, lower is better", "Cloudflare", "Quad9 Secure", "Google Public DNS", "10.1 ms", "18.0 ms", + "#", "resolver", "cost", "loss", "● current DNS", "* cost includes penalties", "1 sidelined", "1 unreachable", @@ -120,14 +121,18 @@ func TestRenderRankingList(t *testing.T) { if strings.Contains(out, "← current DNS") { t.Errorf("ranking rows should no longer append the trailing current-DNS marker\n%s", out) } + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "Google Public DNS") && !strings.HasPrefix(line, "●") { + t.Errorf("current DNS row is missing its non-color marker\n%s", out) + } + } if !strings.Contains(out, "█") { t.Errorf("ranking list has no bars\n%s", out) } } func TestRenderRankingListColorsCurrentDNSName(t *testing.T) { - SetColorEnabled(true) - t.Cleanup(func() { SetColorEnabled(false) }) + enableColors(t) out := RenderRankingList(chartFixture(), model.RankLatency, "", "") if !strings.Contains(out, "\x1b[36mGoogle Public DNS") { t.Errorf("current DNS name is not colored cyan\n%q", out) diff --git a/internal/ui/format_test.go b/internal/ui/format_test.go index b2e8630..93e53fb 100644 --- a/internal/ui/format_test.go +++ b/internal/ui/format_test.go @@ -14,6 +14,22 @@ func disableColors(t *testing.T) { t.Cleanup(func() { SetColorEnabled(prev) }) } +func enableColors(t *testing.T) { + t.Helper() + orig, had := os.LookupEnv("NO_COLOR") + prev := ColorEnabled() + os.Unsetenv("NO_COLOR") + SetColorEnabled(true) + t.Cleanup(func() { + if had { + os.Setenv("NO_COLOR", orig) + } else { + os.Unsetenv("NO_COLOR") + } + SetColorEnabled(prev) + }) +} + func TestFormatMs(t *testing.T) { cases := []struct { in float64 diff --git a/internal/ui/live.go b/internal/ui/live.go index 3f1704d..2899516 100644 --- a/internal/ui/live.go +++ b/internal/ui/live.go @@ -103,6 +103,17 @@ type benchmarkEventMsg struct { } type eventsClosedMsg struct{} +type livePrepareTickMsg struct { + at time.Time +} + +type livePhase uint8 + +const ( + livePhaseTriage livePhase = iota + livePhaseWarmup + livePhaseBenchmark +) func (ls *liveServer) totalSamples() int { total, _, _ := ls.counts() @@ -127,30 +138,57 @@ func (ls *liveServer) counts() (total, lost, invalid int) { } type Live struct { - servers []model.Server - cfg model.BenchConfig - out io.Writer - isTTY bool - sortKey string - categories []model.Category - byID map[string]*liveServer - inflight map[string]bool - start time.Time - round int - lastPctTen int - noticeText string - noticeColor func(string) string - width int - height int -} - -const maxNoticeLines = 2 + servers []model.Server + cfg model.BenchConfig + out io.Writer + isTTY bool + sortKey string + categories []model.Category + byID map[string]*liveServer + inflight map[string]bool + triaged map[string]bool + phase livePhase + triageDone int + warmupDone int + warmupStarted int + start time.Time + benchmarkStart time.Time + displayElapsed time.Duration + etaEstimate time.Duration + etaSamples []time.Duration + etaLastRound int + etaLastAt time.Time + etaReady bool + round int + roundStarted int + measuredDone int + measuredTotal int + prepareFrame int + lastPctTen int + noticeText string + noticeColor func(string) string + width int + height int +} + +const ( + maxNoticeLines = 2 + liveETAWindow = 5 + liveETAInitialCompletedRounds = 3 + liveETARounding = 5 * time.Second +) func NewLive(servers []model.Server, cfg model.BenchConfig, out io.Writer, isTTY bool, sortKey string) *Live { cats := cfg.Categories if len(cats) == 0 { cats = model.AllCategories() } + phase := livePhaseBenchmark + if cfg.TriageEnabled { + phase = livePhaseTriage + } else if cfg.WarmupRounds > 0 { + phase = livePhaseWarmup + } byID := make(map[string]*liveServer, len(servers)) for _, s := range servers { ls := &liveServer{server: s, state: model.StateActive, cats: map[model.Category]*liveAgg{}} @@ -168,6 +206,8 @@ func NewLive(servers []model.Server, cfg model.BenchConfig, out io.Writer, isTTY categories: cats, byID: byID, inflight: make(map[string]bool, len(servers)), + triaged: make(map[string]bool, len(servers)), + phase: phase, start: time.Now(), width: liveFallbackWidth, height: liveFallbackHeight, @@ -177,7 +217,7 @@ func NewLive(servers []model.Server, cfg model.BenchConfig, out io.Writer, isTTY func (l *Live) Run(events <-chan model.Event) { l.start = time.Now() if !l.isTTY { - fmt.Fprintf(l.out, "dnsbench — benchmarking %d servers\n", len(l.servers)) + fmt.Fprintf(l.out, "dnsbench — benchmarking %s\n", resolverCount(len(l.servers))) for ev := range events { l.handle(ev) } @@ -211,7 +251,7 @@ func (l *Live) Run(events <-chan model.Event) { } func (l *Live) Init() tea.Cmd { - return nil + return l.prepareTick() } func (l *Live) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -232,6 +272,16 @@ func (l *Live) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case eventsClosedMsg: return l, tea.Quit + case livePrepareTickMsg: + at := msg.at + if at.IsZero() { + at = time.Now() + } + l.updateDisplayedElapsed(at) + if l.round <= 0 { + l.prepareFrame++ + } + return l, l.prepareTick() } return l, nil } @@ -240,19 +290,45 @@ func (l *Live) View() string { return l.renderFrame() } +func (l *Live) prepareTick() tea.Cmd { + interval := time.Second + if l.round <= 0 { + interval = time.Second / liveFPS + } + return tea.Tick(interval, func(at time.Time) tea.Msg { + return livePrepareTickMsg{at: at} + }) +} + func (l *Live) handle(ev model.Event) { switch ev.Type { case model.EvQueryStart: + if ev.Round > 0 { + l.noteRoundStarted(ev.Round) + } else if l.phase != livePhaseTriage { + l.noteWarmupStarted() + } if ev.ServerID != "" { l.inflight[ev.ServerID] = true } case model.EvSample: if ev.Sample != nil { + if ev.Sample.Warmup { + l.noteWarmupStarted() + } else { + l.noteRoundStarted(ev.Sample.Round) + } delete(l.inflight, ev.Sample.ServerID) } l.recordSample(ev.Sample) case model.EvRoundDone: - l.round = ev.Round + if ev.Round == 0 && l.phase == livePhaseWarmup { + l.warmupDone = min(l.cfg.WarmupRounds, l.warmupDone+1) + } else { + l.round = ev.Round + l.noteRoundStarted(ev.Round) + l.noteRoundCompleted(ev.Round, time.Now()) + } if !l.isTTY { l.printProgress() } @@ -260,6 +336,7 @@ func (l *Live) handle(ev model.Event) { if ev.Triage != nil { delete(l.inflight, ev.Triage.ServerID) l.setState(ev.Triage.ServerID, ev.Triage.State, ev.Triage.Reason, false) + l.noteTriageDone(ev.Triage.ServerID) } case model.EvStateChange: delete(l.inflight, ev.ServerID) @@ -275,6 +352,108 @@ func (l *Live) handle(ev model.Event) { } } +func (l *Live) noteRoundStarted(round int) { + if round <= 0 { + return + } + l.phase = livePhaseBenchmark + if l.benchmarkStart.IsZero() { + l.benchmarkStart = time.Now() + l.displayElapsed = 0 + l.measuredTotal = l.expectedMeasuredSamples() + } + if round > l.roundStarted { + l.roundStarted = round + } +} + +func (l *Live) updateDisplayedElapsed(at time.Time) { + if l.benchmarkStart.IsZero() || at.Before(l.benchmarkStart) { + return + } + l.displayElapsed = at.Sub(l.benchmarkStart) +} + +func (l *Live) noteRoundCompleted(round int, at time.Time) { + if round <= 0 || at.IsZero() || l.benchmarkStart.IsZero() { + return + } + + previousRound := l.etaLastRound + previousAt := l.etaLastAt + if previousRound == 0 { + previousAt = l.benchmarkStart + } + roundDelta := round - previousRound + if roundDelta <= 0 || !at.After(previousAt) { + return + } + + perRound := at.Sub(previousAt) / time.Duration(roundDelta) + l.etaLastRound = round + l.etaLastAt = at + if perRound <= 0 { + return + } + + l.etaSamples = append(l.etaSamples, perRound) + if len(l.etaSamples) > liveETAWindow { + copy(l.etaSamples, l.etaSamples[len(l.etaSamples)-liveETAWindow:]) + l.etaSamples = l.etaSamples[:liveETAWindow] + } + + rounds := l.cfg.Rounds + if round <= liveETAInitialCompletedRounds || round >= rounds { + l.etaReady = false + l.etaEstimate = 0 + return + } + + var total time.Duration + for _, sample := range l.etaSamples { + total += sample + } + average := total / time.Duration(len(l.etaSamples)) + estimate := average * time.Duration(rounds-round) + l.etaEstimate = roundLiveETA(estimate) + l.etaReady = l.etaEstimate > 0 +} + +func roundLiveETA(estimate time.Duration) time.Duration { + if estimate <= 0 { + return 0 + } + rounded := estimate.Round(liveETARounding) + if rounded < liveETARounding { + return liveETARounding + } + return rounded +} + +func (l *Live) noteWarmupStarted() { + if l.phase == livePhaseTriage || l.cfg.WarmupRounds <= 0 { + return + } + l.phase = livePhaseWarmup + l.warmupStarted = min(l.cfg.WarmupRounds, max(l.warmupStarted, l.warmupDone+1)) +} + +func (l *Live) noteTriageDone(id string) { + if id == "" || l.triaged[id] { + return + } + l.triaged[id] = true + l.triageDone++ + if l.triageDone < len(l.servers) { + return + } + if l.cfg.WarmupRounds > 0 { + l.phase = livePhaseWarmup + return + } + l.phase = livePhaseBenchmark +} + func connLine(base, msg string) string { if msg == "" { return base @@ -290,6 +469,9 @@ func (l *Live) recordSample(s *model.Sample) { if ls == nil { return } + if s.Round > 0 { + l.measuredDone++ + } agg := ls.cats[s.Category] if agg == nil { agg = &liveAgg{} @@ -321,7 +503,7 @@ func (l *Live) setState(id string, state model.ServerState, reason string, annou if state == model.StateActive && !announceActive { return } - line := ls.server.DisplayName() + ": " + state.Label() + line := ls.server.DisplayName() + ": " + uiStateLabel(state) if reason != "" { line += " (" + reason + ")" } @@ -408,7 +590,7 @@ func (l *Live) renderFrame() string { layout := l.makeRowLayout(visibleActive) var b strings.Builder - l.writeFrameLine(&b, Bold(fmt.Sprintf("dnsbench — benchmarking %d servers", len(l.servers)))) + l.writeFrameLine(&b, Bold(l.headerLine(len(active), len(inactive)))) l.writeFrameLine(&b, l.progressLine()) if showMetric { l.writeFrameLine(&b, l.metricLine(metric, scale)) @@ -426,7 +608,7 @@ func (l *Live) renderFrame() string { } } for _, ls := range inactive[:inactiveShown] { - line := ls.server.DisplayName() + " — " + ls.state.Label() + line := ls.server.DisplayName() + " — " + uiStateLabel(ls.state) if ls.reason != "" { line += ": " + ls.reason } @@ -438,36 +620,148 @@ func (l *Live) renderFrame() string { } func (l *Live) progressLine() string { + switch l.phase { + case livePhaseTriage: + return l.indeterminateLine("triaging resolvers…") + case livePhaseWarmup: + return l.warmupLine() + } + rounds := l.cfg.Rounds - pct := 0 - if rounds > 0 { - pct = l.round * 100 / rounds - if pct > 100 { - pct = 100 + if l.roundStarted <= 0 { + return l.indeterminateLine("starting benchmark…") + } + + pct := l.benchmarkProgressPct() + elapsedDuration := l.displayElapsed + elapsed := formatElapsed(elapsedDuration) + pctText := "0%" + if pct > 0 { + displayPct := max(1, int(math.Floor(pct))) + if l.round >= rounds { + displayPct = 100 + } + pctText = fmt.Sprintf("%d%%", displayPct) + } + displayRound := max(l.round, l.roundStarted) + suffix := fmt.Sprintf("%4s · round %d/%d · elapsed %s", pctText, displayRound, rounds, elapsed) + if l.round < rounds && l.width >= 72 { + if l.etaReady { + suffix += " · ETA ~" + formatElapsed(l.etaEstimate) + } else { + suffix += " · ETA ~" } } - elapsed := formatElapsed(time.Since(l.start)) - suffix := fmt.Sprintf("%3d%% round %d/%d elapsed %s", pct, l.round, rounds, elapsed) barWidth := min(liveProgressWidth, l.width-visibleWidth(suffix)-4) if barWidth < 4 { - return fmt.Sprintf(" %3d%% round %d/%d elapsed %s", pct, l.round, rounds, elapsed) + return " " + suffix } - bar := Green(Bar(float64(pct), 100, barWidth)) + bar := liveProgressBar(pct, 100, barWidth) return " " + bar + " " + suffix } +func (l *Live) expectedMeasuredSamples() int { + if l.cfg.Rounds <= 0 || len(l.categories) == 0 { + return 0 + } + active := 0 + for _, server := range l.byID { + if server.state == model.StateActive { + active++ + } + } + return active * len(l.categories) * l.cfg.Rounds +} + +func (l *Live) benchmarkProgressPct() float64 { + pct := 0.0 + if l.cfg.Rounds > 0 && l.round > 0 { + pct = float64(l.round) * 100 / float64(l.cfg.Rounds) + } + if l.measuredTotal > 0 && l.measuredDone > 0 { + samplePct := float64(l.measuredDone) * 100 / float64(l.measuredTotal) + pct = max(pct, samplePct) + } + return min(100, pct) +} + +func (l *Live) warmupLine() string { + return l.indeterminateLine("warming up resolvers…") +} + +func (l *Live) indeterminateLine(suffix string) string { + barWidth := min(liveProgressWidth, l.width-visibleWidth(suffix)-4) + if barWidth < 6 { + return " " + suffix + } + return " " + oscillatingBar(barWidth, l.prepareFrame) + " " + suffix +} + +func oscillatingBar(width, frame int) string { + const ( + rightComet = "░▒▓█" + leftComet = "█▓▒░" + ) + if width < 6 { + return "" + } + innerWidth := width - 2 + cometWidth := len([]rune(rightComet)) + lastStart := innerWidth - cometWidth + start := 0 + comet := rightComet + if lastStart > 0 { + cycle := lastStart * 2 + phase := frame % cycle + start = phase + if phase > lastStart { + start = cycle - phase + comet = leftComet + } + } + before := strings.Repeat("·", start) + after := strings.Repeat("·", innerWidth-start-cometWidth) + head, tail := "█", "▓▒░" + if comet == rightComet { + head, tail = "█", "░▒▓" + return Gray("▏"+before) + Cyan(tail) + White(head) + Gray(after+"▕") + } + return Gray("▏"+before) + White(head) + Cyan(tail) + Gray(after+"▕") +} + +func liveProgressBar(value, maximum float64, width int) string { + if width < 3 { + return Green(Bar(value, maximum, width)) + } + innerWidth := width - 2 + fill := strings.TrimRight(Bar(value, maximum, innerWidth), " ") + empty := innerWidth - len([]rune(fill)) + return Gray("▏") + Green(fill) + Gray(strings.Repeat("·", empty)+"▕") +} + func (l *Live) legendLine() string { parts := make([]string, 0, len(l.categories)) for _, c := range l.categories { - parts = append(parts, CategoryColor(c)("■")+" "+c.Label()) + marker := CategoryColor(c)("■") + if !ColorEnabled() { + marker = categoryMarker(c) + } + parts = append(parts, marker+" "+c.Label()) } - switch { - case l.width >= liveFullLegend: - parts = append(parts, "loss = unanswered queries", "inv = unusable answers", "› = above P90") - case l.width >= liveTinyWidth: - parts = append(parts, "loss = unanswered", "inv = invalid", "› above P90") - default: - parts = append(parts, "L=loss", "I=invalid") + if ColorEnabled() { + parts = append(parts, Cyan("name")+" = querying") + } + + candidates := [][]string{ + {"loss = unanswered queries", "inv = unusable answers", "› = above P90"}, + {"loss = unanswered", "inv = invalid", "› above P90"}, + {"L=loss", "I=invalid"}, + } + for _, extra := range candidates { + line := " " + strings.Join(append(append([]string{}, parts...), extra...), " ") + if visibleWidth(line) <= l.width || extra[0] == "L=loss" { + return line + } } return " " + strings.Join(parts, " ") } @@ -479,24 +773,66 @@ func (l *Live) metricLine(metric string, scale float64) string { } switch l.sortKey { case "loss": - return fmt.Sprintf(" live order: loss · bars: %s latency · shared scale: %s", metric, scaleText) + return fmt.Sprintf(" order loss · bars %s · lower is better · scale %s", metric, scaleText) case "name": - return fmt.Sprintf(" live order: name · bars: %s latency · shared scale: %s", metric, scaleText) + return fmt.Sprintf(" order name · bars %s · lower is better · scale %s", metric, scaleText) case "cost": - return fmt.Sprintf(" live order: median latency · final latency cost after benchmark · shared scale: %s", scaleText) + return fmt.Sprintf(" order median · final ranking uses latency cost · scale %s", scaleText) default: - return fmt.Sprintf(" live order and bars: %s latency · shared scale: %s · lower is better", metric, scaleText) + return fmt.Sprintf(" %s · lower is better · scale %s", metric, scaleText) } } func (l *Live) statusLine(shown, active, inactive int) string { if l.width >= 80 { return Gray(fmt.Sprintf( - " showing top %d/%d active · %d sidelined · final report includes all", + " showing top %d/%d active resolvers · %d sidelined · final report includes all", shown, active, inactive, )) } - return Gray(fmt.Sprintf(" shown %d/%d active · %d sidelined", shown, active, inactive)) + return Gray(fmt.Sprintf(" shown %d/%d active resolvers · %d sidelined", shown, active, inactive)) +} + +func (l *Live) headerLine(active, inactive int) string { + switch l.phase { + case livePhaseTriage: + return "dnsbench — evaluating " + resolverCount(len(l.servers)) + case livePhaseWarmup: + return livePhaseHeader("warming up", active, inactive) + default: + return livePhaseHeader("benchmarking", active, inactive) + } +} + +func livePhaseHeader(action string, active, inactive int) string { + if inactive == 0 { + return fmt.Sprintf("dnsbench — %s %s", action, resolverCount(active)) + } + return fmt.Sprintf( + "dnsbench — %s %d active %s · %d sidelined", + action, + active, + resolverNoun(active), + inactive, + ) +} + +func resolverCount(n int) string { + return fmt.Sprintf("%d %s", n, resolverNoun(n)) +} + +func resolverNoun(n int) string { + if n == 1 { + return "resolver" + } + return "resolvers" +} + +func uiStateLabel(state model.ServerState) string { + if state == model.StateBenched { + return "sidelined" + } + return state.Label() } func (l *Live) writeFrameLine(b *strings.Builder, line string) { @@ -512,14 +848,16 @@ type liveRowLayout struct { showBars bool detailed bool tiny bool + categoryMarkers bool } func (l *Live) makeRowLayout(visible []*liveServer) liveRowLayout { layout := liveRowLayout{ - rankDigits: max(2, len(fmt.Sprint(max(1, len(l.servers))))), - showBars: l.width >= liveNarrowWidth, - detailed: l.width >= liveDetailedWidth, - tiny: l.width < liveTinyWidth, + rankDigits: max(2, len(fmt.Sprint(max(1, len(l.servers))))), + showBars: l.width >= liveNarrowWidth, + detailed: l.width >= liveDetailedWidth, + tiny: l.width < liveTinyWidth, + categoryMarkers: !ColorEnabled() && len(l.categories) > 1, } setReliabilityWidth := func() { @@ -538,6 +876,9 @@ func (l *Live) makeRowLayout(visible []*liveServer) liveRowLayout { fixed := func(withBar bool) int { n := layout.rankDigits + 2 + 2 + liveMsWidth + 2 + layout.reliabilityWidth + if layout.categoryMarkers { + n += 2 + } if withBar { n++ // Space between the track and latency value. } @@ -628,6 +969,10 @@ func (l *Live) serverLines(rank int, ls *liveServer, metric string, scale float6 b.WriteString(prefix) b.WriteString(name) b.WriteString(" ") + if layout.categoryMarkers { + b.WriteString(categoryMarker(c)) + b.WriteByte(' ') + } if layout.showBars { glyph := "▄" if i%2 == 1 { @@ -651,6 +996,19 @@ func (l *Live) serverLines(rank int, ls *liveServer, metric string, scale float6 return lines } +func categoryMarker(category model.Category) string { + switch category { + case model.CatCached: + return "C" + case model.CatUncached: + return "U" + case model.CatTLD: + return "T" + default: + return "?" + } +} + func (l *Live) latencyTrack(value, scale float64, width int, glyph string, color func(string) string, valid bool) string { if width <= 0 { return "" diff --git a/internal/ui/live_test.go b/internal/ui/live_test.go index b222956..75f4626 100644 --- a/internal/ui/live_test.go +++ b/internal/ui/live_test.go @@ -93,7 +93,7 @@ func TestLiveNonTTY(t *testing.T) { runLive(t, l, events) out := buf.String() for _, want := range []string{ - "dnsbench — benchmarking 2 servers", + "dnsbench — benchmarking 2 resolvers", "progress 10%", "progress 50%", "progress 100%", @@ -137,7 +137,7 @@ func TestLiveTTYClosedImmediately(t *testing.T) { if !strings.Contains(out, "\x1b[?1049h") || !strings.Contains(out, "\x1b[?1049l") { t.Errorf("TTY output missing alternate-screen lifecycle\n%q", out) } - if !strings.Contains(out, "dnsbench — benchmarking 2 servers") { + if !strings.Contains(out, "dnsbench — evaluating 2 resolvers") { t.Errorf("TTY output missing header\n%q", out) } } @@ -201,6 +201,194 @@ func TestLiveModelUpdatesStateWithoutWritingDirectly(t *testing.T) { } } +func TestLiveProgressShowsTriageWarmupAndBenchmarkPhases(t *testing.T) { + disableColors(t) + cfg := model.DefaultBenchConfig(model.ModeQuick) + cfg.Rounds = 250 + cfg.WarmupRounds = 2 + l := NewLive(liveServers(), cfg, &bytes.Buffer{}, true, "median") + l.width = 80 + + first := l.progressLine() + if !strings.Contains(first, "triaging resolvers…") { + t.Fatalf("initial progress is missing the triage phase: %q", first) + } + if strings.Contains(first, "/") { + t.Fatalf("triage progress contains a transient counter: %q", first) + } + if strings.Contains(first, "0%") || strings.Contains(first, "round 0/") { + t.Fatalf("initial progress should be indeterminate instead of showing zero: %q", first) + } + + updated, cmd := l.Update(livePrepareTickMsg{}) + if updated != l || cmd == nil { + t.Fatalf("preparation tick returned model=%T cmd=%v", updated, cmd) + } + if second := l.progressLine(); second == first { + t.Fatalf("triage bar did not move after a tick: %q", second) + } + + l.handle(model.Event{Type: model.EvQueryStart, ServerID: "a", Round: 0}) + if triage := l.progressLine(); !strings.Contains(triage, "triaging resolvers…") { + t.Fatalf("triage query changed the phase prematurely: %q", triage) + } + for _, server := range liveServers() { + l.handle(model.Event{Type: model.EvTriage, Triage: &model.TriageResult{ + ServerID: server.ID, + State: model.StateActive, + }}) + } + if warmup := l.progressLine(); !strings.Contains(warmup, "warming up resolvers…") { + t.Fatalf("completed triage did not enter warmup: %q", warmup) + } else if strings.Contains(warmup, "/") { + t.Fatalf("warmup progress contains a transient counter: %q", warmup) + } + + l.handle(model.Event{Type: model.EvQueryStart, ServerID: "a", Round: 0}) + if warmup := l.progressLine(); !strings.Contains(warmup, "warming up resolvers…") { + t.Fatalf("first warmup round is not visible: %q", warmup) + } + l.handle(model.Event{Type: model.EvRoundDone, Round: 0}) + l.handle(model.Event{Type: model.EvQueryStart, ServerID: "a", Round: 0}) + if warmup := l.progressLine(); !strings.Contains(warmup, "warming up resolvers…") { + t.Fatalf("second warmup round is not visible: %q", warmup) + } + + l.handle(model.Event{Type: model.EvQueryStart, ServerID: "a", Round: 1}) + running := l.progressLine() + if strings.Contains(running, "warming up") || strings.Contains(running, "triaging") { + t.Fatalf("progress remained in preparation after measurement started: %q", running) + } + if !strings.Contains(running, "0%") || !strings.Contains(running, "round 1/250") { + t.Fatalf("first measured query progress = %q, want 0%% and round 1/250", running) + } + + firstSample := sampleEvent("a", model.CatCached, 8, false) + firstSample.Sample.Round = 1 + l.handle(firstSample) + if inProgress := l.progressLine(); !strings.Contains(inProgress, "1%") { + t.Fatalf("first measured sample progress = %q, want 1%%", inProgress) + } + + l.handle(model.Event{Type: model.EvRoundDone, Round: 1}) + completed := l.progressLine() + if !strings.Contains(completed, " 1%") || !strings.Contains(completed, "round 1/250") { + t.Fatalf("first completed round progress = %q, want 1%% and round 1/250", completed) + } +} + +func TestLiveQuickProgressStartsAtZeroAndAdvancesWithinFirstRound(t *testing.T) { + disableColors(t) + cfg := model.DefaultBenchConfig(model.ModeQuick) + cfg.TriageEnabled = false + cfg.WarmupRounds = 0 + l := NewLive(liveServers(), cfg, &bytes.Buffer{}, true, "median") + l.width = 80 + + l.handle(model.Event{Type: model.EvQueryStart, ServerID: "a", Round: 1}) + started := l.progressLine() + if !strings.Contains(started, "0%") || !strings.Contains(started, "round 1/50") { + t.Fatalf("first quick round progress = %q, want 0%% and round 1/50", started) + } + if strings.ContainsAny(started, "░▒▓") || strings.Contains(started, "benchmarking ·") { + t.Fatalf("first quick round still uses the indeterminate benchmark bar: %q", started) + } + + firstSample := sampleEvent("a", model.CatCached, 8, false) + firstSample.Sample.Round = 1 + l.handle(firstSample) + if inProgress := l.progressLine(); !strings.Contains(inProgress, "1%") { + t.Fatalf("first quick sample progress = %q, want 1%% before round completion", inProgress) + } + + l.handle(model.Event{Type: model.EvRoundDone, Round: 1}) + completed := l.progressLine() + if !strings.Contains(completed, " 2%") { + t.Fatalf("first completed quick round progress = %q, want 2%%", completed) + } +} + +func TestLiveProgressDoesNotReach100BeforeFinalRound(t *testing.T) { + disableColors(t) + cfg := model.DefaultBenchConfig(model.ModeQuick) + cfg.Rounds = 250 + l := NewLive(liveServers(), cfg, &bytes.Buffer{}, true, "median") + l.width = 80 + + l.handle(model.Event{Type: model.EvRoundDone, Round: 249}) + penultimate := l.progressLine() + if !strings.Contains(penultimate, "99%") || strings.Contains(penultimate, "100%") { + t.Fatalf("penultimate progress = %q, want 99%% and not 100%%", penultimate) + } + + l.handle(model.Event{Type: model.EvRoundDone, Round: 250}) + if complete := l.progressLine(); !strings.Contains(complete, "100%") { + t.Fatalf("completed progress = %q, want 100%%", complete) + } +} + +func TestLiveProgressUpdatesElapsedOnClockAndETAOnCompletedRounds(t *testing.T) { + disableColors(t) + cfg := model.DefaultBenchConfig(model.ModeQuick) + cfg.TriageEnabled = false + cfg.WarmupRounds = 0 + cfg.Rounds = 20 + l := NewLive(liveServers(), cfg, &bytes.Buffer{}, true, "median") + l.width = 120 + base := time.Date(2026, time.July, 26, 12, 0, 0, 0, time.UTC) + l.benchmarkStart = base + + completeRound := func(round int, elapsed time.Duration) { + l.round = round + l.roundStarted = round + l.noteRoundCompleted(round, base.Add(elapsed)) + } + completeRound(1, 7*time.Second) + completeRound(2, 15*time.Second) + completeRound(3, 24*time.Second) + if progress := l.progressLine(); !strings.Contains(progress, "ETA ~") { + t.Fatalf("initial ETA should use the tilde placeholder: %q", progress) + } + + completeRound(4, 34*time.Second) + _, _ = l.Update(livePrepareTickMsg{at: base.Add(34 * time.Second)}) + progress := l.progressLine() + if !strings.Contains(progress, "elapsed 0:34") || !strings.Contains(progress, "ETA ~2:15") { + t.Fatalf("stable progress line = %q, want elapsed 0:34 and rounded ETA 2:15", progress) + } + if got, want := l.etaEstimate, 135*time.Second; got != want { + t.Fatalf("ETA = %s, want %s", got, want) + } + + l.handle(sampleEvent("a", model.CatCached, 8, false)) + if afterSample := l.progressLine(); afterSample != progress { + t.Fatalf("query callback changed timing text:\nbefore: %q\nafter: %q", progress, afterSample) + } + + _, cmd := l.Update(livePrepareTickMsg{at: base.Add(35 * time.Second)}) + if cmd == nil { + t.Fatal("clock tick did not schedule the next one-second update") + } + afterTick := l.progressLine() + if !strings.Contains(afterTick, "elapsed 0:35") || !strings.Contains(afterTick, "ETA ~2:15") { + t.Fatalf("clock tick changed ETA unexpectedly: %q", afterTick) + } +} + +func TestOscillatingBarBounces(t *testing.T) { + disableColors(t) + + if got, want := oscillatingBar(10, 0), "▏░▒▓█····▕"; got != want { + t.Fatalf("initial oscillating bar = %q, want %q", got, want) + } + if got, want := oscillatingBar(10, 4), "▏····░▒▓█▕"; got != want { + t.Fatalf("far edge oscillating bar = %q, want %q", got, want) + } + if got, want := oscillatingBar(10, 6), "▏··█▓▒░··▕"; got != want { + t.Fatalf("returning oscillating bar = %q, want %q", got, want) + } +} + func TestLiveLossAndInvalidAreLabeledSeparately(t *testing.T) { disableColors(t) cfg := model.DefaultBenchConfig(model.ModeQuick) @@ -226,8 +414,7 @@ func TestLiveLossAndInvalidAreLabeledSeparately(t *testing.T) { } func TestLiveHighlightsServerUnderTest(t *testing.T) { - SetColorEnabled(true) - t.Cleanup(func() { SetColorEnabled(false) }) + enableColors(t) cfg := model.DefaultBenchConfig(model.ModeQuick) cfg.Categories = []model.Category{model.CatCached} l := NewLive(liveServers(), cfg, &bytes.Buffer{}, true, "name") @@ -252,6 +439,9 @@ func TestLiveHighlightsServerUnderTest(t *testing.T) { if strings.Contains(l.renderFrame(), "\x1b[36mAlpha DNS") { t.Fatalf("highlight persisted after the query finished") } + if legend := l.legendLine(); !strings.Contains(legend, Cyan("name")+" = querying") { + t.Fatalf("legend does not explain the cyan activity state: %q", legend) + } } func TestLivePaceNoticeWrapsWithIcon(t *testing.T) { @@ -305,7 +495,7 @@ func TestLiveSharedP90ScaleMarksOutlier(t *testing.T) { t.Fatalf("P90 scale = %.1f, want 90.0", got) } frame := l.renderFrame() - if !strings.Contains(frame, "shared scale: P90 90.0 ms") { + if !strings.Contains(frame, "scale P90 90.0 ms") { t.Fatalf("frame missing shared scale:\n%s", frame) } if !strings.Contains(frame, "›") { @@ -336,7 +526,7 @@ func TestLiveLatencyMetricMatchesSort(t *testing.T) { t.Fatalf("latency metric = %q, want %q", got, sortKey) } frame := l.renderFrame() - if !strings.Contains(frame, "live order and bars: "+sortKey+" latency") { + if !strings.Contains(frame, sortKey+" · lower is better") { t.Fatalf("frame does not describe %s metric:\n%s", sortKey, frame) } if !strings.Contains(frame, wantValue) { @@ -350,10 +540,10 @@ func TestLiveNonLatencySortDescriptions(t *testing.T) { disableColors(t) cfg := model.DefaultBenchConfig(model.ModeQuick) for sortKey, want := range map[string]string{ - "loss": "live order: loss · bars: median latency", - "name": "live order: name · bars: median latency", - "cost": "live order: median latency · final latency cost after benchmark", - "score": "live order: median latency · final latency cost after benchmark", + "loss": "order loss · bars median · lower is better", + "name": "order name · bars median · lower is better", + "cost": "order median · final ranking uses latency cost", + "score": "order median · final ranking uses latency cost", } { t.Run(sortKey, func(t *testing.T) { l := NewLive(liveServers(), cfg, &bytes.Buffer{}, true, sortKey) @@ -404,9 +594,10 @@ func TestLiveResponsiveWidthsDoNotWrap(t *testing.T) { } } -func TestLiveServerRowsUseCompactBarsWithoutRepeatedLabelsOrMarkers(t *testing.T) { +func TestLiveServerRowsUseCategoryMarkersWithoutColor(t *testing.T) { disableColors(t) cfg := model.DefaultBenchConfig(model.ModeQuick) + cfg.Categories = model.AllCategories() l := NewLive(liveServers(), cfg, &bytes.Buffer{}, true, "median") l.width = 100 for i, category := range cfg.Categories { @@ -417,11 +608,13 @@ func TestLiveServerRowsUseCompactBarsWithoutRepeatedLabelsOrMarkers(t *testing.T if strings.Contains(lines, "cached") || strings.Contains(lines, "recursive/TLD") { t.Fatalf("server rows repeated category labels already present in legend:\n%s", lines) } - if strings.Contains(lines, "■") { - t.Fatalf("server rows repeated category markers already present in legend:\n%s", lines) + for _, marker := range []string{"C ", "U ", "T "} { + if !strings.Contains(lines, marker) { + t.Fatalf("server rows are missing the %q category marker:\n%s", marker, lines) + } } - if layout.barWidth < 32 { - t.Fatalf("compact layout = %+v, want the marker space reassigned to the bar", layout) + if !layout.categoryMarkers { + t.Fatalf("no-color layout did not reserve category markers: %+v", layout) } if strings.Contains(lines, "█") || !strings.Contains(lines, "▄") || !strings.Contains(lines, "▀") { t.Fatalf("server rows must use touching lower/upper half-height bars:\n%s", lines) @@ -492,7 +685,7 @@ func TestLiveTTYFrame(t *testing.T) { for _, want := range []string{ "Alpha DNS", "cached", "recursive/TLD", - "Beta DNS — out of contention: too slow in triage", + "Beta DNS — sidelined: too slow in triage", "connectivity lost — benchmark paused (default route gone)", "8.0 ms", } { diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go index f2a432c..af4f2bd 100644 --- a/internal/ui/spinner.go +++ b/internal/ui/spinner.go @@ -12,9 +12,7 @@ var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", " const spinnerInterval = 80 * time.Millisecond -// Spinner is an npm-style animated status line for a bounded, concurrent task. -// On a TTY it redraws a single line in place with a braille frame, a live -// count and elapsed time; off a TTY it degrades to one static start line. +// Spinner renders progress for a bounded concurrent task. type Spinner struct { out io.Writer isTTY bool @@ -22,7 +20,6 @@ type Spinner struct { total int count int64 - start time.Time stop chan struct{} done chan struct{} once sync.Once @@ -33,7 +30,6 @@ func NewSpinner(out io.Writer, isTTY bool, label string, total int) *Spinner { } func (s *Spinner) Start() { - s.start = time.Now() if !s.isTTY { fmt.Fprintf(s.out, "%s...\n", s.label) return @@ -68,7 +64,6 @@ func (s *Spinner) render(glyph string) { if s.total > 0 { suffix = fmt.Sprintf(" %d/%d", atomic.LoadInt64(&s.count), s.total) } - suffix += " " + formatElapsed(time.Since(s.start)) fmt.Fprintf(s.out, "\r\x1b[K%s %s%s", Cyan(glyph), s.label, Gray(suffix)) } diff --git a/internal/ui/spinner_test.go b/internal/ui/spinner_test.go index 6e8c6c1..ebbbda8 100644 --- a/internal/ui/spinner_test.go +++ b/internal/ui/spinner_test.go @@ -25,6 +25,20 @@ func TestSpinnerNonTTYPrintsStaticLines(t *testing.T) { } } +func TestSpinnerRenderShowsOnlyCount(t *testing.T) { + disableColors(t) + + var buf bytes.Buffer + s := NewSpinner(&buf, true, "Characterizing 2 servers", 2) + s.Inc() + s.render("⠋") + + const want = "\r\x1b[K⠋ Characterizing 2 servers 1/2" + if got := buf.String(); got != want { + t.Fatalf("rendered spinner = %q, want %q", got, want) + } +} + func TestSpinnerTTYRedrawsInPlaceAndClears(t *testing.T) { var buf bytes.Buffer s := NewSpinner(&buf, true, "Working", 2) @@ -35,6 +49,9 @@ func TestSpinnerTTYRedrawsInPlaceAndClears(t *testing.T) { if !strings.Contains(out, "\r\x1b[K") { t.Fatalf("TTY spinner should redraw in place with a carriage return + clear:\n%q", out) } + if !strings.Contains(out, "/2") { + t.Fatalf("TTY spinner should show the completed/total count:\n%q", out) + } if !strings.Contains(out, "finished") { t.Fatalf("TTY spinner missing final message:\n%q", out) } diff --git a/internal/ui/tables.go b/internal/ui/tables.go index 3a0f998..e9097bc 100644 --- a/internal/ui/tables.go +++ b/internal/ui/tables.go @@ -81,7 +81,7 @@ func RenderServerCharacteristics(res *model.RunResult) string { return strings.ToLower(servers[i].DisplayName()) < strings.ToLower(servers[j].DisplayName()) }) - headers := []string{" ", "resolver", "proto", "status", "dnssec", "nxdomain", "rebind"} + headers := []string{" ", "resolver", "proto", "status", "dnssec", "nxdomain"} rows := make([][]string, 0, len(servers)) for _, s := range servers { marker := "" @@ -96,7 +96,6 @@ func RenderServerCharacteristics(res *model.RunResult) string { statusCell(states, s.ID), dnssecCell(p), nxCell(p), - rebindCell(p), }) } legend := "" @@ -107,7 +106,7 @@ func RenderServerCharacteristics(res *model.RunResult) string { } func RenderServersTable(servers []model.Server, probes map[string]*model.ProbeResult, states map[string]model.ServerState) string { - headers := []string{"endpoint", "name", "operator", "protocol", "source", "scope", "status", "dnssec", "nxdomain", "rebind"} + headers := []string{"endpoint", "name", "operator", "protocol", "source", "scope", "status", "dnssec", "nxdomain"} rows := make([][]string, 0, len(servers)) for _, s := range servers { p := probes[s.ID] @@ -121,7 +120,6 @@ func RenderServersTable(servers []model.Server, probes map[string]*model.ProbeRe statusCell(states, s.ID), dnssecCell(p), nxCell(p), - rebindCell(p), }) } return renderTable(headers, nil, rows) @@ -135,7 +133,7 @@ func dashIfEmpty(s string) string { } func scopeCell(s model.Server) string { - if s.Protocol == model.ProtoDoH && s.Address == "" { + if s.Protocol.UsesURL() && s.Address == "" { return "remote" } return model.ScopeOfString(s.Address).Label() @@ -206,14 +204,7 @@ func nxCell(p *model.ProbeResult) string { return "?" } -func rebindCell(p *model.ProbeResult) string { - if p == nil { - return "?" - } - return coloredVerdict(p.Rebind.Overall) -} - -func RenderMetricsTable(res *model.RunResult, category model.Category, sortKey string) string { +func RenderMetricsTable(res *model.RunResult, category model.Category, sortKey string, terminalWidth ...int) string { key := normalizeSortKey(sortKey) type metricsRow struct { name string @@ -242,7 +233,7 @@ func RenderMetricsTable(res *model.RunResult, category model.Category, sortKey s return an < bn }) headers := []string{ - "server", "count", "ans", "valid", "tmo", "err", "sf", "trunc", "inv", "loss", "retry%", + "resolver", "count", "ans", "valid", "tmo", "err", "sf", "trunc", "inv", "loss", "retry%", "min", "max", "mean", "median", "stddev", "var", "p50", "p90", "p95", "p99", "ci95lo", "ci95hi", "jitter", "sf%", "inv%", "trunc%", } @@ -265,9 +256,70 @@ func RenderMetricsTable(res *model.RunResult, category model.Category, sortKey s FormatPct(d.ServfailPct), FormatPct(d.InvalidPct), FormatPct(d.TruncatedPct), }) } + if len(terminalWidth) > 0 && terminalWidth[0] > 0 { + headers, rightAlign, cells = fitMetricsTable(headers, rightAlign, cells, terminalWidth[0]) + } return renderTable(headers, rightAlign, cells) } +func fitMetricsTable(headers []string, rightAlign []bool, rows [][]string, width int) ([]string, []bool, [][]string) { + const ( + minNameWidth = 8 + maxNameWidth = 28 + ) + required := []int{0, 1, 9, 14, 19} + optional := []int{ + 3, 23, 10, 2, 4, 5, 8, 11, 13, 18, 20, + 12, 15, 6, 7, 24, 25, 26, 16, 17, 21, 22, + } + selected := append([]int(nil), required...) + for _, index := range optional { + candidate := append(append([]int(nil), selected...), index) + sort.Ints(candidate) + if metricsNameWidth(headers, rows, candidate, width) >= minNameWidth { + selected = candidate + } + } + sort.Ints(selected) + + nameWidth := metricsNameWidth(headers, rows, selected, width) + nameWidth = max(visibleWidth(headers[0]), min(maxNameWidth, nameWidth)) + + fittedHeaders := make([]string, len(selected)) + fittedAlign := make([]bool, len(selected)) + fittedRows := make([][]string, len(rows)) + for column, source := range selected { + fittedHeaders[column] = headers[source] + fittedAlign[column] = rightAlign[source] + } + for rowIndex, row := range rows { + fittedRows[rowIndex] = make([]string, len(selected)) + for column, source := range selected { + value := row[source] + if source == 0 && visibleWidth(value) > nameWidth { + value = strings.TrimRight(TruncatePad(value, nameWidth), " ") + } + fittedRows[rowIndex][column] = value + } + } + return fittedHeaders, fittedAlign, fittedRows +} + +func metricsNameWidth(headers []string, rows [][]string, selected []int, width int) int { + fixed := 2 * (len(selected) - 1) + for _, source := range selected { + if source == 0 { + continue + } + columnWidth := visibleWidth(headers[source]) + for _, row := range rows { + columnWidth = max(columnWidth, visibleWidth(row[source])) + } + fixed += columnWidth + } + return width - fixed +} + func metricValue(d *model.Distribution, key string) float64 { switch key { case "mean": diff --git a/internal/ui/tables_test.go b/internal/ui/tables_test.go index 097f0c3..a206356 100644 --- a/internal/ui/tables_test.go +++ b/internal/ui/tables_test.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "strings" "testing" @@ -20,14 +21,12 @@ func TestRenderServersTable(t *testing.T) { Reachable: true, DNSSEC: model.DNSSECInfo{Validating: model.VerdictYes}, NXInterception: model.VerdictNo, - Rebind: model.RebindInfo{Overall: model.VerdictYes}, }, "router": { ServerID: "router", Reachable: true, DNSSEC: model.DNSSECInfo{Validating: model.VerdictNo}, NXInterception: model.VerdictYes, - Rebind: model.RebindInfo{Overall: model.VerdictNo}, }, } states := map[string]model.ServerState{ @@ -37,7 +36,7 @@ func TestRenderServersTable(t *testing.T) { } out := RenderServersTable(servers, probes, states) for _, want := range []string{ - "endpoint", "name", "operator", "protocol", "source", "scope", "status", "dnssec", "nxdomain", "rebind", + "endpoint", "name", "operator", "protocol", "source", "scope", "status", "dnssec", "nxdomain", "1.1.1.1:53", "192.168.1.1:53", "https://dns.mullvad.net/dns-query", "Cloudflare Inc", "UDP/53", "DoH", @@ -58,7 +57,7 @@ func TestRenderServerCharacteristicsCompact(t *testing.T) { out := RenderServerCharacteristics(chartFixture()) for _, want := range []string{ "● current DNS resolver", - "resolver", "proto", "status", "dnssec", "nxdomain", "rebind", + "resolver", "proto", "status", "dnssec", "nxdomain", "Google Public DNS", "Cloudflare", "Quad9 Secure", "active", "sidelined", "unreachable", } { @@ -107,7 +106,7 @@ func TestRenderMetricsTableCells(t *testing.T) { disableColors(t) out := RenderMetricsTable(chartFixture(), model.CatCached, "median") for _, want := range []string{ - "server", "count", "ans", "loss", "min", "max", "mean", "median", + "resolver", "count", "ans", "loss", "min", "max", "mean", "median", "stddev", "var", "p50", "p90", "p95", "p99", "ci95lo", "ci95hi", "jitter", "8.0 ms", "12.0 ms", "2.0%", "100", } { @@ -116,3 +115,29 @@ func TestRenderMetricsTableCells(t *testing.T) { } } } + +func TestRenderMetricsTableFitsTerminalWidth(t *testing.T) { + disableColors(t) + for _, width := range []int{50, 60, 80, 120, 160} { + t.Run(fmt.Sprintf("width-%d", width), func(t *testing.T) { + out := RenderMetricsTable(chartFixture(), model.CatCached, "median", width) + for _, line := range strings.Split(strings.TrimSuffix(out, "\n"), "\n") { + if got := visibleWidth(line); got > width { + t.Fatalf("responsive metrics line width = %d, want <= %d:\n%s", got, width, out) + } + } + for _, want := range []string{"resolver", "count", "loss", "median", "p95"} { + if !strings.Contains(out, want) { + t.Errorf("responsive metrics table is missing essential column %q:\n%s", want, out) + } + } + }) + } + + out := RenderMetricsTable(chartFixture(), model.CatCached, "median", 80) + for _, unwanted := range []string{"stddev", "ci95lo", "ci95hi"} { + if strings.Contains(out, unwanted) { + t.Errorf("responsive metrics table retained low-priority column %q:\n%s", unwanted, out) + } + } +}