commit 53626dd36a56cf575136df9818758c78e577e85f Author: Pierre-Olivier Mercier Date: Sun Apr 26 18:44:22 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7e2a47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-dnsviz +checker-dnsviz.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8bc33d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# -- Build the Go checker binary ------------------------------------------ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum* ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-dnsviz . + +# -- Runtime image: dnsviz (Python) + checker binary ---------------------- +# +# DNSViz is a Python tool. We base on alpine:3.20 and install dnsviz from +# its pip distribution along with the C deps it needs (libcrypto, m2crypto, +# pygraphviz is *not* installed: we only need probe/grok which output JSON). +FROM alpine:3.20 + +RUN apk add --no-cache \ + python3 \ + py3-pip \ + py3-cryptography \ + py3-dnspython \ + py3-pygraphviz \ + graphviz \ + ca-certificates \ + dnssec-root \ + && pip3 install --no-cache-dir --break-system-packages dnsviz \ + && adduser -D -u 65534 -H -s /sbin/nologin checker || true + +COPY --from=builder /checker-dnsviz /usr/local/bin/checker-dnsviz + +USER 65534:65534 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["/usr/local/bin/checker-dnsviz", "-healthcheck"] +ENTRYPOINT ["/usr/local/bin/checker-dnsviz"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The happyDomain Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6e97f9b --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +CHECKER_NAME := checker-dnsviz +CHECKER_IMAGE := happydomain/$(CHECKER_NAME) +CHECKER_VERSION ?= custom-build + +CHECKER_SOURCES := main.go $(wildcard checker/*.go) + +GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) + +.PHONY: all plugin docker test clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + +plugin: $(CHECKER_NAME).so + +$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/ + +docker: + docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . + +test: + go test -tags standalone ./... + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..311d284 --- /dev/null +++ b/NOTICE @@ -0,0 +1,26 @@ +checker-dnsviz +Copyright (c) 2026 The happyDomain Authors + +This product is licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------------- +Third-party notices +------------------------------------------------------------------------------- + +This product includes software developed as part of the checker-sdk-go +project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed +under the Apache License, Version 2.0: + + checker-sdk-go + Copyright 2020-2026 The happyDomain Authors + + This product includes software developed as part of the happyDomain + project (https://happydomain.org). + + Portions of this code were originally written for the happyDomain + server (licensed under AGPL-3.0 and a commercial license) and are + made available there under the Apache License, Version 2.0 to enable + a permissively licensed ecosystem of checker plugins. + +You may obtain a copy of the Apache License 2.0 at: + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e399a7a --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# checker-dnsviz + +DNSSEC checker for [happyDomain](https://www.happydomain.org/), implemented as +a thin wrapper around [DNSViz](https://github.com/dnsviz/dnsviz). + +The container ships `dnsviz` (Python) alongside the Go binary that exposes the +standard happyDomain checker HTTP API (`/health`, `/definition`, `/collect`, +`/evaluate`, `/report`). + +## How it works + +For each check run, the Go binary invokes: + +``` +dnsviz probe -A . | dnsviz grok -t +``` + +The queried name **and every ancestor up to the root** are passed to +`dnsviz probe`, in root → leaf order. `dnsviz` only emits a full analysis +(DNSKEY set, DS at parent, queries) for names listed on the command line; +ancestors stumbled upon implicitly are kept as "stub" entries that grok +ignores. Listing them explicitly is what makes every link of the chain +appear in the report. + +`dnsviz grok -t ` is given a BIND-format DNSKEY trust anchor for the +root zone. Without it, the root has no parent to chain against and stays +classified as `NOERROR` (DNS rcode) instead of `SECURE` (DNSSEC). + +The output is then parsed: per-zone errors and warnings are walked out of +the nested record tree (`delegation.errors`, `dnskey[i].errors`, +`queries/.../rrsig[j].errors`, …) and turned into individual `CheckState` +entries tagged with the JSON path where they were found. A curated +catalog of common DNSSEC failure scenarios (broken chain, expired RRSIG, +DS digest mismatch, deprecated algorithm, …) is matched against the +findings to generate a "Fix these first" section in the HTML report with +plain-language remediation hints. + +The HTML report renders one block per zone in the chain (root → TLD → +intermediates → leaf), each with its delegation/DS records, DNSKEY set, +authoritative servers, and per-query analysis (RRsets, RRSIG validity, +NSEC proofs) so a recursive DNSSEC failure can be located at the exact +level, and the exact record, where it broke. + +## Scope + +This checker is intentionally limited to what DNSViz reports. NSEC/NSEC3 +zone-walk hardening and NSEC3PARAM iteration policy (RFC 9276) are +delivered by a separate `checker-dnssec` module. + +## Usage + +### Standalone server + +```bash +make +./checker-dnsviz -listen :8080 +``` + +Runtime requirements: + +- `dnsviz` on `PATH` (the Python CLI: `pip install dnsviz`, plus + `pygraphviz` and `graphviz` since `dnsviz grok -t` imports the graph + module even when only producing JSON). +- A BIND-format root DNSKEY trust anchor file. The collector + auto-detects `/usr/share/dnssec-root/trusted-key.key` (Alpine's + `dnssec-root` package, Debian/Ubuntu's `dns-root-data` ships an + equivalent at `/usr/share/dns/root.key`); pass `-trust-anchors-file + ` to override. Without it, the root zone in the report stays at + `NOERROR` instead of `SECURE`. + +### Docker + +```bash +make docker +docker run -p 8080:8080 happydomain/checker-dnsviz +``` + +The image bundles `dnsviz`, `pygraphviz`, `graphviz` and the alpine +`dnssec-root` package, so the trust anchor is in place out of the box. +When ICANN rolls a new root KSK (KSK-2024 is scheduled to begin signing +in late 2026), rebuild the image once the upstream alpine package ships +the new key. + +### happyDomain plugin + +```bash +make plugin +# produces checker-dnsviz.so +``` + +## Options + +| Scope | Id | Default | Description | +|--------|-----------------------|-----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| admin | `dnsvizBin` | `dnsviz` | Path to the dnsviz CLI. | +| admin | `probeTimeoutSeconds` | `120` | Hard timeout for `dnsviz probe`. | +| admin | `extraProbeArgs` | `-A` | Extra arguments appended verbatim to `dnsviz probe`. | +| admin | `trustAnchorsFile` | `/usr/share/dnssec-root/trusted-key.key` (auto-detected) | BIND DNSKEY file passed to `dnsviz grok -t`. When unset, the collector falls back to the Alpine `dnssec-root` package path if it exists. | +| domain | `domain_name` | auto-fill | Domain to analyse. | + +## Rules + +| Rule | Description | +|----------------------------|------------------------------------------------------------------------------| +| `dnsviz_overall_status` | DNSViz status of the queried domain (SECURE/INSECURE/BOGUS/INDETERMINATE). | +| `dnsviz_per_zone_status` | One state per zone in the chain (root, TLD, intermediates, leaf). | +| `dnsviz_zone_errors` | Every error reported by DNSViz, scoped to the zone where it was found. | +| `dnsviz_zone_warnings` | Every warning reported by DNSViz, scoped to the zone where it was found. | +| `dnsviz_common_failures` | Pattern-matches findings against a catalog of common DNSSEC failures. | + +## Licensing + +This repository is split into two licensing zones: + +| Path | License | Reason | +|------|---------|--------| +| `checker/` | MIT | Pure analysis logic (types, rules, HTML report). Can be imported by third-party Go projects without GPL obligations. | +| `internal/collect/` | GPL v2 | Invokes the `dnsviz` subprocess. Covered by [DNSViz's GPL v2 licence](https://github.com/dnsviz/dnsviz/blob/master/LICENSE). | +| `main.go`, `plugin/` | GPL v2 | Wire the two together; distributed binaries include the GPL layer. | + +If you only need the analysis primitives (parse grok output, evaluate rules, render the HTML report), import `git.happydns.org/checker-dnsviz/checker` and supply your own `checker.CollectFn`, for example one that calls the HTTP API instead of running DNSViz locally. diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..a6713b7 --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: MIT + +package checker + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// ParseGrokOutput decodes the JSON produced by `dnsviz grok` into a typed +// map of zone analyses. Order lists zones from most-specific to root. +func ParseGrokOutput(raw []byte) (map[string]ZoneAnalysis, []string, error) { + var top map[string]json.RawMessage + if err := json.Unmarshal(raw, &top); err != nil { + return nil, nil, err + } + + out := make(map[string]ZoneAnalysis, len(top)) + for k, v := range top { + // Skip non-zone keys some dnsviz versions emit (e.g. "_meta"). + if k == "" || strings.HasPrefix(k, "_") { + continue + } + out[k] = decodeZone(v) + } + + keys := make([]string, 0, len(out)) + for k := range out { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + return labelDepth(keys[i]) > labelDepth(keys[j]) + }) + return out, keys, nil +} + +func decodeZone(raw json.RawMessage) ZoneAnalysis { + var z ZoneAnalysis + var node any + if err := json.Unmarshal(raw, &node); err != nil { + return z + } + m, ok := node.(map[string]any) + if !ok { + if s, ok := node.(string); ok { + z.Status = s + } + return z + } + + if s, ok := m["status"].(string); ok { + z.DNSStatus = s + } + // DNSSEC chain status lives under delegation.status. + if del, ok := m["delegation"].(map[string]any); ok { + if s, ok := del["status"].(string); ok { + z.Status = s + } + } + // Root has no parent and therefore no delegation block. dnsviz signals + // trust-anchor validation through the RRSIG covering the apex DNSKEY + // rrset (queries./IN/DNSKEY.answer[*].rrsig[*].status). With + // `dnsviz grok -t …` and a matching anchor, that RRSIG becomes VALID + // and we lift the zone to SECURE; an INVALID/EXPIRED RRSIG drags it + // to BOGUS. Without a trust anchor, this leaves Status empty and we + // fall back to the DNS rcode below. + if z.Status == "" { + z.Status = inferApexDNSKEYStatus(m["queries"]) + } + if z.Status == "" { + z.Status = z.DNSStatus + } + + z.Errors, z.Warnings = collectFindings(m, "") + return z +} + +func collectFindings(node any, path string) (errs, warns []Finding) { + switch v := node.(type) { + case map[string]any: + for k, val := range v { + sub := joinPath(path, k) + switch k { + case "errors": + errs = append(errs, asFindings(val, path)...) + continue + case "warnings": + warns = append(warns, asFindings(val, path)...) + continue + } + e, w := collectFindings(val, sub) + errs = append(errs, e...) + warns = append(warns, w...) + } + case []any: + for i, item := range v { + sub := fmt.Sprintf("%s[%d]", path, i) + e, w := collectFindings(item, sub) + errs = append(errs, e...) + warns = append(warns, w...) + } + } + return +} + +func joinPath(parent, key string) string { + if parent == "" { + return key + } + return parent + "/" + key +} + +// asFindings turns a value attached to an "errors"/"warnings" key into a +// slice of Finding. DNSViz uses a few shapes here across versions: +// - []object{description, code, servers} +// - []string (rare, very old grok) +// - object keyed by code -> entry (newer grok flattens findings by code) +func asFindings(raw any, path string) []Finding { + switch v := raw.(type) { + case []any: + out := make([]Finding, 0, len(v)) + for _, item := range v { + out = append(out, makeFinding(item, "", path)) + } + return out + case map[string]any: + out := make([]Finding, 0, len(v)) + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + out = append(out, makeFinding(v[k], k, path)) + } + return out + case []string: + out := make([]Finding, 0, len(v)) + for _, s := range v { + out = append(out, Finding{Description: s, Path: path}) + } + return out + } + return nil +} + +func makeFinding(item any, codeHint, path string) Finding { + f := Finding{Path: path, Code: codeHint} + switch v := item.(type) { + case string: + f.Description = v + case map[string]any: + if s, ok := v["code"].(string); ok && s != "" { + f.Code = s + } + if s, ok := v["description"].(string); ok && s != "" { + f.Description = s + } else if s, ok := v["message"].(string); ok && s != "" { + f.Description = s + } + if servers, ok := v["servers"].([]any); ok { + for _, s := range servers { + if str, ok := s.(string); ok { + f.Servers = append(f.Servers, str) + } + } + } + // If we couldn't extract a human description, keep the raw structure + // in Extra rather than synthesising a JSON blob into the description + // field (which would then be rendered as ugly text in the report). + if f.Description == "" { + f.Extra = v + } + default: + // Unknown shape: stash the raw value so the report can still surface + // it from a debug section, but don't pollute Description. + f.Extra = map[string]any{"value": item} + } + return f +} + +// inferApexDNSKEYStatus returns "SECURE", "BOGUS", or "" based on the +// status of RRSIGs covering the zone's apex DNSKEY rrset. dnsviz attaches +// a per-RRSIG status whenever a key reaches it (either through DS from +// the parent or through a configured trust anchor at this zone). For +// the root, this is the only place where trust-anchor validation +// surfaces in the grok output. +// +// queries is the value at zone["queries"], a map keyed by +// "/IN/". We pick the DNSKEY query and look at every +// RRSIG inside its answer. +func inferApexDNSKEYStatus(queries any) string { + q, ok := queries.(map[string]any) + if !ok { + return "" + } + var dnskeyQ map[string]any + for k, v := range q { + if !strings.HasSuffix(k, "/IN/DNSKEY") { + continue + } + if m, ok := v.(map[string]any); ok { + dnskeyQ = m + break + } + } + if dnskeyQ == nil { + return "" + } + answers, _ := dnskeyQ["answer"].([]any) + sawValid := false + for _, a := range answers { + am, _ := a.(map[string]any) + if am == nil { + continue + } + rrsigs, _ := am["rrsig"].([]any) + for _, rs := range rrsigs { + rm, _ := rs.(map[string]any) + if rm == nil { + continue + } + s, _ := rm["status"].(string) + switch strings.ToUpper(s) { + case "INVALID", "BOGUS", "EXPIRED", "PREMATURE": + return "BOGUS" + case "VALID", "SECURE": + sawValid = true + } + } + } + if sawValid { + return "SECURE" + } + return "" +} + +func labelDepth(zone string) int { + z := strings.TrimSuffix(zone, ".") + if z == "" { + return 0 + } + return strings.Count(z, ".") + 1 +} diff --git a/checker/collect_test.go b/checker/collect_test.go new file mode 100644 index 0000000..e92ee2d --- /dev/null +++ b/checker/collect_test.go @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT + +package checker + +import ( + "reflect" + "sort" + "strings" + "testing" +) + +func TestLabelDepth(t *testing.T) { + cases := map[string]int{ + "": 0, + ".": 0, + "com.": 1, + "example.com.": 2, + "www.example.com": 3, + "a.b.c.d.e.": 5, + } + for in, want := range cases { + if got := labelDepth(in); got != want { + t.Errorf("labelDepth(%q) = %d, want %d", in, got, want) + } + } +} + +func TestJoinPath(t *testing.T) { + cases := []struct { + parent, key, want string + }{ + {"", "errors", "errors"}, + {"delegation", "errors", "delegation/errors"}, + {"queries/example.com.", "answer", "queries/example.com./answer"}, + } + for _, c := range cases { + if got := joinPath(c.parent, c.key); got != c.want { + t.Errorf("joinPath(%q,%q) = %q, want %q", c.parent, c.key, got, c.want) + } + } +} + +func TestParseGrokOutput_OrderAndShape(t *testing.T) { + raw := []byte(`{ + "example.com.": { + "status": "NOERROR", + "delegation": {"status": "SECURE"}, + "queries": {"example.com./A": {"errors": [{"code": "X", "description": "boom"}]}} + }, + "com.": {"delegation": {"status": "SECURE"}}, + ".": {"delegation": {"status": "SECURE"}}, + "_meta": {"ignored": true} + }`) + + zones, order, err := ParseGrokOutput(raw) + if err != nil { + t.Fatalf("ParseGrokOutput: %v", err) + } + if _, ok := zones["_meta"]; ok { + t.Errorf("expected _meta-prefixed key to be skipped, got it in zones") + } + if len(zones) != 3 { + t.Errorf("expected 3 zones, got %d (%v)", len(zones), zones) + } + // Order: most-specific first (example.com.), root last. + if !reflect.DeepEqual(order, []string{"example.com.", "com.", "."}) { + t.Errorf("unexpected order: %v", order) + } + if zones["example.com."].Status != "SECURE" { + t.Errorf("expected delegation.status to win for example.com., got %q", zones["example.com."].Status) + } + if zones["example.com."].DNSStatus != "NOERROR" { + t.Errorf("expected DNSStatus=NOERROR, got %q", zones["example.com."].DNSStatus) + } + if len(zones["example.com."].Errors) != 1 { + t.Fatalf("expected 1 error, got %v", zones["example.com."].Errors) + } + if zones["example.com."].Errors[0].Code != "X" { + t.Errorf("expected code=X, got %q", zones["example.com."].Errors[0].Code) + } +} + +func TestParseGrokOutput_InvalidJSON(t *testing.T) { + if _, _, err := ParseGrokOutput([]byte("not json")); err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestParseGrokOutput_StringZone(t *testing.T) { + // Old grok: a zone may collapse into a bare string status. + raw := []byte(`{"missing.example.": "NON_EXISTENT"}`) + zones, _, err := ParseGrokOutput(raw) + if err != nil { + t.Fatalf("ParseGrokOutput: %v", err) + } + if zones["missing.example."].Status != "NON_EXISTENT" { + t.Errorf("got %q, want NON_EXISTENT", zones["missing.example."].Status) + } +} + +func TestDecodeZone_StatusFallbacks(t *testing.T) { + // Only top-level status; no delegation block. Status must fall back to it. + raw := []byte(`{"status": "NOERROR"}`) + z := decodeZone(raw) + if z.DNSStatus != "NOERROR" || z.Status != "NOERROR" { + t.Errorf("expected Status and DNSStatus = NOERROR, got %+v", z) + } +} + +func TestCollectFindings_Nested(t *testing.T) { + raw := []byte(`{ + "delegation": { + "errors": [{"code": "DS", "description": "missing"}] + }, + "queries": { + "example.com./A": { + "answer": [ + {"warnings": [{"code": "W1", "description": "smelly"}]} + ] + } + } + }`) + z := decodeZone(raw) + if len(z.Errors) != 1 || z.Errors[0].Path != "delegation" { + t.Errorf("expected one error tagged delegation, got %+v", z.Errors) + } + if len(z.Warnings) != 1 { + t.Fatalf("expected one warning, got %+v", z.Warnings) + } + w := z.Warnings[0] + if !strings.HasPrefix(w.Path, "queries/example.com./A/answer[") { + t.Errorf("unexpected warning path: %q", w.Path) + } + if w.Code != "W1" || w.Description != "smelly" { + t.Errorf("unexpected warning content: %+v", w) + } +} + +func TestAsFindings_VariantShapes(t *testing.T) { + // Object-keyed-by-code variant. + out := asFindings(map[string]any{ + "CODE_B": map[string]any{"description": "second"}, + "CODE_A": map[string]any{"description": "first"}, + }, "p") + if len(out) != 2 { + t.Fatalf("expected 2 findings, got %v", out) + } + // Sorted by key for stability. + if out[0].Code != "CODE_A" || out[1].Code != "CODE_B" { + t.Errorf("findings not sorted by key: %+v", out) + } + for _, f := range out { + if f.Path != "p" { + t.Errorf("expected path=p, got %q", f.Path) + } + } + + // []string variant (rare but supported via direct call). + strs := asFindings([]string{"raw1", "raw2"}, "p") + if len(strs) != 2 || strs[0].Description != "raw1" { + t.Errorf("string-list shape mishandled: %+v", strs) + } + + // Unsupported scalar shape returns nil. + if asFindings(42, "p") != nil { + t.Errorf("expected nil for non-list non-map non-string-slice") + } +} + +func TestMakeFinding_FallbackAndServers(t *testing.T) { + // description missing, message present. + f := makeFinding(map[string]any{ + "message": "use-message", + "servers": []any{"ns1.example.", "ns2.example.", 42 /*ignored*/}, + }, "fallback_code", "p") + if f.Description != "use-message" { + t.Errorf("wanted message fallback, got %q", f.Description) + } + if !reflect.DeepEqual(f.Servers, []string{"ns1.example.", "ns2.example."}) { + t.Errorf("non-string server entries should be skipped, got %v", f.Servers) + } + if f.Code != "fallback_code" { + t.Errorf("expected codeHint to be used when item has no code, got %q", f.Code) + } + + // neither description nor message: keep the raw payload in Extra + // instead of synthesising a JSON blob into Description (which would + // then render as ugly text in the report). + f2 := makeFinding(map[string]any{"weird": 1}, "", "p") + if f2.Description != "" { + t.Errorf("expected empty Description when no human text available, got %q", f2.Description) + } + if f2.Extra == nil || f2.Extra["weird"] != 1 { + t.Errorf("expected raw payload in Extra, got %+v", f2.Extra) + } + + // Plain string item. + f3 := makeFinding("just a string", "h", "p") + if f3.Description != "just a string" || f3.Code != "h" { + t.Errorf("string item mishandled: %+v", f3) + } + + // Item explicit code overrides codeHint. + f4 := makeFinding(map[string]any{"code": "REAL", "description": "d"}, "hint", "p") + if f4.Code != "REAL" { + t.Errorf("expected explicit code to win, got %q", f4.Code) + } +} + +func TestParseGrokOutput_OrderStable(t *testing.T) { + // Same-depth zones should still produce a deterministic slice (keys order + // in Go maps is randomized) - just checks the zones each appear once. + raw := []byte(`{"a.": {}, "b.": {}}`) + _, order, err := ParseGrokOutput(raw) + if err != nil { + t.Fatal(err) + } + cp := append([]string(nil), order...) + sort.Strings(cp) + if !reflect.DeepEqual(cp, []string{"a.", "b."}) { + t.Errorf("missing zones in order: %v", order) + } +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..94a5a5c --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT + +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is overridden at link time: -ldflags "-X ...Version=1.2.3". +var Version = "built-in" + +func (p *dnsvizProvider) Definition() *sdk.CheckerDefinition { + def := &sdk.CheckerDefinition{ + ID: "dnsviz", + Name: "DNSSEC (DNSViz)", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToDomain: true, + }, + ObservationKeys: []sdk.ObservationKey{ObservationKeyDNSViz}, + Options: sdk.CheckerOptionsDocumentation{ + AdminOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "probeTimeoutSeconds", + Type: "uint", + Label: "Probe timeout (s)", + Description: "Hard timeout for the `dnsviz probe` invocation. The recursive walk can take a while on slow zones.", + Default: float64(120), + }, + }, + DomainOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "domain_name", + Label: "Domain name", + AutoFill: sdk.AutoFillDomainName, + }, + }, + }, + Rules: Rules(), + HasHTMLReport: true, + HasMetrics: true, + Interval: &sdk.CheckIntervalSpec{ + Min: 15 * time.Minute, + Max: 7 * 24 * time.Hour, + Default: 6 * time.Hour, + }, + } + def.BuildRulesInfo() + return def +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..87e740f --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT + +//go:build standalone + +package checker + +import ( + "errors" + "net/http" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// RenderForm exposes a minimal /check form when running standalone. +func (p *dnsvizProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "domain_name", + Type: "string", + Label: "Domain name", + Placeholder: "example.com", + Required: true, + Description: "Fully-qualified domain name to analyse with DNSViz.", + }, + } +} + +// ParseForm builds the CheckerOptions from the human-facing /check form. +func (p *dnsvizProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + domain := strings.TrimSpace(r.FormValue("domain_name")) + if domain == "" { + return nil, errors.New("domain name is required") + } + opts := sdk.CheckerOptions{ + "domain_name": strings.TrimSuffix(domain, "."), + } + return opts, nil +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..ad7e3b3 --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT + +package checker + +import ( + "context" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// CollectFn is the function signature for the DNSViz data collection step. +// The checker package is decoupled from the subprocess invocation so it can +// be imported without GPL obligations. Implementations live in the binary or +// plugin layer (see internal/collect). +type CollectFn func(ctx context.Context, opts sdk.CheckerOptions) (any, error) + +// Provider returns a new DNSViz observation provider backed by the given +// collect function. +func Provider(collect CollectFn) sdk.ObservationProvider { + return &dnsvizProvider{collect: collect} +} + +type dnsvizProvider struct{ collect CollectFn } + +func (p *dnsvizProvider) Key() sdk.ObservationKey { + return ObservationKeyDNSViz +} + +func (p *dnsvizProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + return p.collect(ctx, opts) +} diff --git a/checker/provider_test.go b/checker/provider_test.go new file mode 100644 index 0000000..d963f83 --- /dev/null +++ b/checker/provider_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT + +package checker + +import ( + "context" + "errors" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func TestProvider_DelegatesCollect(t *testing.T) { + called := false + want := errors.New("sentinel") + p := Provider(func(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + called = true + return "value", want + }) + got, err := p.Collect(context.Background(), sdk.CheckerOptions{}) + if !called { + t.Fatal("collect fn not called") + } + if got != "value" || err != want { + t.Errorf("unexpected return: %v, %v", got, err) + } + if p.Key() != ObservationKeyDNSViz { + t.Errorf("Key=%q, want %q", p.Key(), ObservationKeyDNSViz) + } +} + +func TestDefinition(t *testing.T) { + p := Provider(func(_ context.Context, _ sdk.CheckerOptions) (any, error) { return nil, nil }) + dp, ok := p.(sdk.CheckerDefinitionProvider) + if !ok { + t.Fatal("provider does not implement CheckerDefinitionProvider") + } + def := dp.Definition() + if def.ID != "dnsviz" { + t.Errorf("ID=%q", def.ID) + } + if !def.HasHTMLReport || !def.HasMetrics { + t.Error("expected HasHTMLReport and HasMetrics to be true") + } + if !def.Availability.ApplyToDomain { + t.Error("expected ApplyToDomain") + } + if def.Interval == nil || def.Interval.Default <= 0 { + t.Errorf("interval not set: %+v", def.Interval) + } + if len(def.Rules) == 0 || len(def.RulesInfo) != len(def.Rules) { + t.Errorf("rules vs rulesInfo: %d / %d", len(def.Rules), len(def.RulesInfo)) + } + // At least one rule per published name. + for _, ri := range def.RulesInfo { + if ri.Name == "" || ri.Description == "" { + t.Errorf("missing name/description in RulesInfo: %+v", ri) + } + } + if len(def.ObservationKeys) == 0 || def.ObservationKeys[0] != ObservationKeyDNSViz { + t.Errorf("observation keys: %v", def.ObservationKeys) + } + // Sanity: the domain-level option declares the auto-fill we rely on. + hasDomain := false + for _, o := range def.Options.DomainOpts { + if o.Id == "domain_name" && o.AutoFill == sdk.AutoFillDomainName { + hasDomain = true + } + } + if !hasDomain { + t.Error("expected domain_name option with AutoFillDomainName") + } +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..f02299b --- /dev/null +++ b/checker/report.go @@ -0,0 +1,1000 @@ +// SPDX-License-Identifier: MIT + +package checker + +import ( + "bytes" + "encoding/json" + "fmt" + "html" + "html/template" + "sort" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// GetHTMLReport uses html/template for the skeleton (auto-escaping) and +// html.EscapeString for the hand-rendered zone tree; every user-controlled +// field must be escaped at the call site. +func (p *dnsvizProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { + var data DNSVizData + if raw := ctx.Data(); len(raw) > 0 { + if err := json.Unmarshal(raw, &data); err != nil { + return "", fmt.Errorf("decoding DNSViz data: %w", err) + } + } + states := ctx.States() + + view := buildReportView(&data, states) + var buf bytes.Buffer + if err := reportTmpl.Execute(&buf, view); err != nil { + return "", fmt.Errorf("rendering DNSViz report: %w", err) + } + return buf.String(), nil +} + +// ExtractMetrics turns the rule output into time-series points so a +// happyDomain dashboard can show DNSSEC drift over time. +func (p *dnsvizProvider) ExtractMetrics(ctx sdk.ReportContext, collectedAt time.Time) ([]sdk.CheckMetric, error) { + var data DNSVizData + if raw := ctx.Data(); len(raw) > 0 { + if err := json.Unmarshal(raw, &data); err != nil { + return nil, err + } + } + + metrics := []sdk.CheckMetric{ + { + Name: "dnsviz.zones.count", + Value: float64(len(data.Zones)), + Timestamp: collectedAt, + }, + } + + var totalErrors, totalWarnings int + for _, z := range data.Zones { + totalErrors += len(z.Errors) + totalWarnings += len(z.Warnings) + } + metrics = append(metrics, + sdk.CheckMetric{Name: "dnsviz.errors.count", Value: float64(totalErrors), Timestamp: collectedAt}, + sdk.CheckMetric{Name: "dnsviz.warnings.count", Value: float64(totalWarnings), Timestamp: collectedAt}, + ) + + byStatus := map[sdk.Status]int{} + for _, s := range ctx.States() { + byStatus[s.Status]++ + } + for status, n := range byStatus { + metrics = append(metrics, sdk.CheckMetric{ + Name: "dnsviz.findings.count", + Value: float64(n), + Labels: map[string]string{"status": status.String()}, + Timestamp: collectedAt, + }) + } + return metrics, nil +} + +// ── view assembly ──────────────────────────────────────────────────────── + +type reportView struct { + Domain string + DomainHdr string + Empty bool + Banner *bannerView + Fixes []fixView + Chain template.HTML + States []stateRow + HasRaw bool + Raw string + ProbeStderr string + GrokStderr string +} + +type bannerView struct { + Status string + Leaf string + LeafSt string +} + +type fixView struct { + Class string + Title string + Subject string + Code string + Hint string +} + +type stateRow struct { + Status string + Subject string + Code string + Message string +} + +func buildReportView(data *DNSVizData, states []sdk.CheckState) reportView { + v := reportView{ + Domain: data.Domain, + DomainHdr: emptyAsUnknown(data.Domain), + } + if len(states) == 0 && len(data.Zones) == 0 { + v.Empty = true + return v + } + v.Banner = buildBanner(data, states) + v.Fixes = buildFixes(states) + v.Chain = template.HTML(renderChain(data)) + v.States = buildStates(states) + if len(data.Raw) > 0 { + v.HasRaw = true + v.Raw = string(data.Raw) + v.ProbeStderr = data.ProbeStderr + v.GrokStderr = data.GrokStderr + } + return v +} + +func buildBanner(data *DNSVizData, states []sdk.CheckState) *bannerView { + leaf := data.Domain + "." + z, ok := data.Zones[leaf] + if !ok { + zones := orderedZones(data) + if len(zones) > 0 { + leaf = zones[0] + z = data.Zones[leaf] + } + } + st := statusFromGrok(z.Status) + if w := worstStatus(states); w > st { + st = w + } + return &bannerView{ + Status: st.String(), + Leaf: strings.TrimSuffix(leaf, "."), + LeafSt: emptyAsUnknown(z.Status), + } +} + +func buildFixes(states []sdk.CheckState) []fixView { + var items []sdk.CheckState + for _, s := range states { + if s.Status < sdk.StatusWarn { + continue + } + items = append(items, s) + } + if len(items) == 0 { + return nil + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].Status != items[j].Status { + return items[i].Status > items[j].Status + } + return items[i].Subject < items[j].Subject + }) + out := make([]fixView, 0, len(items)) + for _, s := range items { + title, hint := titleAndHint(s) + klass := "fix-card" + if s.Status == sdk.StatusWarn { + klass = "fix-card warn" + } + if hint == "" { + hint = s.Message + } + out = append(out, fixView{ + Class: klass, + Title: title, + Subject: s.Subject, + Code: s.Code, + Hint: hint, + }) + } + return out +} + +func buildStates(states []sdk.CheckState) []stateRow { + if len(states) == 0 { + return nil + } + sorted := append([]sdk.CheckState(nil), states...) + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].Status != sorted[j].Status { + return sorted[i].Status > sorted[j].Status + } + if sorted[i].Subject != sorted[j].Subject { + return sorted[i].Subject < sorted[j].Subject + } + return sorted[i].Code < sorted[j].Code + }) + out := make([]stateRow, 0, len(sorted)) + for _, s := range sorted { + out = append(out, stateRow{ + Status: s.Status.String(), + Subject: s.Subject, + Code: s.Code, + Message: s.Message, + }) + } + return out +} + +func titleAndHint(s sdk.CheckState) (title, hint string) { + if s.Meta != nil { + if v, ok := s.Meta["title"].(string); ok { + title = v + } + if v, ok := s.Meta["hint"].(string); ok { + hint = v + } + } + if title == "" { + title = s.Message + } + return +} + +// ── top-level template ─────────────────────────────────────────────────── + +var reportTmpl = template.Must(template.New("report").Parse(` + +DNSSEC report: {{.Domain}} + +

