Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Build artifacts
/releases/

# Logs
*.log

# Generated benchmark reports
/dnsbench-*.html
/reports/
Expand Down
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <command> --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.

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

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions cmd/dnsbench/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 10 additions & 12 deletions cmd/dnsbench/intro.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
},
{
Expand Down
42 changes: 19 additions & 23 deletions cmd/dnsbench/probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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())
Expand Down
24 changes: 19 additions & 5 deletions cmd/dnsbench/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package main

import (
"fmt"
"io"
"os"

"github.com/charmbracelet/x/term"
"github.com/spf13/cobra"

"dnsbench/internal/model"
Expand All @@ -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.

Expand All @@ -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,
Expand All @@ -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."
Loading
Loading