diff --git a/README.md b/README.md index 3b80b2c..b8fc519 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/cmd/dnsbench/probe.go b/cmd/dnsbench/probe.go index a14f779..de8481b 100644 --- a/cmd/dnsbench/probe.go +++ b/cmd/dnsbench/probe.go @@ -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 @@ -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 } @@ -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") diff --git a/cmd/dnsbench/run.go b/cmd/dnsbench/run.go index fff56bd..a3585cc 100644 --- a/cmd/dnsbench/run.go +++ b/cmd/dnsbench/run.go @@ -29,6 +29,7 @@ import ( type runFlags struct { sel selectionFlags skipProbe bool + noDNSSEC bool extended bool mode string rounds int @@ -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)") @@ -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 } @@ -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() } @@ -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 { @@ -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 @@ -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 == "" { diff --git a/cmd/dnsbench/run_test.go b/cmd/dnsbench/run_test.go index aa3197c..a82db46 100644 --- a/cmd/dnsbench/run_test.go +++ b/cmd/dnsbench/run_test.go @@ -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) { @@ -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}, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0ce3329..d4f2ac3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/FORMULAS.md b/docs/FORMULAS.md index 1d53449..1a02b6e 100644 --- a/docs/FORMULAS.md +++ b/docs/FORMULAS.md @@ -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 ``` ``` @@ -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 `. +`--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: diff --git a/docs/METHODOLOGY.md b/docs/METHODOLOGY.md index a70b8bc..c39ae94 100644 --- a/docs/METHODOLOGY.md +++ b/docs/METHODOLOGY.md @@ -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 | diff --git a/internal/model/model.go b/internal/model/model.go index 3125632..0ea53d0 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -10,7 +10,7 @@ import ( const ( AppName = "dnsbench" - AppVersion = "0.7.1" + AppVersion = "0.8.0" ) type Protocol string @@ -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"` @@ -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"` diff --git a/internal/probe/config.go b/internal/probe/config.go index 58680da..d7d5e77 100644 --- a/internal/probe/config.go +++ b/internal/probe/config.go @@ -17,6 +17,7 @@ type Config struct { Retries int Concurrency int Extended bool + SkipDNSSEC bool Factory transport.Factory OnResult func() } diff --git a/internal/probe/probe.go b/internal/probe/probe.go index 905fe5e..63ba9ce 100644 --- a/internal/probe/probe.go +++ b/internal/probe/probe.go @@ -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()) @@ -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 } diff --git a/internal/probe/probe_test.go b/internal/probe/probe_test.go index 87e8e7e..c3df08e 100644 --- a/internal/probe/probe_test.go +++ b/internal/probe/probe_test.go @@ -2,6 +2,7 @@ package probe import ( "context" + "errors" "net" "strings" "testing" @@ -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() } @@ -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 diff --git a/internal/rank/rank.go b/internal/rank/rank.go index 809acb8..9dd5c93 100644 --- a/internal/rank/rank.go +++ b/internal/rank/rank.go @@ -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) } } diff --git a/internal/rank/rank_test.go b/internal/rank/rank_test.go index ac5b612..beefb22 100644 --- a/internal/rank/rank_test.go +++ b/internal/rank/rank_test.go @@ -273,6 +273,36 @@ func TestNoDNSSECPenaltyExact(t *testing.T) { } } +func TestSkippedDNSSECRemovesOnlyDNSSECPenalty(t *testing.T) { + stats := map[string]*model.ServerStats{ + "s1": activeStats("s1", map[model.Category]*model.Distribution{ + model.CatCached: {Count: 10, Answered: 9, MedianMs: 10, JitterMs: 2}, + }), + } + for mode, weights := range Presets() { + t.Run(string(mode), func(t *testing.T) { + probe := cleanProbe("s1") + probe.DNSSEC.Validating = model.VerdictUnknown + probe.NXInterception = model.VerdictYes + probes := map[string]*model.ProbeResult{"s1": probe} + before := findScore(t, ScoreServers(stats, probes, []model.Category{model.CatCached}, weights, mode), "s1") + probe.DNSSEC.Skipped = true + after := findScore(t, ScoreServers(stats, probes, []model.Category{model.CatCached}, weights, mode), "s1") + if _, ok := after.Penalties[penaltyNoDNSSEC]; ok { + t.Fatal("skipped DNSSEC was penalized") + } + if !almostEqual(before.TotalMs-after.TotalMs, weights.PenaltyNoDNSSECMs) { + t.Fatalf("unexpected score change: before=%+v after=%+v", before, after) + } + for _, key := range []string{penaltyLoss, penaltyJitter, penaltyNXInterception} { + if after.Penalties[key] <= 0 || after.Penalties[key] != before.Penalties[key] { + t.Errorf("%s penalty changed: before=%v after=%v", key, before.Penalties[key], after.Penalties[key]) + } + } + }) + } +} + func TestZeroPenaltiesStayOutOfMap(t *testing.T) { w := singleCatWeights("median") w.PenaltyPerLossPctMs = 5 diff --git a/internal/report/export_csv.go b/internal/report/export_csv.go index 4e600df..08e98c0 100644 --- a/internal/report/export_csv.go +++ b/internal/report/export_csv.go @@ -16,6 +16,7 @@ var csvColumns = []string{ "p50_ms", "p90_ms", "p95_ms", "p99_ms", "ci95_low_ms", "ci95_high_ms", "jitter_ms", "servfail_pct", "invalid_pct", "truncated_pct", "ranking_mode", "rank", "cost_base_ms", "cost_penalty_ms", "latency_cost_ms", + "dnssec_validation", } func ExportCSV(w io.Writer, res *model.RunResult) error { @@ -55,6 +56,14 @@ func ExportCSV(w io.Writer, res *model.RunResult) error { csvFloat(d.JitterMs), csvFloat(d.ServfailPct), csvFloat(d.InvalidPct), csvFloat(d.TruncatedPct), } row = append(row, csvRanking(scores, modeCell, s.ID)...) + dnssec := "not probed" + if p := res.Probes[s.ID]; p != nil { + dnssec = string(p.DNSSEC.Validating) + if p.DNSSEC.Skipped { + dnssec = "skipped" + } + } + row = append(row, dnssec) if err := cw.Write(row); err != nil { return err } diff --git a/internal/report/export_html.go b/internal/report/export_html.go index 6108bbb..a445877 100644 --- a/internal/report/export_html.go +++ b/internal/report/export_html.go @@ -432,6 +432,9 @@ func statusDotCell(v model.Verdict, goodWhenYes bool, yesLabel, noLabel string) func writeHTMLCharacteristics(b *strings.Builder, res *model.RunResult) { b.WriteString("
\n

Server characteristics

\n") + if res.Config.NoDNSSEC { + b.WriteString("

DNSSEC checks and penalty disabled (--no-dnssec).

\n") + } b.WriteString("
\n\n\n") for i := range res.Servers { s := &res.Servers[i] @@ -449,6 +452,9 @@ func writeHTMLCharacteristics(b *strings.Builder, res *model.RunResult) { dnssec, nx := "not probed", "not probed" if p != nil { dnssec = statusDotCell(p.DNSSEC.Validating, true, "validating", "not validating") + if p.DNSSEC.Skipped { + dnssec = "skipped" + } nx = statusDotCell(p.NXInterception, false, "intercepts", "honest") } fmt.Fprintf(b, "\n", diff --git a/internal/report/export_text.go b/internal/report/export_text.go index 8114ac2..7448449 100644 --- a/internal/report/export_text.go +++ b/internal/report/export_text.go @@ -24,6 +24,9 @@ func ExportText(w io.Writer, res *model.RunResult) error { cfg := res.Config fmt.Fprintf(&b, "Configuration: mode %s, %d rounds, categories %s, timeout %s, session %s, seed %d\n", cfg.Mode, cfg.Rounds, categoryList(cfg.Categories), cfg.Timeout, cfg.Session, cfg.Seed) + if cfg.NoDNSSEC { + b.WriteString("DNSSEC checks and penalty disabled (--no-dnssec).\n") + } sel := selectedMode(res) fmt.Fprintf(&b, "Ranking mode: %s\n", sel.Label()) b.WriteString("Latency cost: weighted latency plus penalties, in ms — lower is better\n") diff --git a/internal/report/report_test.go b/internal/report/report_test.go index 036de3d..74adb48 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -3,7 +3,9 @@ package report import ( "bytes" "encoding/csv" + "encoding/json" "encoding/xml" + "io" "os" "path/filepath" "reflect" @@ -12,6 +14,7 @@ import ( "time" "dnsbench/internal/model" + "dnsbench/internal/rank" ) func dist(median, mean, loss, servfail float64, samples []float64) *model.Distribution { @@ -326,23 +329,26 @@ func TestExportCSV(t *testing.T) { if len(records) != 7 { t.Fatalf("expected header plus 6 rows, got %d records", len(records)) } - if len(records[0]) != 41 { - t.Fatalf("expected 41 columns, got %d", len(records[0])) + if len(records[0]) != 42 { + t.Fatalf("expected 42 columns, got %d", len(records[0])) } if records[0][0] != "server_id" || records[0][35] != "truncated_pct" { t.Fatalf("pre-existing columns must keep their position: %v", records[0]) } - if got := records[0][36:]; !reflect.DeepEqual(got, []string{"ranking_mode", "rank", "cost_base_ms", "cost_penalty_ms", "latency_cost_ms"}) { + if got := records[0][36:41]; !reflect.DeepEqual(got, []string{"ranking_mode", "rank", "cost_base_ms", "cost_penalty_ms", "latency_cost_ms"}) { t.Fatalf("unexpected ranking columns: %v", got) } seen := map[string]bool{} ranking := map[string][]string{} for _, row := range records[1:] { - if len(row) != 41 { - t.Fatalf("row has %d columns, expected 41: %v", len(row), row) + if len(row) != 42 { + t.Fatalf("row has %d columns, expected 42: %v", len(row), row) } seen[row[0]+"|"+row[7]] = true - ranking[row[0]] = row[36:] + ranking[row[0]] = row[36:41] + if records[0][41] != "dnssec_validation" || row[41] != string(testResult().Probes[row[0]].DNSSEC.Validating) { + t.Fatalf("unexpected DNSSEC validation column: %v", row) + } if !strings.Contains(row[23], ".") { t.Fatalf("median_ms should use dot decimal: %q", row[23]) } @@ -385,14 +391,81 @@ func TestExportCSVLeavesUnrankedServersEmpty(t *testing.T) { if row[0] != "ghost" { continue } - if got := row[36:]; !reflect.DeepEqual(got, []string{"browsing", "", "", "", ""}) { + if got := row[36:41]; !reflect.DeepEqual(got, []string{"browsing", "", "", "", ""}) { t.Fatalf("unranked server must have empty ranking cells, got %v", got) } + if row[41] != "not probed" { + t.Fatalf("unprobed server DNSSEC = %q", row[41]) + } return } t.Fatal("no row emitted for the unranked server") } +func TestExportsWithDNSSECDisabled(t *testing.T) { + res := testResult() + res.Config.NoDNSSEC = true + for _, p := range res.Probes { + p.DNSSEC = model.DNSSECInfo{Skipped: true, Validating: model.VerdictUnknown} + } + for mode, weights := range res.Weights { + weights.PenaltyNoDNSSECMs = 0 + res.Weights[mode] = weights + res.Scores[mode] = rank.ScoreServers(res.Stats, res.Probes, res.Config.Categories, weights, mode) + } + for name, export := range map[string]func(io.Writer) error{ + "text": func(w io.Writer) error { return ExportText(w, res) }, + "html": func(w io.Writer) error { return ExportHTML(w, res, model.RankBrowsing) }, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + if err := export(&buf); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "DNSSEC checks and penalty disabled (--no-dnssec)") || !strings.Contains(out, "no DNSSEC 0.0") { + t.Fatalf("report omitted disabled checks or zero penalty:\n%s", out) + } + if name == "html" { + if strings.Count(out, ">skipped") != len(res.Probes) || strings.Contains(out, ">not validating<") { + t.Fatal("HTML must distinguish skipped DNSSEC from failed validation") + } + } + }) + } + var buf bytes.Buffer + if err := ExportJSON(&buf, res, false); err != nil { + t.Fatal(err) + } + var decoded model.RunResult + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + if !decoded.Config.NoDNSSEC || !decoded.Probes["system-1"].DNSSEC.Skipped { + t.Fatal("JSON lost the disabled/skipped DNSSEC settings") + } + for _, scores := range decoded.Scores { + for _, score := range scores { + if _, ok := score.Penalties["no-dnssec"]; ok { + t.Fatal("JSON still contains a DNSSEC penalty") + } + } + } + buf.Reset() + if err := ExportCSV(&buf, res); err != nil { + t.Fatal(err) + } + rows, err := csv.NewReader(&buf).ReadAll() + if err != nil { + t.Fatal(err) + } + for _, row := range rows[1:] { + if row[41] != "skipped" { + t.Fatalf("CSV DNSSEC validation = %q, want skipped", row[41]) + } + } +} + func TestChartSVGIsValidXML(t *testing.T) { for _, theme := range []svgTheme{svgLight, svgDark} { var b strings.Builder diff --git a/internal/ui/chart.go b/internal/ui/chart.go index f3bcef6..03f9cde 100644 --- a/internal/ui/chart.go +++ b/internal/ui/chart.go @@ -16,6 +16,9 @@ const ( func RenderRunSummary(res *model.RunResult) string { parts := []string{fmt.Sprintf("%d resolvers", len(res.Servers))} + if res.Config.NoDNSSEC { + parts = append(parts, "DNSSEC checks and penalty disabled") + } if res.Config.Mode != "" { parts = append(parts, string(res.Config.Mode)+" mode") } diff --git a/internal/ui/tables.go b/internal/ui/tables.go index e9097bc..a82010f 100644 --- a/internal/ui/tables.go +++ b/internal/ui/tables.go @@ -186,6 +186,9 @@ func dnssecCell(p *model.ProbeResult) string { if p == nil { return "?" } + if p.DNSSEC.Skipped { + return "skipped" + } return coloredVerdict(p.DNSSEC.Validating) } diff --git a/internal/ui/tables_test.go b/internal/ui/tables_test.go index a206356..4f92e23 100644 --- a/internal/ui/tables_test.go +++ b/internal/ui/tables_test.go @@ -8,6 +8,27 @@ import ( "dnsbench/internal/model" ) +func TestSkippedDNSSECInTablesAndRunSummary(t *testing.T) { + disableColors(t) + res := chartFixture() + res.Config.NoDNSSEC = true + res.Probes = map[string]*model.ProbeResult{} + for _, server := range res.Servers { + res.Probes[server.ID] = &model.ProbeResult{DNSSEC: model.DNSSECInfo{Skipped: true, Validating: model.VerdictUnknown}} + } + for _, out := range []string{ + RenderServersTable(res.Servers, res.Probes, nil), + RenderServerCharacteristics(res), + } { + if strings.Count(out, "skipped") != len(res.Servers) { + t.Errorf("table must mark each DNSSEC check as skipped:\n%s", out) + } + } + if out := RenderRunSummary(res); !strings.Contains(out, "DNSSEC checks and penalty disabled") { + t.Errorf("run summary omitted DNSSEC setting: %s", out) + } +} + func TestRenderServersTable(t *testing.T) { disableColors(t) servers := []model.Server{
resolverprotocolstatusdnssec validationnxdomain honesty
%s%s%s%s%s