DNSSEC analysis

{{.DomainHdr}}

+{{- if .Empty}} +

No DNSViz data and no rule states. The check probably failed before producing any output.

+{{- else -}} +{{with .Banner}}{{end}} +{{if .Fixes}}

Fix these first

+{{range .Fixes}}

{{.Title}}

at {{.Subject}}, rule {{.Code}}
{{if .Hint}}

{{.Hint}}

{{end}}
+{{end}}
{{end}} +{{.Chain}} +{{if .States}}

All rule states

+{{range .States}} +{{end}}
StatusSubjectCodeMessage
{{.Status}}{{.Subject}}{{.Code}}{{.Message}}
{{end}} +{{if .HasRaw}}
Raw dnsviz grok output
{{.Raw}}
+{{if .ProbeStderr}}
dnsviz probe stderr
{{.ProbeStderr}}
{{end}} +{{if .GrokStderr}}
dnsviz grok stderr
{{.GrokStderr}}
{{end}}
{{end}} +{{- end}} +`)) + +// ── CSS ───────────────────────────────────────────────────────────────── + +const reportCSS = ` +*,*::before,*::after{box-sizing:border-box} +body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;padding:1.5rem;background:#fafafa;color:#222;line-height:1.45} +code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace} +header h1{margin:0 0 .25rem;font-size:1.6rem} +header .domain{margin:0 0 1rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#555} +.banner{display:inline-block;padding:.6rem 1rem;border-radius:.4rem;color:#fff;font-weight:600;margin:0 0 1.5rem} +.banner small{display:block;font-weight:400;opacity:.85;font-size:.85rem;margin-top:.25rem} +.s-OK{background:#2e7d32}.s-INFO{background:#0277bd}.s-WARN{background:#ef6c00}.s-CRIT{background:#c62828}.s-ERROR{background:#6a1b9a}.s-UNKNOWN{background:#555} +.section{background:#fff;border:1px solid #e0e0e0;border-radius:.4rem;padding:1rem 1.25rem;margin:0 0 1.5rem;box-shadow:0 1px 2px rgba(0,0,0,.04)} +.section h2{margin:0 0 .75rem;font-size:1.15rem} +.zone{position:relative;border:1px solid #e0e0e0;border-left:5px solid #ccc;border-radius:.4rem;background:#fff;margin:0 0 1rem;padding:0;overflow:hidden} +.zone.s-OK{border-left-color:#2e7d32}.zone.s-INFO{border-left-color:#0277bd}.zone.s-WARN{border-left-color:#ef6c00}.zone.s-CRIT{border-left-color:#c62828}.zone.s-UNKNOWN{border-left-color:#777} +.zone>summary.zone-head{cursor:pointer;list-style:none;display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem;padding:.65rem .9rem .65rem 2rem;background:#f7f9fc;position:relative;user-select:none} +.zone>summary.zone-head::-webkit-details-marker{display:none} +.zone>summary.zone-head::before{content:"▸";position:absolute;left:.85rem;top:.7rem;color:#888;font-size:.85rem;transition:transform .12s ease} +.zone[open]>summary.zone-head::before{transform:rotate(90deg)} +.zone[open]>summary.zone-head{border-bottom:1px solid #e0e0e0} +.zone>summary.zone-head:hover{background:#eef2f7} +.zone-head h3{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:1.05rem} +.zone-head .level{color:#777;font-size:.8rem;font-weight:400;margin-left:.25rem} +.zone-body{padding:.6rem .9rem .8rem} +.subsec{margin:.85rem 0 0} +.subsec:first-child{margin-top:.25rem} +.subsec h4{margin:0 0 .35rem;font-size:.92rem;font-weight:600;color:#444;display:flex;align-items:baseline;gap:.4rem} +.subsec h4 .count{color:#888;font-weight:400;font-size:.82rem} +.subsec p.empty-sub{margin:.15rem 0;color:#777;font-size:.85rem;font-style:italic} +.badge{display:inline-block;padding:.05rem .45rem;border-radius:.25rem;font-size:.72rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:#fff;line-height:1.5} +.badge.ghost{background:#eceff1;color:#455a64} +.badge.alg{background:#37474f;color:#fff;text-transform:none;font-weight:500} +.badge.flag{background:#5e35b1;color:#fff;text-transform:none} +.records{display:grid;gap:.35rem} +.record{display:grid;grid-template-columns:auto 1fr auto;gap:.5rem;align-items:center;padding:.4rem .55rem;border:1px solid #eceff1;border-radius:.3rem;background:#fafbfc;font-size:.88rem} +.record.s-OK{border-left:3px solid #2e7d32}.record.s-INFO{border-left:3px solid #0277bd}.record.s-WARN{border-left:3px solid #ef6c00}.record.s-CRIT{border-left:3px solid #c62828}.record.s-UNKNOWN{border-left:3px solid #777} +.record .lhs{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem} +.record .desc{color:#333} +.record .desc small{display:block;color:#888;font-weight:400} +.record .meta{color:#666;font-size:.8rem;text-align:right;white-space:nowrap} +.kv{font-size:.78rem;color:#555;background:#eceff1;padding:0 .35rem;border-radius:.2rem} +.kv b{color:#222;font-weight:600} +.servers-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.4rem} +.server{padding:.4rem .55rem;border:1px solid #eceff1;border-radius:.3rem;background:#fafbfc;font-size:.85rem} +.server h5{margin:0 0 .2rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.85rem;color:#222} +.server ul{margin:.15rem 0 0;padding:0 0 0 1rem;color:#666;font-size:.78rem} +.queries details{border:1px solid #eceff1;border-radius:.3rem;background:#fafbfc;margin:.25rem 0;padding:0} +.queries details>summary{padding:.4rem .55rem;font-size:.88rem;color:#333;list-style:none} +.queries details>summary::-webkit-details-marker{display:none} +.queries details[open]>summary{border-bottom:1px solid #eceff1;background:#f1f3f5} +.queries summary .qname{font-family:ui-monospace,SFMono-Regular,Menlo,monospace} +.queries summary .qtype{display:inline-block;background:#0277bd;color:#fff;font-size:.72rem;padding:.05rem .4rem;border-radius:.2rem;margin-left:.4rem;letter-spacing:.04em} +.queries summary .qkind{margin-left:.4rem;font-size:.78rem;color:#666} +.queries .qbody{padding:.45rem .6rem .55rem} +.rdata{margin:.15rem 0 .35rem;padding:0;list-style:none;display:flex;flex-wrap:wrap;gap:.3rem} +.rdata li{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#eceff1;color:#222;padding:.05rem .4rem;border-radius:.2rem;font-size:.8rem} +.findings{margin:.5rem 0 0;padding:0;list-style:none} +.findings li{padding:.4rem .55rem;border-radius:.25rem;margin:.2rem 0;font-size:.88rem} +.findings li.err{background:#ffebee;color:#b71c1c;border-left:3px solid #c62828} +.findings li.warn{background:#fff3e0;color:#bf360c;border-left:3px solid #ef6c00} +.findings li code{background:rgba(0,0,0,.08);padding:0 .25rem;border-radius:.15rem;font-size:.78rem} +.findings li .path{display:block;margin-top:.2rem;color:#555;font-size:.74rem} +table{border-collapse:collapse;width:100%;font-size:.9rem} +th,td{border:1px solid #e0e0e0;padding:.35rem .5rem;text-align:left;vertical-align:top} +th{background:#f5f5f5;font-weight:600} +.fix-card{border-left:4px solid #c62828;background:#fff;padding:.75rem 1rem;border-radius:0 .3rem .3rem 0;margin:.5rem 0} +.fix-card.warn{border-color:#ef6c00} +.fix-card h4{margin:0 0 .25rem;font-size:1rem} +.fix-card .where{color:#666;font-size:.85rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace} +.fix-card .hint{margin:.4rem 0 0} +details>summary{cursor:pointer;color:#555} +pre{background:#f5f5f5;padding:.75rem;border-radius:.3rem;overflow-x:auto;font-size:.8rem;line-height:1.4} +.empty{padding:2rem;text-align:center;color:#777} +` + +// ── deep zone-tree fragment (hand-rendered, escapes all user data) ─────── + +func renderChain(data *DNSVizData) string { + zones := orderedZones(data) + if len(zones) == 0 { + return "" + } + // rawZones is supplemental: it powers the deep per-zone subtree + // (delegation, DNSKEY, queries, …). The typed view in data.Zones is the + // source of truth for status and findings, so a malformed Raw blob must + // not break rendering; we just fall back to the typed-only view and + // note the decode failure inline so it is visible to the operator. + var rawZones map[string]json.RawMessage + var rawDecodeErr error + if len(data.Raw) > 0 { + if err := json.Unmarshal(data.Raw, &rawZones); err != nil { + rawZones = nil + rawDecodeErr = err + } + } + + var b strings.Builder + b.WriteString(`

