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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ 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

# Skip DNSSEC checks and exclude the DNSSEC penalty from the ranking
./dnsbench run --no-dnssec --open

# Characterize servers (DNSSEC and NXDOMAIN) without benchmarking
./dnsbench probe --builtin --verbose

Expand Down Expand Up @@ -191,9 +194,13 @@ The popular domains used by the cached benchmark are stored in [`internal/model/

## Reports and exports

`--no-dnssec` skips DNSSEC characterization and removes its penalty from every ranking mode, including when using custom `--weights`. Reachability, NXDOMAIN and optional `--extended` checks still run; other ranking penalties still apply. The flag is also available on `dnsbench probe`. It does not disable validation on the resolver itself. To keep DNSSEC checks but remove only their ranking penalty, use `--weights` with `penalty_no_dnssec_ms` set to `0` for the desired mode.

With `--no-dnssec`, the terminal and HTML/text reports state that DNSSEC checks and the penalty are disabled. Server characteristics show `skipped`; JSON records `config.no_dnssec: true` and `dnssec.skipped: true` in probe results. Skipped checks are distinct from an inconclusive (`unknown`) validation result.

The terminal prints a compact report: a one-line run summary, a single ranking (bar · latency cost · loss, with your current DNS marked and statistical ties flagged) and a footer. Lower latency cost is better. A `*` after the cost means penalties are part of it, so the number sits above the measured latency — the HTML report breaks down which penalties and by how much. `--details` adds the per-category metrics table.

For a full, shareable report use `--export html`, or `--open`, which writes the HTML report and opens it in your browser when the run finishes. The file is self-contained — verdict, full ranking, an embedded chart, latency-cost breakdown, server characteristics, detailed metrics and the factual DNS-configuration and comparison sections — so it needs no network and adapts to light or dark themes. Available formats are `json`, `csv`, `txt` and `html`; `--out` sets the directory, `--prefix` the file name, and `--include-raw` keeps per-query samples in the JSON. The CSV has one row per server and category, and closes each row with the selected ranking's `ranking_mode`, `rank`, `cost_base_ms`, `cost_penalty_ms` and `latency_cost_ms` so the ranking can be reproduced in a spreadsheet; servers that never earned a rank leave those cells empty.
For a full, shareable report use `--export html`, or `--open`, which writes the HTML report and opens it in your browser when the run finishes. The file is self-contained — verdict, full ranking, an embedded chart, latency-cost breakdown, server characteristics, detailed metrics and the factual DNS-configuration and comparison sections — so it needs no network and adapts to light or dark themes. Available formats are `json`, `csv`, `txt` and `html`; `--out` sets the directory, `--prefix` the file name, and `--include-raw` keeps per-query samples in the JSON. The CSV has one row per server and category and includes the selected ranking's `ranking_mode`, `rank`, `cost_base_ms`, `cost_penalty_ms` and `latency_cost_ms` so the ranking can be reproduced in a spreadsheet; servers that never earned a rank leave those cells empty. The final `dnssec_validation` column records `yes`, `no`, `partial`, `unknown`, `skipped` or `not probed`.

## Measurement pacing

Expand Down
4 changes: 3 additions & 1 deletion cmd/dnsbench/probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ func filterOnly(servers []model.Server, only []string) []model.Server {

func newProbeCmd() *cobra.Command {
var sel selectionFlags
var extended, verbose bool
var extended, verbose, noDNSSEC bool
var timeout time.Duration
var concurrency int
var jsonPath string
Expand All @@ -248,6 +248,7 @@ validation and NXDOMAIN handling. No latency benchmark is run; use
}
cfg := probe.DefaultConfig()
cfg.Extended = extended
cfg.SkipDNSSEC = noDNSSEC
if timeout > 0 {
cfg.Timeout = timeout
}
Expand Down Expand Up @@ -280,6 +281,7 @@ validation and NXDOMAIN handling. No latency benchmark is run; use
}
registerSelectionFlags(cmd, &sel)
cmd.Flags().BoolVar(&extended, "extended", false, "run extended checks (DNS64, QNAME minimization, HTTPS records)")
cmd.Flags().BoolVar(&noDNSSEC, "no-dnssec", false, "skip DNSSEC checks")
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")
Expand Down
26 changes: 24 additions & 2 deletions cmd/dnsbench/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
type runFlags struct {
sel selectionFlags
skipProbe bool
noDNSSEC bool
extended bool
mode string
rounds int
Expand Down Expand Up @@ -82,6 +83,7 @@ time of day.`,
registerSelectionFlags(cmd, &f.sel)
base := model.DefaultBenchConfig(model.ModeStandard)
cmd.Flags().BoolVar(&f.skipProbe, "skip-probe", false, "skip the characterization phase")
cmd.Flags().BoolVar(&f.noDNSSEC, "no-dnssec", false, "skip DNSSEC checks and remove the DNSSEC penalty from all rankings")
cmd.Flags().BoolVar(&f.extended, "extended", false, "run extended probe checks (DNS64, QNAME minimization, HTTPS records)")
cmd.Flags().StringVar(&f.mode, "mode", string(model.ModeStandard), "benchmark mode: quick, standard, precise or custom")
cmd.Flags().Int("rounds", 0, "rounds per category (overrides the mode; required with --mode custom)")
Expand Down Expand Up @@ -153,7 +155,7 @@ func executeRun(cmd *cobra.Command, f *runFlags) error {
if !f.quiet {
printForwarderNotes(out, systemServers(selection))
}
weights, err := loadWeights(f.weightsFile)
weights, err := loadRunWeights(f)
if err != nil {
return err
}
Expand All @@ -170,7 +172,11 @@ 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", countNoun(len(selection.servers), "resolver"))
checks := "DNSSEC, NXDOMAIN"
if f.noDNSSEC {
checks = "NXDOMAIN"
}
label := fmt.Sprintf("Characterizing %s — %s", countNoun(len(selection.servers), "resolver"), checks)
spinner = ui.NewSpinner(out, stdoutIsTTY(), label, len(selection.servers))
spinner.Start()
}
Expand Down Expand Up @@ -355,6 +361,7 @@ func systemServers(selection selectedServers) []model.Server {
func runProbePhase(ctx context.Context, servers []model.Server, f *runFlags, onResult func()) map[string]*model.ProbeResult {
cfg := probe.DefaultConfig()
cfg.Extended = f.extended
cfg.SkipDNSSEC = f.noDNSSEC
cfg.UncachedZone = f.uncachedZone
cfg.OnResult = onResult
if f.timeout > 0 {
Expand All @@ -368,6 +375,7 @@ func runProbePhase(ctx context.Context, servers []model.Server, f *runFlags, onR

func buildBenchConfig(f *runFlags, mode model.Mode, session model.SessionMode) (model.BenchConfig, []string) {
cfg := model.DefaultBenchConfig(mode)
cfg.NoDNSSEC = f.noDNSSEC
var notices []string
if f.rounds > 0 {
cfg.Rounds = f.rounds
Expand Down Expand Up @@ -403,6 +411,20 @@ func buildBenchConfig(f *runFlags, mode model.Mode, session model.SessionMode) (
return cfg, notices
}

func loadRunWeights(f *runFlags) (map[model.RankMode]model.Weights, error) {
weights, err := loadWeights(f.weightsFile)
if err != nil {
return nil, err
}
if f.noDNSSEC {
for mode, w := range weights {
w.PenaltyNoDNSSECMs = 0
weights[mode] = w
}
}
return weights, nil
}

func loadWeights(path string) (map[model.RankMode]model.Weights, error) {
weights := rank.Presets()
if path == "" {
Expand Down
44 changes: 44 additions & 0 deletions cmd/dnsbench/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import (
"bytes"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"

"dnsbench/internal/model"

"github.com/spf13/cobra"
)

func TestRunCommandModernDefaults(t *testing.T) {
Expand Down Expand Up @@ -56,6 +59,47 @@ func TestProbeCommandHasNoPacingFlag(t *testing.T) {
}
}

func TestNoDNSSECFlagDefaultsOff(t *testing.T) {
for _, cmd := range []*cobra.Command{newRunCmd(), newProbeCmd()} {
flag := cmd.Flags().Lookup("no-dnssec")
if flag == nil || flag.DefValue != "false" {
t.Fatalf("%s must expose --no-dnssec defaulting to false", cmd.Name())
}
if err := cmd.ParseFlags([]string{"--no-dnssec"}); err != nil {
t.Fatal(err)
}
}
}

func TestNoDNSSECOverridesCustomWeights(t *testing.T) {
path := filepath.Join(t.TempDir(), "weights.json")
if err := os.WriteFile(path, []byte(`{"latency":{"penalty_no_dnssec_ms":99,"penalty_nx_interception_ms":17}}`), 0o600); err != nil {
t.Fatal(err)
}
for _, weightsFile := range []string{"", path} {
flags := runFlags{weightsFile: weightsFile}
before, err := loadRunWeights(&flags)
if err != nil {
t.Fatal(err)
}
flags.noDNSSEC = true
after, err := loadRunWeights(&flags)
if err != nil {
t.Fatal(err)
}
for mode, want := range before {
want.PenaltyNoDNSSECMs = 0
if !reflect.DeepEqual(after[mode], want) {
t.Errorf("%s: DNSSEC override changed other weights: %+v", mode, after[mode])
}
}
cfg, _ := buildBenchConfig(&flags, model.ModeStandard, model.SessionPersistent)
if !cfg.NoDNSSEC {
t.Fatal("run configuration lost --no-dnssec")
}
}
}

func TestMatchSystemServerIDsByEndpoint(t *testing.T) {
selected := []model.Server{
{ID: "builtin-quad9", Address: "9.9.9.9", Port: 53, Protocol: model.ProtoUDP, Source: model.SourceBuiltin},
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ 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` and repeatable one-off `--server` entries) enter the benchmark; selected endpoints are merged by key and filtered (`--protocols`, `--no-ipv6`, `--only`). If no source flag or one-off endpoint is given, all three sources are included. Supplying `--server` disables that automatic three-source default while preserving any explicitly selected sources and server file. An explicitly selected `--system` source is exempt from `--protocols`, while `--no-ipv6` and `--only` remain in force.
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, 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.
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`. `--no-dnssec` skips only the DNSSEC matrix, marks `DNSSECInfo.Skipped`, and zeros the DNSSEC penalty in all effective ranking weights. 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.
Expand Down
4 changes: 3 additions & 1 deletion docs/FORMULAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ invalid-response = PenaltyPerInvalidPctMs · (invalid / totalQueries
retry = PenaltyPerRetryPctMs · (retried / totalQueries · 100)
jitter = JitterWeight · ( Σ_{c∈C} jitter_c / |C| )
nxdomain-interception = PenaltyNXInterceptionMs if NX interception verdict = yes
no-dnssec = PenaltyNoDNSSECMs if DNSSEC validating verdict ≠ yes
no-dnssec = PenaltyNoDNSSECMs if DNSSEC was not skipped and validating verdict ≠ yes
```

```
Expand Down Expand Up @@ -167,6 +167,8 @@ The Loss, SERVFAIL, Invalid and Retry columns are milliseconds per percentage po

Custom weights can be merged over these presets per mode with `--weights <file.json>`.

`--no-dnssec` skips DNSSEC checks and forces `PenaltyNoDNSSECMs` to zero in every mode after custom weights are merged. The same effective weights are used for ranking, bootstrap comparisons and reports. Other penalties remain enabled. No characterization penalties apply when the entire probe phase is skipped with `--skip-probe`.

## Worked ranking example

Browsing mode, all three categories present, medians: cached 8.0 ms, uncached 22.0 ms, tld 30.0 ms. Weights sum to 1, so no renormalization:
Expand Down
2 changes: 2 additions & 0 deletions docs/METHODOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ The server's `NX interception` verdict is **yes** if any check was intercepted,

## DNSSEC: the DO/CD matrix

`--no-dnssec` (on `run` or `probe`) skips the three DNSSEC queries below while retaining the other characterization checks. Skipped results carry `dnssec.skipped: true`; their verdicts remain `unknown` and reports label them `skipped`. On `run`, the flag also sets the DNSSEC penalty to zero in every ranking mode, overriding custom weights. It does not change the resolver's own validation behavior or remove other ranking penalties. `--skip-probe` skips the entire characterization phase instead.

Three probe queries build the matrix (DO = DNSSEC OK bit, CD = Checking Disabled bit):

| Query | DO | CD | What it observes |
Expand Down
4 changes: 3 additions & 1 deletion internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (

const (
AppName = "dnsbench"
AppVersion = "0.7.1"
AppVersion = "0.8.0"
)

type Protocol string
Expand Down Expand Up @@ -392,6 +392,7 @@ type NXCheck struct {
}

type DNSSECInfo struct {
Skipped bool `json:"skipped,omitempty"`
ReturnsRRSIG Verdict `json:"returns_rrsig"`
SignedResolves Verdict `json:"signed_resolves"`
BogusServfail Verdict `json:"bogus_servfail"`
Expand Down Expand Up @@ -618,6 +619,7 @@ const (
)

type BenchConfig struct {
NoDNSSEC bool `json:"no_dnssec,omitempty"`
Mode Mode `json:"mode"`
Rounds int `json:"rounds"`
WarmupRounds int `json:"warmup_rounds"`
Expand Down
1 change: 1 addition & 0 deletions internal/probe/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Config struct {
Retries int
Concurrency int
Extended bool
SkipDNSSEC bool
Factory transport.Factory
OnResult func()
}
Expand Down
5 changes: 4 additions & 1 deletion internal/probe/probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +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)
res.DNSSEC.Skipped = cfg.SkipDNSSEC
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())
Expand Down Expand Up @@ -91,7 +92,9 @@ func (p *prober) run(ctx context.Context) {
if p.canceled(ctx) {
return
}
p.checkDNSSEC(ctx)
if !p.cfg.SkipDNSSEC {
p.checkDNSSEC(ctx)
}
if p.canceled(ctx) {
return
}
Expand Down
49 changes: 47 additions & 2 deletions internal/probe/probe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package probe

import (
"context"
"errors"
"net"
"strings"
"testing"
Expand Down Expand Up @@ -95,10 +96,12 @@ func TestDefaultConfig(t *testing.T) {
}

type probeTestQuerier struct {
onQuery func()
onQuery func()
questions []transport.Question
}

func (q *probeTestQuerier) Query(context.Context, transport.Question) model.QueryResult {
func (q *probeTestQuerier) Query(_ context.Context, question transport.Question) model.QueryResult {
q.questions = append(q.questions, question)
if q.onQuery != nil {
q.onQuery()
}
Expand All @@ -111,6 +114,48 @@ func (q *probeTestQuerier) Query(context.Context, transport.Question) model.Quer
func (*probeTestQuerier) Protocol() model.Protocol { return model.ProtoUDP }
func (*probeTestQuerier) Close() error { return nil }

func TestSkipDNSSECKeepsOtherChecks(t *testing.T) {
cfg := testConfig("")
cfg.SkipDNSSEC = true
cfg.Extended = true
q := &probeTestQuerier{}
cfg.Factory = func(model.Server, transport.Options) (transport.Querier, error) {
return q, nil
}
pr := Run(context.Background(), []model.Server{mockServerEntry("mock", 53)}, cfg)["mock"]
if !pr.DNSSEC.Skipped || pr.DNSSEC.Validating != model.VerdictUnknown {
t.Fatalf("skipped DNSSEC must remain untested: %+v", pr.DNSSEC)
}
if !pr.Reachable || pr.SupportsA != model.VerdictYes || len(pr.NXChecks) != 3 || pr.Extended == nil {
t.Fatalf("other characterization checks did not run: %+v", pr)
}
var baselineAAAA, reverse, https bool
for _, question := range q.questions {
if question.DO || question.CD || question.Name == cfg.BogusDomain ||
(question.Name == cfg.SignedDomain && question.Qtype == dns.TypeA) {
t.Errorf("DNSSEC query sent with SkipDNSSEC: %+v", question)
}
baselineAAAA = baselineAAAA || question.Name == cfg.ReachabilityDomain && question.Qtype == dns.TypeAAAA
reverse = reverse || question.Qtype == dns.TypePTR
https = https || question.Name == cfg.SignedDomain && question.Qtype == dns.TypeHTTPS
}
if !baselineAAAA || !reverse || !https {
t.Fatalf("missing non-DNSSEC queries: %+v", q.questions)
}
}

func TestSkipDNSSECRecordedOnTransportFailure(t *testing.T) {
cfg := testConfig("")
cfg.SkipDNSSEC = true
cfg.Factory = func(model.Server, transport.Options) (transport.Querier, error) {
return nil, errors.New("transport unavailable")
}
pr := Run(context.Background(), []model.Server{{ID: "mock"}}, cfg)["mock"]
if !pr.DNSSEC.Skipped || len(pr.Errors) == 0 {
t.Fatalf("transport failure lost the skipped status: %+v", pr)
}
}

func TestRunUsesPersistentQuerierForCharacterization(t *testing.T) {
cfg := testConfig("")
var got transport.Options
Expand Down
2 changes: 1 addition & 1 deletion internal/rank/rank.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func computePenalties(st *model.ServerStats, cats []model.Category, probe *model
if probe.NXInterception == model.VerdictYes {
addPenalty(pens, penaltyNXInterception, w.PenaltyNXInterceptionMs)
}
if probe.DNSSEC.Validating != model.VerdictYes {
if !probe.DNSSEC.Skipped && probe.DNSSEC.Validating != model.VerdictYes {
addPenalty(pens, penaltyNoDNSSEC, w.PenaltyNoDNSSECMs)
}
}
Expand Down
Loading
Loading