DNS hierarchy (root → leaf)

`) + if rawDecodeErr != nil { + fmt.Fprintf(&b, `

Raw dnsviz grok JSON could not be decoded (%s); rendering from typed data only.

`, + html.EscapeString(rawDecodeErr.Error())) + } + for i := len(zones) - 1; i >= 0; i-- { + name := zones[i] + z := data.Zones[name] + var raw map[string]any + if rb, ok := rawZones[name]; ok { + // Per-zone decode failures are expected for some dnsviz versions + // where a key holds a non-object value; fall back silently. + if err := json.Unmarshal(rb, &raw); err != nil { + raw = nil + } + } + writeZoneBlock(&b, name, i, len(zones), z, raw) + } + b.WriteString(`
`) + return b.String() +} + +func writeZoneBlock(b *strings.Builder, name string, idx, total int, z ZoneAnalysis, raw map[string]any) { + st := statusFromGrok(z.Status) + level := zoneLevelLabel(idx, total) + + // Default-open zones with problems so the user sees them without + // clicking; healthy/informational zones collapse to keep the chain + // overview tidy. Threshold is StatusWarn: INFO (e.g. INSECURE, + // NOERROR fallback) is not a problem worth surfacing automatically. + openAttr := "" + if st >= sdk.StatusWarn || len(z.Errors) > 0 || len(z.Warnings) > 0 { + openAttr = " open" + } + + fmt.Fprintf(b, `
`, st.String(), openAttr) + b.WriteString(``) + fmt.Fprintf(b, `

%s

`, html.EscapeString(name)) + if level != "" { + fmt.Fprintf(b, `%s`, html.EscapeString(level)) + } + fmt.Fprintf(b, `%s`, st.String(), html.EscapeString(emptyAsUnknown(z.Status))) + if z.DNSStatus != "" && !strings.EqualFold(z.DNSStatus, z.Status) { + fmt.Fprintf(b, `DNS: %s`, html.EscapeString(z.DNSStatus)) + } + if n := len(z.Errors); n > 0 { + fmt.Fprintf(b, `%d error%s`, n, pluralS(n)) + } + if n := len(z.Warnings); n > 0 { + fmt.Fprintf(b, `%d warning%s`, n, pluralS(n)) + } + b.WriteString(`
`) + + b.WriteString(`
`) + if raw != nil { + writeDelegationSubsec(b, raw["delegation"]) + writeDNSKEYSubsec(b, raw["dnskey"]) + writeServersSubsec(b, raw["zone"]) + writeQueriesSubsec(b, raw["queries"]) + } + writeZoneFindings(b, z) + b.WriteString(`
`) +} + +func zoneLevelLabel(idx, total int) string { + switch { + case total == 1: + return "" + case idx == 0: + return "(leaf)" + case idx == total-1: + return "(root / TLD)" + default: + return "(intermediate)" + } +} + +// ── delegation (DS at parent) ──────────────────────────────────────────── + +func writeDelegationSubsec(b *strings.Builder, node any) { + m, ok := node.(map[string]any) + if !ok { + return + } + dsArr, _ := m["ds"].([]any) + delStatus, _ := m["status"].(string) + + b.WriteString(`

Delegation (DS at parent)`) + if delStatus != "" { + fmt.Fprintf(b, ` %s`, recordStatusClass(delStatus), html.EscapeString(delStatus)) + } + fmt.Fprintf(b, ` %d DS

`, len(dsArr)) + + if len(dsArr) == 0 { + b.WriteString(`

No DS record published at the parent: zone is unsigned (INSECURE).

`) + return + } + b.WriteString(`
`) + for _, item := range dsArr { + ds, _ := item.(map[string]any) + writeDSRecord(b, ds) + } + b.WriteString(`
`) +} + +func writeDSRecord(b *strings.Builder, ds map[string]any) { + if ds == nil { + return + } + status, _ := ds["status"].(string) + alg := numAsInt(ds["algorithm"]) + keyTag := numAsInt(ds["key_tag"]) + digestType := numAsInt(ds["digest_type"]) + digest, _ := ds["digest"].(string) + + fmt.Fprintf(b, `
`, recordStatusClass(status)) + b.WriteString(`
`) + fmt.Fprintf(b, `%s`, html.EscapeString(dnssecAlgName(alg))) + fmt.Fprintf(b, `key tag %d`, keyTag) + fmt.Fprintf(b, `digest %s`, html.EscapeString(digestTypeName(digestType))) + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(html.EscapeString(truncMid(digest, 40))) + b.WriteString(`
`) + b.WriteString(`
`) + if status != "" { + fmt.Fprintf(b, `%s`, recordStatusClass(status), html.EscapeString(status)) + } + b.WriteString(`
`) +} + +// ── DNSKEY set at apex ─────────────────────────────────────────────────── + +func writeDNSKEYSubsec(b *strings.Builder, node any) { + arr, ok := node.([]any) + if !ok { + return + } + b.WriteString(`

DNSKEY set at apex`) + fmt.Fprintf(b, ` %d key%s

`, len(arr), pluralS(len(arr))) + if len(arr) == 0 { + b.WriteString(`

No DNSKEY published at the apex.

`) + return + } + b.WriteString(`
`) + for _, item := range arr { + k, _ := item.(map[string]any) + writeDNSKEYRecord(b, k) + } + b.WriteString(`
`) +} + +func writeDNSKEYRecord(b *strings.Builder, k map[string]any) { + if k == nil { + return + } + flags := numAsInt(k["flags"]) + alg := numAsInt(k["algorithm"]) + keyTag := numAsInt(k["key_tag"]) + keyLen := numAsInt(k["key_length"]) + status, _ := k["status"].(string) + + fmt.Fprintf(b, `
`, recordStatusClass(status)) + b.WriteString(`
`) + fmt.Fprintf(b, `%s`, html.EscapeString(dnskeyFlagsLabel(flags))) + fmt.Fprintf(b, `%s`, html.EscapeString(dnssecAlgName(alg))) + fmt.Fprintf(b, `key tag %d`, keyTag) + if keyLen > 0 { + fmt.Fprintf(b, `%d bits`, keyLen) + } + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(`
`) + if status != "" { + fmt.Fprintf(b, `%s`, recordStatusClass(status), html.EscapeString(status)) + } + b.WriteString(`
`) +} + +// ── authoritative servers ──────────────────────────────────────────────── + +func writeServersSubsec(b *strings.Builder, node any) { + z, ok := node.(map[string]any) + if !ok { + return + } + servers, ok := z["servers"].(map[string]any) + if !ok || len(servers) == 0 { + return + } + + names := make([]string, 0, len(servers)) + for n := range servers { + names = append(names, n) + } + sort.Strings(names) + + b.WriteString(`

Authoritative servers`) + fmt.Fprintf(b, ` %d

`, len(names)) + b.WriteString(`
`) + for _, n := range names { + entry, _ := servers[n].(map[string]any) + b.WriteString(`
`) + fmt.Fprintf(b, `
%s
`, html.EscapeString(n)) + writeIPList(b, "auth", entry["auth"]) + writeIPList(b, "glue", entry["glue"]) + b.WriteString(`
`) + } + b.WriteString(`
`) +} + +func writeIPList(b *strings.Builder, label string, node any) { + arr, ok := node.([]any) + if !ok || len(arr) == 0 { + return + } + fmt.Fprintf(b, `
%s
    `, html.EscapeString(label)) + for _, ip := range arr { + if s, ok := ip.(string); ok { + fmt.Fprintf(b, `
  • %s
  • `, html.EscapeString(s)) + } + } + b.WriteString(`
`) +} + +// ── queries (per RR-name/type) ─────────────────────────────────────────── + +func writeQueriesSubsec(b *strings.Builder, node any) { + q, ok := node.(map[string]any) + if !ok || len(q) == 0 { + return + } + keys := make([]string, 0, len(q)) + for k := range q { + keys = append(keys, k) + } + sort.Strings(keys) + + b.WriteString(`

Queries`) + fmt.Fprintf(b, ` %d

`, len(keys)) + for _, k := range keys { + entry, _ := q[k].(map[string]any) + writeQueryEntry(b, k, entry) + } + b.WriteString(`
`) +} + +func writeQueryEntry(b *strings.Builder, key string, entry map[string]any) { + if entry == nil { + return + } + qname, qtype := splitQueryKey(key) + + kind := queryKindLabel(entry) + worst := worstQueryStatus(entry) + + fmt.Fprintf(b, `
%s%s%s`, + html.EscapeString(qname), html.EscapeString(qtype), html.EscapeString(kind)) + if worst != "" { + fmt.Fprintf(b, ` %s`, recordStatusClass(worst), html.EscapeString(worst)) + } + b.WriteString(`
`) + + if ans, ok := entry["answer"].([]any); ok { + for _, a := range ans { + writeAnswerRRset(b, a) + } + } + if nd, ok := entry["nodata"].([]any); ok { + for _, p := range nd { + writeNegativeProof(b, p, "NODATA") + } + } + if nx, ok := entry["nxdomain"].([]any); ok { + for _, p := range nx { + writeNegativeProof(b, p, "NXDOMAIN") + } + } + if er, ok := entry["error"].([]any); ok { + for _, e := range er { + writeQueryError(b, e) + } + } + b.WriteString(`
`) +} + +func splitQueryKey(k string) (name, typ string) { + parts := strings.Split(k, "/") + if len(parts) >= 3 { + return parts[0], parts[len(parts)-1] + } + return k, "" +} + +func queryKindLabel(entry map[string]any) string { + for _, k := range []string{"answer", "nodata", "nxdomain", "error", "referral"} { + if v, ok := entry[k]; ok { + if arr, ok := v.([]any); ok && len(arr) > 0 { + return strings.ToUpper(k) + } + } + } + return "" +} + +func worstQueryStatus(entry map[string]any) string { + worst := "" + rank := func(s string) int { + switch strings.ToUpper(s) { + case "BOGUS", "INVALID": + return 4 + case "EXPIRED", "PREMATURE": + return 4 + case "INDETERMINATE": + return 2 + case "INSECURE": + return 1 + case "VALID", "SECURE": + return 0 + } + return 0 + } + var walk func(any) + walk = func(node any) { + switch v := node.(type) { + case map[string]any: + if s, ok := v["status"].(string); ok { + if rank(s) > rank(worst) { + worst = s + } + } + for _, val := range v { + walk(val) + } + case []any: + for _, item := range v { + walk(item) + } + } + } + walk(entry) + if rank(worst) == 0 { + return "" + } + return worst +} + +func writeAnswerRRset(b *strings.Builder, node any) { + a, ok := node.(map[string]any) + if !ok { + return + } + rdata, _ := a["rdata"].([]any) + ttl := numAsInt(a["ttl"]) + desc, _ := a["description"].(string) + + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(`RRset`) + if ttl > 0 { + fmt.Fprintf(b, `TTL %d`, ttl) + } + b.WriteString(`
`) + b.WriteString(`
`) + if desc != "" { + fmt.Fprintf(b, `%s`, html.EscapeString(desc)) + } + if len(rdata) > 0 { + b.WriteString(`
    `) + for _, r := range rdata { + if s, ok := r.(string); ok { + fmt.Fprintf(b, `
  • %s
  • `, html.EscapeString(s)) + } + } + b.WriteString(`
`) + } + b.WriteString(`
`) + + if rrsigs, ok := a["rrsig"].([]any); ok { + for _, rs := range rrsigs { + writeRRSIG(b, rs) + } + } +} + +func writeRRSIG(b *strings.Builder, node any) { + r, ok := node.(map[string]any) + if !ok { + return + } + status, _ := r["status"].(string) + alg := numAsInt(r["algorithm"]) + keyTag := numAsInt(r["key_tag"]) + signer, _ := r["signer"].(string) + insep, _ := r["inception"].(string) + expir, _ := r["expiration"].(string) + + fmt.Fprintf(b, `
`, recordStatusClass(status)) + b.WriteString(`
`) + b.WriteString(`RRSIG`) + fmt.Fprintf(b, `%s`, html.EscapeString(dnssecAlgName(alg))) + fmt.Fprintf(b, `key tag %d`, keyTag) + if signer != "" { + fmt.Fprintf(b, `signer %s`, html.EscapeString(signer)) + } + b.WriteString(`
`) + b.WriteString(`
`) + if insep != "" || expir != "" { + fmt.Fprintf(b, `valid %s → %s`, html.EscapeString(insep), html.EscapeString(expir)) + } + b.WriteString(`
`) + b.WriteString(`
`) + if status != "" { + fmt.Fprintf(b, `%s`, recordStatusClass(status), html.EscapeString(status)) + } + b.WriteString(`
`) +} + +func writeNegativeProof(b *strings.Builder, node any, kind string) { + p, ok := node.(map[string]any) + if !ok { + return + } + proofs, _ := p["proof"].([]any) + b.WriteString(`
`) + b.WriteString(`
`) + fmt.Fprintf(b, `%s`, html.EscapeString(kind)) + fmt.Fprintf(b, `%d NSEC proof%s`, len(proofs), pluralS(len(proofs))) + b.WriteString(`
`) + for _, pr := range proofs { + writeNSECProof(b, pr) + } +} + +func writeNSECProof(b *strings.Builder, node any) { + p, ok := node.(map[string]any) + if !ok { + return + } + status, _ := p["status"].(string) + desc, _ := p["description"].(string) + fmt.Fprintf(b, `
`, recordStatusClass(status)) + b.WriteString(`
NSEC
`) + b.WriteString(`
`) + b.WriteString(html.EscapeString(desc)) + b.WriteString(`
`) + b.WriteString(`
`) + if status != "" { + fmt.Fprintf(b, `%s`, recordStatusClass(status), html.EscapeString(status)) + } + b.WriteString(`
`) +} + +func writeQueryError(b *strings.Builder, node any) { + e, ok := node.(map[string]any) + if !ok { + return + } + desc, _ := e["description"].(string) + if desc == "" { + desc, _ = e["message"].(string) + } + if desc == "" { + j, _ := json.Marshal(e) + desc = string(j) + } + b.WriteString(`
ERROR
`) + fmt.Fprintf(b, `
%s
`, html.EscapeString(desc)) +} + +// ── findings list (errors/warnings collected across the zone tree) ─────── + +func writeZoneFindings(b *strings.Builder, z ZoneAnalysis) { + if len(z.Errors) == 0 && len(z.Warnings) == 0 { + b.WriteString(`

DNSViz reported no problem at this level.

`) + return + } + b.WriteString(`

Findings`) + fmt.Fprintf(b, ` %d error%s, %d warning%s

`, + len(z.Errors), pluralS(len(z.Errors)), + len(z.Warnings), pluralS(len(z.Warnings))) + if len(z.Errors) > 0 { + b.WriteString(`
    `) + for _, f := range z.Errors { + writeFindingLI(b, f, "err") + } + b.WriteString(`
`) + } + if len(z.Warnings) > 0 { + b.WriteString(`
    `) + for _, f := range z.Warnings { + writeFindingLI(b, f, "warn") + } + b.WriteString(`
`) + } + b.WriteString(`
`) +} + +func writeFindingLI(b *strings.Builder, f Finding, klass string) { + fmt.Fprintf(b, `
  • `, klass) + if f.Code != "" { + fmt.Fprintf(b, `%s `, html.EscapeString(f.Code)) + } + b.WriteString(html.EscapeString(f.Description)) + if len(f.Servers) > 0 { + fmt.Fprintf(b, ` (%s)`, html.EscapeString(strings.Join(f.Servers, ", "))) + } + if f.Path != "" { + fmt.Fprintf(b, `at %s`, html.EscapeString(f.Path)) + } + b.WriteString(`
  • `) +} + +// ── helpers ────────────────────────────────────────────────────────────── + +func recordStatusClass(s string) string { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "": + return "s-none" + case "VALID", "SECURE": + return "s-OK" + case "INSECURE", "NON_EXISTENT": + return "s-INFO" + case "INDETERMINATE", "INDETERMINATE_DS": + return "s-WARN" + case "BOGUS", "INVALID", "EXPIRED", "PREMATURE", "MISSING": + return "s-CRIT" + } + return "s-UNKNOWN" +} + +func dnssecAlgName(n int) string { + switch n { + case 1: + return "RSAMD5" + case 3: + return "DSA" + case 5: + return "RSASHA1" + case 6: + return "DSA-NSEC3-SHA1" + case 7: + return "RSASHA1-NSEC3" + case 8: + return "RSASHA256" + case 10: + return "RSASHA512" + case 12: + return "ECC-GOST" + case 13: + return "ECDSAP256SHA256" + case 14: + return "ECDSAP384SHA384" + case 15: + return "ED25519" + case 16: + return "ED448" + case 0: + return "?" + } + return fmt.Sprintf("alg %d", n) +} + +func digestTypeName(n int) string { + switch n { + case 1: + return "SHA-1" + case 2: + return "SHA-256" + case 3: + return "GOST R 34.11-94" + case 4: + return "SHA-384" + } + return fmt.Sprintf("type %d", n) +} + +func dnskeyFlagsLabel(f int) string { + zone := f&0x100 != 0 + sep := f&0x1 != 0 + rev := f&0x80 != 0 + switch { + case rev && zone && sep: + return "KSK (revoked)" + case zone && sep: + return "KSK" + case zone: + return "ZSK" + } + return fmt.Sprintf("flags %d", f) +} + +func numAsInt(v any) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case int64: + return int(n) + case json.Number: + i, _ := n.Int64() + return int(i) + } + return 0 +} + +func truncMid(s string, max int) string { + if max <= 0 || len(s) <= max { + return s + } + if max < 5 { + return s[:max] + } + half := (max - 1) / 2 + return s[:half] + "…" + s[len(s)-half:] +} + +func pluralS(n int) string { + if n == 1 { + return "" + } + return "s" +} + +// worstStatus returns the highest-severity status in states, using the same +// ordering buildFixes relies on (Crit > Error > Warn > Info > OK > Unknown). +// Returns StatusOK when states is empty. +func worstStatus(states []sdk.CheckState) sdk.Status { + if len(states) == 0 { + return sdk.StatusOK + } + worst := states[0].Status + for _, s := range states[1:] { + if s.Status > worst { + worst = s.Status + } + } + return worst +} diff --git a/checker/report_test.go b/checker/report_test.go new file mode 100644 index 0000000..afd3c41 --- /dev/null +++ b/checker/report_test.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT + +package checker + +import ( + "encoding/json" + "strings" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func TestWorstStatus(t *testing.T) { + if got := worstStatus(nil); got != sdk.StatusOK { + t.Errorf("nil states: got %v, want OK", got) + } + got := worstStatus([]sdk.CheckState{ + {Status: sdk.StatusOK}, + {Status: sdk.StatusWarn}, + {Status: sdk.StatusCrit}, + {Status: sdk.StatusInfo}, + }) + if got != sdk.StatusCrit { + t.Errorf("got %v, want Crit", got) + } +} + +func TestTitleAndHint(t *testing.T) { + title, hint := titleAndHint(sdk.CheckState{ + Message: "fallback", + Meta: map[string]any{"title": "T", "hint": "H"}, + }) + if title != "T" || hint != "H" { + t.Errorf("got (%q,%q), want (T,H)", title, hint) + } + // Falls back to message when no title in meta. + title, _ = titleAndHint(sdk.CheckState{Message: "fb"}) + if title != "fb" { + t.Errorf("expected fallback to Message, got %q", title) + } +} + +func TestGetHTMLReport_EmptyContext(t *testing.T) { + p := &dnsvizProvider{} + out, err := p.GetHTMLReport(sdk.StaticReportContext(nil)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "No DNSViz data and no rule states") { + t.Errorf("expected empty banner, got: %s", out) + } +} + +func TestGetHTMLReport_FullDocument(t *testing.T) { + data := &DNSVizData{ + Domain: "example.com", + Order: []string{"example.com.", "com.", "."}, + Zones: map[string]ZoneAnalysis{ + "example.com.": { + Status: "BOGUS", + Errors: []Finding{{Code: "RRSIG_EXPIRED", Description: "signature has expired", Servers: []string{"ns1"}}}, + }, + "com.": {Status: "SECURE"}, + ".": {Status: "SECURE"}, + }, + Raw: []byte(`{"example.com.": {"status": "BOGUS"}}`), + ProbeStderr: "probe-warning", + GrokStderr: "grok-warning", + } + rawJSON, _ := json.Marshal(data) + states := []sdk.CheckState{ + {Status: sdk.StatusCrit, Code: "dnssec_rrsig_expired", Subject: "example.com.", Message: "Signature expired", + Meta: map[string]any{"title": "Signature expired", "hint": "Re-sign the zone."}}, + {Status: sdk.StatusOK, Code: "dnsviz_overall_status", Message: "ok"}, + } + p := &dnsvizProvider{} + out, err := p.GetHTMLReport(sdk.NewReportContext(rawJSON, nil, states)) + if err != nil { + t.Fatal(err) + } + wantContains := []string{ + "DNSSEC report: example.com", + `class="banner s-CRIT"`, + "Fix these first", + "Re-sign the zone.", + "DNS hierarchy", + "RRSIG_EXPIRED", + "All rule states", + "probe-warning", + "grok-warning", + } + for _, sub := range wantContains { + if !strings.Contains(out, sub) { + t.Errorf("HTML missing %q", sub) + } + } + // Ensure XSS-prone strings are escaped. + xssData := &DNSVizData{ + Domain: ``, + Order: []string{"x."}, + Zones: map[string]ZoneAnalysis{"x.": {Status: "SECURE"}}, + } + rawXSS, _ := json.Marshal(xssData) + xssOut, _ := p.GetHTMLReport(sdk.StaticReportContext(rawXSS)) + if strings.Contains(xssOut, "") { + t.Errorf("unescaped