Initial commit

This commit is contained in:
nemunaire 2026-04-26 18:44:22 +07:00
commit 30710adfcd
29 changed files with 3955 additions and 0 deletions

246
checker/collect.go Normal file
View file

@ -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.<zone>/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
// "<zone>/IN/<RRTYPE>". 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
}

223
checker/collect_test.go Normal file
View file

@ -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)
}
}

52
checker/definition.go Normal file
View file

@ -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
}

39
checker/interactive.go Normal file
View file

@ -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
}

31
checker/provider.go Normal file
View file

@ -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)
}

73
checker/provider_test.go Normal file
View file

@ -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")
}
}

999
checker/report.go Normal file
View file

@ -0,0 +1,999 @@
// 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 produces a self-contained HTML page that:
//
// - Banners the overall DNSViz status of the queried domain.
// - Lists "Fix these first": the curated common-failure matches and any
// critical state, with the human-readable hint pulled from CheckState.Meta.
// - Renders one block per zone in the chain (root → … → leaf), with the
// zone's status, errors and warnings, so a recursive DNSSEC failure
// can be located at the exact level it broke.
// - Falls back to a raw-JSON dump when no rule states were threaded in.
//
// The page skeleton is rendered through html/template (auto-escaping every
// user-controlled field) and the deep per-zone subtree is built as an HTML
// fragment by writeChain, which goes through html.EscapeString itself.
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(`<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>DNSSEC report: {{.Domain}}</title>
<style>` + reportCSS + `</style></head><body>
<header><h1>DNSSEC analysis</h1><p class="domain">{{.DomainHdr}}</p></header>
{{- if .Empty}}
<p class="empty">No DNSViz data and no rule states. The check probably failed before producing any output.</p>
{{- else -}}
{{with .Banner}}<div class="banner s-{{.Status}}">Overall: {{.Status}}<small>DNSViz status of {{.Leaf}}: {{.LeafSt}}</small></div>{{end}}
{{if .Fixes}}<section class="section"><h2>Fix these first</h2>
{{range .Fixes}}<div class="{{.Class}}"><h4>{{.Title}}</h4><div class="where">at <code>{{.Subject}}</code>, rule <code>{{.Code}}</code></div>{{if .Hint}}<p class="hint">{{.Hint}}</p>{{end}}</div>
{{end}}</section>{{end}}
{{.Chain}}
{{if .States}}<section class="section"><h2>All rule states</h2><table><thead><tr><th>Status</th><th>Subject</th><th>Code</th><th>Message</th></tr></thead><tbody>
{{range .States}}<tr><td><span class="status s-{{.Status}}">{{.Status}}</span></td><td><code>{{.Subject}}</code></td><td><code>{{.Code}}</code></td><td>{{.Message}}</td></tr>
{{end}}</tbody></table></section>{{end}}
{{if .HasRaw}}<section class="section"><details><summary>Raw <code>dnsviz grok</code> output</summary><pre>{{.Raw}}</pre></details>
{{if .ProbeStderr}}<details><summary>dnsviz probe stderr</summary><pre>{{.ProbeStderr}}</pre></details>{{end}}
{{if .GrokStderr}}<details><summary>dnsviz grok stderr</summary><pre>{{.GrokStderr}}</pre></details>{{end}}</section>{{end}}
{{- end}}
</body></html>`))
// ── 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) ───────
//
// renderChain returns the full <section> for the DNS hierarchy. It is
// injected into the top-level template as template.HTML, so every dynamic
// piece below MUST go through html.EscapeString. The functions in this
// section are individual record-builders; each one is responsible for
// escaping its own inputs.
func renderChain(data *DNSVizData) string {
zones := orderedZones(data)
if len(zones) == 0 {
return ""
}
var rawZones map[string]json.RawMessage
if len(data.Raw) > 0 {
_ = json.Unmarshal(data.Raw, &rawZones)
}
var b strings.Builder
b.WriteString(`<section class="section"><h2>DNS hierarchy (root → leaf)</h2>`)
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 {
_ = json.Unmarshal(rb, &raw)
}
writeZoneBlock(&b, name, i, len(zones), z, raw)
}
b.WriteString(`</section>`)
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, `<details class="zone s-%s"%s>`, st.String(), openAttr)
b.WriteString(`<summary class="zone-head">`)
fmt.Fprintf(b, `<h3>%s</h3>`, html.EscapeString(name))
if level != "" {
fmt.Fprintf(b, `<span class="level">%s</span>`, html.EscapeString(level))
}
fmt.Fprintf(b, `<span class="badge s-%s">%s</span>`, st.String(), html.EscapeString(emptyAsUnknown(z.Status)))
if z.DNSStatus != "" && !strings.EqualFold(z.DNSStatus, z.Status) {
fmt.Fprintf(b, `<span class="badge ghost">DNS: %s</span>`, html.EscapeString(z.DNSStatus))
}
if n := len(z.Errors); n > 0 {
fmt.Fprintf(b, `<span class="badge s-CRIT">%d error%s</span>`, n, pluralS(n))
}
if n := len(z.Warnings); n > 0 {
fmt.Fprintf(b, `<span class="badge s-WARN">%d warning%s</span>`, n, pluralS(n))
}
b.WriteString(`</summary>`)
b.WriteString(`<div class="zone-body">`)
if raw != nil {
writeDelegationSubsec(b, raw["delegation"])
writeDNSKEYSubsec(b, raw["dnskey"])
writeServersSubsec(b, raw["zone"])
writeQueriesSubsec(b, raw["queries"])
}
writeZoneFindings(b, z)
b.WriteString(`</div></details>`)
}
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(`<section class="subsec"><h4>Delegation (DS at parent)`)
if delStatus != "" {
fmt.Fprintf(b, ` <span class="badge %s">%s</span>`, recordStatusClass(delStatus), html.EscapeString(delStatus))
}
fmt.Fprintf(b, ` <span class="count">%d DS</span></h4>`, len(dsArr))
if len(dsArr) == 0 {
b.WriteString(`<p class="empty-sub">No DS record published at the parent: zone is unsigned (INSECURE).</p></section>`)
return
}
b.WriteString(`<div class="records">`)
for _, item := range dsArr {
ds, _ := item.(map[string]any)
writeDSRecord(b, ds)
}
b.WriteString(`</div></section>`)
}
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, `<div class="record %s">`, recordStatusClass(status))
b.WriteString(`<div class="lhs">`)
fmt.Fprintf(b, `<span class="badge alg">%s</span>`, html.EscapeString(dnssecAlgName(alg)))
fmt.Fprintf(b, `<span class="kv">key tag <b>%d</b></span>`, keyTag)
fmt.Fprintf(b, `<span class="kv">digest <b>%s</b></span>`, html.EscapeString(digestTypeName(digestType)))
b.WriteString(`</div>`)
b.WriteString(`<div class="desc"><span class="mono" title="DS digest">`)
b.WriteString(html.EscapeString(truncMid(digest, 40)))
b.WriteString(`</span></div>`)
b.WriteString(`<div class="meta">`)
if status != "" {
fmt.Fprintf(b, `<span class="badge %s">%s</span>`, recordStatusClass(status), html.EscapeString(status))
}
b.WriteString(`</div></div>`)
}
// ── DNSKEY set at apex ───────────────────────────────────────────────────
func writeDNSKEYSubsec(b *strings.Builder, node any) {
arr, ok := node.([]any)
if !ok {
return
}
b.WriteString(`<section class="subsec"><h4>DNSKEY set at apex`)
fmt.Fprintf(b, ` <span class="count">%d key%s</span></h4>`, len(arr), pluralS(len(arr)))
if len(arr) == 0 {
b.WriteString(`<p class="empty-sub">No DNSKEY published at the apex.</p></section>`)
return
}
b.WriteString(`<div class="records">`)
for _, item := range arr {
k, _ := item.(map[string]any)
writeDNSKEYRecord(b, k)
}
b.WriteString(`</div></section>`)
}
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, `<div class="record %s">`, recordStatusClass(status))
b.WriteString(`<div class="lhs">`)
fmt.Fprintf(b, `<span class="badge flag">%s</span>`, html.EscapeString(dnskeyFlagsLabel(flags)))
fmt.Fprintf(b, `<span class="badge alg">%s</span>`, html.EscapeString(dnssecAlgName(alg)))
fmt.Fprintf(b, `<span class="kv">key tag <b>%d</b></span>`, keyTag)
if keyLen > 0 {
fmt.Fprintf(b, `<span class="kv">%d bits</span>`, keyLen)
}
b.WriteString(`</div>`)
b.WriteString(`<div class="desc"></div>`)
b.WriteString(`<div class="meta">`)
if status != "" {
fmt.Fprintf(b, `<span class="badge %s">%s</span>`, recordStatusClass(status), html.EscapeString(status))
}
b.WriteString(`</div></div>`)
}
// ── 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(`<section class="subsec"><h4>Authoritative servers`)
fmt.Fprintf(b, ` <span class="count">%d</span></h4>`, len(names))
b.WriteString(`<div class="servers-grid">`)
for _, n := range names {
entry, _ := servers[n].(map[string]any)
b.WriteString(`<div class="server">`)
fmt.Fprintf(b, `<h5>%s</h5>`, html.EscapeString(n))
writeIPList(b, "auth", entry["auth"])
writeIPList(b, "glue", entry["glue"])
b.WriteString(`</div>`)
}
b.WriteString(`</div></section>`)
}
func writeIPList(b *strings.Builder, label string, node any) {
arr, ok := node.([]any)
if !ok || len(arr) == 0 {
return
}
fmt.Fprintf(b, `<div><span class="kv">%s</span><ul>`, html.EscapeString(label))
for _, ip := range arr {
if s, ok := ip.(string); ok {
fmt.Fprintf(b, `<li class="mono">%s</li>`, html.EscapeString(s))
}
}
b.WriteString(`</ul></div>`)
}
// ── 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(`<section class="subsec queries"><h4>Queries`)
fmt.Fprintf(b, ` <span class="count">%d</span></h4>`, len(keys))
for _, k := range keys {
entry, _ := q[k].(map[string]any)
writeQueryEntry(b, k, entry)
}
b.WriteString(`</section>`)
}
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, `<details><summary><span class="qname">%s</span><span class="qtype">%s</span><span class="qkind">%s</span>`,
html.EscapeString(qname), html.EscapeString(qtype), html.EscapeString(kind))
if worst != "" {
fmt.Fprintf(b, ` <span class="badge %s">%s</span>`, recordStatusClass(worst), html.EscapeString(worst))
}
b.WriteString(`</summary><div class="qbody">`)
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(`</div></details>`)
}
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(`<div class="record s-OK">`)
b.WriteString(`<div class="lhs">`)
b.WriteString(`<span class="badge ghost">RRset</span>`)
if ttl > 0 {
fmt.Fprintf(b, `<span class="kv">TTL <b>%d</b></span>`, ttl)
}
b.WriteString(`</div>`)
b.WriteString(`<div class="desc">`)
if desc != "" {
fmt.Fprintf(b, `<small>%s</small>`, html.EscapeString(desc))
}
if len(rdata) > 0 {
b.WriteString(`<ul class="rdata">`)
for _, r := range rdata {
if s, ok := r.(string); ok {
fmt.Fprintf(b, `<li>%s</li>`, html.EscapeString(s))
}
}
b.WriteString(`</ul>`)
}
b.WriteString(`</div><div class="meta"></div></div>`)
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, `<div class="record %s">`, recordStatusClass(status))
b.WriteString(`<div class="lhs">`)
b.WriteString(`<span class="badge ghost">RRSIG</span>`)
fmt.Fprintf(b, `<span class="badge alg">%s</span>`, html.EscapeString(dnssecAlgName(alg)))
fmt.Fprintf(b, `<span class="kv">key tag <b>%d</b></span>`, keyTag)
if signer != "" {
fmt.Fprintf(b, `<span class="kv">signer <b>%s</b></span>`, html.EscapeString(signer))
}
b.WriteString(`</div>`)
b.WriteString(`<div class="desc"><small>`)
if insep != "" || expir != "" {
fmt.Fprintf(b, `valid %s → %s`, html.EscapeString(insep), html.EscapeString(expir))
}
b.WriteString(`</small></div>`)
b.WriteString(`<div class="meta">`)
if status != "" {
fmt.Fprintf(b, `<span class="badge %s">%s</span>`, recordStatusClass(status), html.EscapeString(status))
}
b.WriteString(`</div></div>`)
}
func writeNegativeProof(b *strings.Builder, node any, kind string) {
p, ok := node.(map[string]any)
if !ok {
return
}
proofs, _ := p["proof"].([]any)
b.WriteString(`<div class="record s-INFO">`)
b.WriteString(`<div class="lhs">`)
fmt.Fprintf(b, `<span class="badge ghost">%s</span>`, html.EscapeString(kind))
fmt.Fprintf(b, `<span class="kv">%d NSEC proof%s</span>`, len(proofs), pluralS(len(proofs)))
b.WriteString(`</div><div class="desc"></div><div class="meta"></div></div>`)
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, `<div class="record %s">`, recordStatusClass(status))
b.WriteString(`<div class="lhs"><span class="badge ghost">NSEC</span></div>`)
b.WriteString(`<div class="desc"><small>`)
b.WriteString(html.EscapeString(desc))
b.WriteString(`</small></div>`)
b.WriteString(`<div class="meta">`)
if status != "" {
fmt.Fprintf(b, `<span class="badge %s">%s</span>`, recordStatusClass(status), html.EscapeString(status))
}
b.WriteString(`</div></div>`)
}
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(`<div class="record s-CRIT"><div class="lhs"><span class="badge s-CRIT">ERROR</span></div>`)
fmt.Fprintf(b, `<div class="desc">%s</div><div class="meta"></div></div>`, 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(`<section class="subsec"><p class="empty-sub" style="color:#2e7d32;font-style:normal">DNSViz reported no problem at this level.</p></section>`)
return
}
b.WriteString(`<section class="subsec"><h4>Findings`)
fmt.Fprintf(b, ` <span class="count">%d error%s, %d warning%s</span></h4>`,
len(z.Errors), pluralS(len(z.Errors)),
len(z.Warnings), pluralS(len(z.Warnings)))
if len(z.Errors) > 0 {
b.WriteString(`<ul class="findings">`)
for _, f := range z.Errors {
writeFindingLI(b, f, "err")
}
b.WriteString(`</ul>`)
}
if len(z.Warnings) > 0 {
b.WriteString(`<ul class="findings">`)
for _, f := range z.Warnings {
writeFindingLI(b, f, "warn")
}
b.WriteString(`</ul>`)
}
b.WriteString(`</section>`)
}
func writeFindingLI(b *strings.Builder, f Finding, klass string) {
fmt.Fprintf(b, `<li class="%s">`, klass)
if f.Code != "" {
fmt.Fprintf(b, `<code>%s</code> `, html.EscapeString(f.Code))
}
b.WriteString(html.EscapeString(f.Description))
if len(f.Servers) > 0 {
fmt.Fprintf(b, ` <small>(%s)</small>`, html.EscapeString(strings.Join(f.Servers, ", ")))
}
if f.Path != "" {
fmt.Fprintf(b, `<small class="path">at <code>%s</code></small>`, html.EscapeString(f.Path))
}
b.WriteString(`</li>`)
}
// ── 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
}

170
checker/report_test.go Normal file
View file

@ -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{
"<title>DNSSEC report: example.com</title>",
`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: `<script>alert(1)</script>`,
Order: []string{"x."},
Zones: map[string]ZoneAnalysis{"x.": {Status: "SECURE"}},
}
rawXSS, _ := json.Marshal(xssData)
xssOut, _ := p.GetHTMLReport(sdk.StaticReportContext(rawXSS))
if strings.Contains(xssOut, "<script>alert(1)</script>") {
t.Errorf("unescaped <script> in report: %s", xssOut)
}
}
func TestGetHTMLReport_BadJSON(t *testing.T) {
p := &dnsvizProvider{}
_, err := p.GetHTMLReport(sdk.StaticReportContext(json.RawMessage("not json")))
if err == nil {
t.Fatal("expected error for malformed data")
}
}
func TestExtractMetrics(t *testing.T) {
data := &DNSVizData{
Zones: map[string]ZoneAnalysis{
"example.com.": {Errors: []Finding{{}, {}}, Warnings: []Finding{{}}},
"com.": {},
},
}
rawJSON, _ := json.Marshal(data)
states := []sdk.CheckState{
{Status: sdk.StatusOK},
{Status: sdk.StatusCrit},
{Status: sdk.StatusCrit},
}
p := &dnsvizProvider{}
now := time.Now()
metrics, err := p.ExtractMetrics(sdk.NewReportContext(rawJSON, nil, states), now)
if err != nil {
t.Fatal(err)
}
byName := map[string]float64{}
statusCounts := map[string]float64{}
for _, m := range metrics {
if !m.Timestamp.Equal(now) {
t.Errorf("metric %q has wrong timestamp", m.Name)
}
if m.Name == "dnsviz.findings.count" {
statusCounts[m.Labels["status"]] = m.Value
continue
}
byName[m.Name] = m.Value
}
if byName["dnsviz.zones.count"] != 2 {
t.Errorf("zones.count = %v, want 2", byName["dnsviz.zones.count"])
}
if byName["dnsviz.errors.count"] != 2 {
t.Errorf("errors.count = %v, want 2", byName["dnsviz.errors.count"])
}
if byName["dnsviz.warnings.count"] != 1 {
t.Errorf("warnings.count = %v, want 1", byName["dnsviz.warnings.count"])
}
if statusCounts["CRIT"] != 2 || statusCounts["OK"] != 1 {
t.Errorf("findings counts: %v", statusCounts)
}
}
func TestExtractMetrics_BadJSON(t *testing.T) {
p := &dnsvizProvider{}
_, err := p.ExtractMetrics(sdk.StaticReportContext(json.RawMessage("not json")), time.Now())
if err == nil {
t.Fatal("expected error")
}
}

75
checker/rule.go Normal file
View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules returns the full rule set evaluated against a DNSVizData observation.
// Subject is the zone FQDN so a fault at the TLD is never silently merged with a leaf fault.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&overallStatusRule{},
&perZoneStatusRule{},
&zoneErrorsRule{},
&zoneWarningsRule{},
&commonFailuresRule{},
}
}
func loadData(ctx context.Context, obs sdk.ObservationGetter, code string) (*DNSVizData, []sdk.CheckState) {
var data DNSVizData
if err := obs.Get(ctx, ObservationKeyDNSViz, &data); err != nil {
return nil, []sdk.CheckState{{
Status: sdk.StatusError,
Code: code,
Message: fmt.Sprintf("Failed to load DNSViz observation: %v", err),
}}
}
return &data, nil
}
func orderedZones(data *DNSVizData) []string {
if len(data.Order) > 0 {
return data.Order
}
keys := make([]string, 0, len(data.Zones))
for k := range data.Zones {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
return labelDepth(keys[i]) > labelDepth(keys[j])
})
return keys
}
func statusFromGrok(s string) sdk.Status {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "SECURE":
return sdk.StatusOK
case "INSECURE":
// "INSECURE" means "no DNSSEC and no parent DS": informational, not
// a failure. Rules elsewhere can still flag a missing chain.
return sdk.StatusInfo
case "BOGUS":
return sdk.StatusCrit
case "INDETERMINATE":
return sdk.StatusWarn
case "NON_EXISTENT":
return sdk.StatusInfo
case "NOERROR":
// DNS-level OK with no DNSSEC chain status reported. The zone
// resolves but isn't signed (or grok didn't classify it).
return sdk.StatusInfo
case "":
return sdk.StatusUnknown
default:
return sdk.StatusUnknown
}
}

96
checker/rule_test.go Normal file
View file

@ -0,0 +1,96 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"errors"
"reflect"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestStatusFromGrok(t *testing.T) {
cases := map[string]sdk.Status{
"SECURE": sdk.StatusOK,
" secure ": sdk.StatusOK,
"INSECURE": sdk.StatusInfo,
"BOGUS": sdk.StatusCrit,
"INDETERMINATE": sdk.StatusWarn,
"NON_EXISTENT": sdk.StatusInfo,
"NOERROR": sdk.StatusInfo,
"": sdk.StatusUnknown,
"WHATEVER": sdk.StatusUnknown,
}
for in, want := range cases {
if got := statusFromGrok(in); got != want {
t.Errorf("statusFromGrok(%q) = %v, want %v", in, got, want)
}
}
}
func TestOrderedZones_PrefersOrder(t *testing.T) {
d := &DNSVizData{
Order: []string{"keep.", "this."},
Zones: map[string]ZoneAnalysis{"keep.": {}, "this.": {}, "other.": {}},
}
if got := orderedZones(d); !reflect.DeepEqual(got, []string{"keep.", "this."}) {
t.Errorf("expected explicit Order to be returned, got %v", got)
}
}
func TestOrderedZones_SortsByDepth(t *testing.T) {
d := &DNSVizData{
Zones: map[string]ZoneAnalysis{
".": {},
"com.": {},
"example.com.": {},
},
}
got := orderedZones(d)
want := []string{"example.com.", "com.", "."}
if !reflect.DeepEqual(got, want) {
t.Errorf("orderedZones=%v, want %v", got, want)
}
}
func TestLoadData_ReturnsErrorState(t *testing.T) {
obs := stubObs{err: errors.New("boom")}
data, errState := loadData(context.Background(), obs, "code_x")
if data != nil {
t.Errorf("expected nil data on error, got %+v", data)
}
if len(errState) != 1 || errState[0].Status != sdk.StatusError || errState[0].Code != "code_x" {
t.Errorf("unexpected error state: %+v", errState)
}
}
func TestLoadData_Success(t *testing.T) {
d := &DNSVizData{Domain: "example.com", Zones: map[string]ZoneAnalysis{"example.com.": {Status: "SECURE"}}}
obs := stubObs{value: d}
got, errState := loadData(context.Background(), obs, "code_y")
if errState != nil {
t.Fatalf("unexpected error state: %+v", errState)
}
if got == nil || got.Domain != "example.com" {
t.Errorf("unexpected data: %+v", got)
}
}
func TestRules_Wired(t *testing.T) {
rs := Rules()
if len(rs) == 0 {
t.Fatal("expected at least one rule")
}
seen := map[string]bool{}
for _, r := range rs {
if r.Name() == "" || r.Description() == "" {
t.Errorf("rule has empty Name/Description: %T", r)
}
if seen[r.Name()] {
t.Errorf("duplicate rule name: %s", r.Name())
}
seen[r.Name()] = true
}
}

214
checker/rules_common.go Normal file
View file

@ -0,0 +1,214 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// commonFailuresRule pattern-matches DNSViz finding descriptions and codes
// against a curated list of "user-facing" failure scenarios so the report
// can put plain-language explanations and remediation hints next to them.
//
// The matching is intentionally permissive: DNSViz wording shifts between
// versions, so we look for substrings rather than exact codes.
type commonFailuresRule struct{}
func (r *commonFailuresRule) Name() string { return "dnsviz_common_failures" }
func (r *commonFailuresRule) Description() string {
return "Highlights well-known DNSSEC failure scenarios (broken chain, expired signatures, missing/extra DS, algorithm mismatch, …) with remediation hints."
}
// CommonFailure is the catalog entry for a recognized scenario. It is
// exported so the report layer can pull the human explanation/hint out of
// CheckState.Meta and render the curated "fix this first" sections.
type CommonFailure struct {
ID string // Stable code emitted in CheckState.Code.
Title string // Short headline for the report block.
Hint string // What the user should typically do.
Patterns []string // Substrings (lowercased) matched against the finding's code+description.
Severity sdk.Status
}
// commonFailures is the curated catalog. Order matters: the first matching
// entry wins, so put more specific scenarios above the generic ones.
var commonFailures = []CommonFailure{
{
ID: "dnssec_chain_broken_no_ds",
Title: "Parent has no DS record for this zone",
Hint: "Publish the DS record(s) generated from your KSK at the registrar/parent. Without DS, validators see the zone as INSECURE even when DNSKEYs are present.",
Patterns: []string{
"no ds records",
"missing ds",
"no ds record was found",
"ds_records_missing",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_ds_digest_mismatch",
Title: "DS at parent does not match any DNSKEY at the child",
Hint: "The DS digest at the parent does not match any DNSKEY served by the child. Re-export the DS record from your current KSK and update it at the registrar; remove stale DS entries.",
Patterns: []string{
"ds does not match",
"no matching dnskey",
"ds_does_not_match",
"no dnskey matching",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_rrsig_expired",
Title: "RRSIG signature has expired",
Hint: "At least one RRSIG is past its expiration. Resign the zone (most signers do this automatically; investigate why the cron/automation didn't run).",
Patterns: []string{
"signature has expired",
"rrsig_expired",
"signature_expired",
"signature expired",
"rrsig expired",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_rrsig_not_yet_valid",
Title: "RRSIG signature is not yet valid",
Hint: "An RRSIG inception time is in the future. Check that the signing host's clock is synchronized (NTP) and that the signer didn't generate signatures with a future inception.",
Patterns: []string{
"signature is not yet valid",
"signature_not_yet_valid",
"inception in the future",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_signature_invalid",
Title: "Cryptographic signature is invalid",
Hint: "A validator could not verify the signature with the published DNSKEY. The zone may have been resigned with a key that was not published, or the served DNSKEY set is inconsistent across servers.",
Patterns: []string{
"signature_invalid",
"signature is invalid",
"bad signature",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_algorithm_mismatch",
Title: "Algorithm declared in DS not present in DNSKEY (or vice versa)",
Hint: "RFC 4035 §2.2 requires that for every algorithm a DS uses, the child must publish at least one DNSKEY with the same algorithm. Either add the missing DNSKEY/DS or retire the orphan.",
Patterns: []string{
"algorithm_missing",
"algorithm not signed",
"missing rrsig for algorithm",
"algorithm mismatch",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_deprecated_algorithm",
Title: "Deprecated DNSSEC algorithm in use",
Hint: "Algorithms 5 (RSASHA1) and 7 (RSASHA1-NSEC3) are deprecated. Roll the KSK/ZSK to algorithm 13 (ECDSAP256SHA256) or 8 (RSASHA256) and update the DS at the parent.",
Patterns: []string{
"deprecated algorithm",
"algorithm_deprecated",
"weak algorithm",
"rsasha1",
"rsa/sha-1",
"rsa-sha1",
"algorithm 5 ",
"algorithm 7 ",
},
Severity: sdk.StatusWarn,
},
{
ID: "dnssec_no_dnskey",
Title: "No DNSKEY served at the apex",
Hint: "The zone declares a DS at the parent but serves no DNSKEY at the apex. Validators see this as BOGUS. Republish the DNSKEY set or remove the DS at the parent.",
Patterns: []string{
"no dnskey",
"dnskey_missing",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_servfail",
Title: "An authoritative server returned SERVFAIL on DNSSEC queries",
Hint: "At least one server on the path returned SERVFAIL. Often caused by a server that doesn't have the keys it should sign with, or by EDNS/UDP fragmentation. Verify the server can answer DNSKEY/RRSIG over both UDP and TCP.",
Patterns: []string{
"servfail",
"server failure",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_inconsistent_responses",
Title: "Authoritative servers disagree",
Hint: "Different authoritative servers serve different DNSKEY/RRSIG/NSEC contents. Confirm that the secondary servers have completed AXFR/IXFR and are serving the same zone version.",
Patterns: []string{
"inconsistent",
"disagree",
},
Severity: sdk.StatusWarn,
},
}
func (r *commonFailuresRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadData(ctx, obs, "dnsviz_common_failures")
if errState != nil {
return errState
}
var out []sdk.CheckState
matched := map[string]struct{}{}
for _, name := range orderedZones(data) {
z := data.Zones[name]
for _, f := range append(append([]Finding(nil), z.Errors...), z.Warnings...) {
haystack := strings.ToLower(f.Code + " " + f.Description)
for _, c := range commonFailures {
if !matchesAny(haystack, c.Patterns) {
continue
}
key := name + "|" + c.ID
if _, seen := matched[key]; seen {
continue
}
matched[key] = struct{}{}
out = append(out, sdk.CheckState{
Status: c.Severity,
Code: c.ID,
Subject: name,
Message: c.Title + ": " + c.Hint,
Meta: map[string]any{
"title": c.Title,
"hint": c.Hint,
"original_code": f.Code,
"original_description": f.Description,
},
})
break
}
}
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "dnsviz_common_failures",
Message: "No well-known DNSSEC failure scenario detected by the heuristics.",
}}
}
return out
}
func matchesAny(haystack string, needles []string) bool {
for _, n := range needles {
if n == "" {
continue
}
if strings.Contains(haystack, n) {
return true
}
}
return false
}

View file

@ -0,0 +1,122 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestMatchesAny(t *testing.T) {
if !matchesAny("foo signature has expired bar", []string{"signature has expired"}) {
t.Error("expected substring match")
}
if matchesAny("clean", []string{"missing"}) {
t.Error("did not expect a match")
}
if matchesAny("anything", []string{""}) {
t.Error("empty needle must not match")
}
if matchesAny("anything", nil) {
t.Error("nil needles must not match")
}
}
func TestCommonFailuresRule_Match(t *testing.T) {
d := &DNSVizData{
Order: []string{"example.com."},
Zones: map[string]ZoneAnalysis{
"example.com.": {
Errors: []Finding{
{Code: "X", Description: "An RRSIG signature has expired"},
},
},
},
}
r := &commonFailuresRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 1 {
t.Fatalf("expected 1 match, got %+v", states)
}
if states[0].Code != "dnssec_rrsig_expired" {
t.Errorf("expected curated id, got %q", states[0].Code)
}
if states[0].Status != sdk.StatusCrit {
t.Errorf("expected severity Crit, got %v", states[0].Status)
}
if _, ok := states[0].Meta["hint"].(string); !ok {
t.Errorf("expected hint in Meta, got %+v", states[0].Meta)
}
}
func TestCommonFailuresRule_NoMatchEmitsOK(t *testing.T) {
d := &DNSVizData{
Order: []string{"example.com."},
Zones: map[string]ZoneAnalysis{
"example.com.": {Errors: []Finding{{Description: "totally unrelated message"}}},
},
}
r := &commonFailuresRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusOK {
t.Errorf("expected single OK summary, got %+v", states)
}
}
func TestCommonFailuresRule_DedupePerZone(t *testing.T) {
// Same scenario surfaced multiple times in a single zone should only emit
// one curated state for that (zone, scenario) pair.
d := &DNSVizData{
Order: []string{"example.com."},
Zones: map[string]ZoneAnalysis{
"example.com.": {
Errors: []Finding{
{Description: "no DS records found"},
{Description: "missing DS at the parent"},
},
},
},
}
r := &commonFailuresRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 1 {
t.Fatalf("expected dedup to a single state, got %+v", states)
}
if states[0].Code != "dnssec_chain_broken_no_ds" {
t.Errorf("unexpected curated code: %q", states[0].Code)
}
}
func TestCommonFailuresRule_FirstMatchWins(t *testing.T) {
// "rrsig_expired" is listed before generic "expired" patterns; verify a
// finding mentioning multiple patterns yields exactly one curated entry.
d := &DNSVizData{
Order: []string{"x."},
Zones: map[string]ZoneAnalysis{
"x.": {Errors: []Finding{{Description: "signature has expired and DS does not match"}}},
},
}
r := &commonFailuresRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 1 {
t.Fatalf("expected single curated entry, got %d: %+v", len(states), states)
}
if !strings.HasPrefix(states[0].Code, "dnssec_") {
t.Errorf("unexpected code %q", states[0].Code)
}
}
func TestCommonFailures_PatternsLowercase(t *testing.T) {
// All catalog patterns are lowercased for substring matching against a
// lowercased haystack. A non-lowercase pattern would silently never match.
for _, c := range commonFailures {
for _, p := range c.Patterns {
if p != strings.ToLower(p) {
t.Errorf("pattern %q in %q is not lowercase", p, c.ID)
}
}
}
}

180
checker/rules_status.go Normal file
View file

@ -0,0 +1,180 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// SECURE means the entire chain validates from root; BOGUS means a broken link somewhere in it.
type overallStatusRule struct{}
func (r *overallStatusRule) Name() string { return "dnsviz_overall_status" }
func (r *overallStatusRule) Description() string {
return "Reports the DNSViz status of the queried domain (SECURE, INSECURE, BOGUS, INDETERMINATE)."
}
func (r *overallStatusRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadData(ctx, obs, "dnsviz_overall_status")
if errState != nil {
return errState
}
leaf := data.Domain + "."
z, ok := data.Zones[leaf]
if !ok {
// Fall back to the most-specific zone DNSViz reported.
zones := orderedZones(data)
if len(zones) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Code: "dnsviz_overall_status",
Message: "DNSViz returned no zones for this domain",
}}
}
leaf = zones[0]
z = data.Zones[leaf]
}
st := sdk.CheckState{
Code: "dnsviz_overall_status",
Subject: leaf,
Status: statusFromGrok(z.Status),
Message: fmt.Sprintf("DNSViz status: %s", emptyAsUnknown(z.Status)),
Meta: map[string]any{
"status": z.Status,
"errors": len(z.Errors),
"warnings": len(z.Warnings),
},
}
return []sdk.CheckState{st}
}
// Subject is set to the zone name so each delegation level gets its own report block.
type perZoneStatusRule struct{}
func (r *perZoneStatusRule) Name() string { return "dnsviz_per_zone_status" }
func (r *perZoneStatusRule) Description() string {
return "Reports the DNSViz status of every zone in the chain (root, TLD, intermediates, leaf)."
}
func (r *perZoneStatusRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadData(ctx, obs, "dnsviz_per_zone_status")
if errState != nil {
return errState
}
zones := orderedZones(data)
if len(zones) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Code: "dnsviz_per_zone_status",
Message: "DNSViz returned no zones for this domain",
}}
}
out := make([]sdk.CheckState, 0, len(zones))
for _, name := range zones {
z := data.Zones[name]
out = append(out, sdk.CheckState{
Code: "dnsviz_per_zone_status",
Subject: name,
Status: statusFromGrok(z.Status),
Message: fmt.Sprintf("%s: errors=%d warnings=%d", emptyAsUnknown(z.Status), len(z.Errors), len(z.Warnings)),
})
}
return out
}
// One state per (zone, finding) pair so the UI can show a precise list.
type zoneErrorsRule struct{}
func (r *zoneErrorsRule) Name() string { return "dnsviz_zone_errors" }
func (r *zoneErrorsRule) Description() string {
return "Surfaces every error reported by DNSViz, scoped to the zone where it was found."
}
func (r *zoneErrorsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadData(ctx, obs, "dnsviz_zone_errors")
if errState != nil {
return errState
}
var out []sdk.CheckState
for _, name := range orderedZones(data) {
for _, f := range data.Zones[name].Errors {
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: nonEmpty(f.Code, "dnsviz_zone_errors"),
Subject: name,
Message: f.Description,
Meta: findingMeta(f),
})
}
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "dnsviz_zone_errors",
Message: "DNSViz reported no errors in any zone",
}}
}
return out
}
// zoneWarningsRule mirrors zoneErrorsRule for warnings (StatusWarn).
type zoneWarningsRule struct{}
func (r *zoneWarningsRule) Name() string { return "dnsviz_zone_warnings" }
func (r *zoneWarningsRule) Description() string {
return "Surfaces every warning reported by DNSViz, scoped to the zone where it was found."
}
func (r *zoneWarningsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadData(ctx, obs, "dnsviz_zone_warnings")
if errState != nil {
return errState
}
var out []sdk.CheckState
for _, name := range orderedZones(data) {
for _, f := range data.Zones[name].Warnings {
out = append(out, sdk.CheckState{
Status: sdk.StatusWarn,
Code: nonEmpty(f.Code, "dnsviz_zone_warnings"),
Subject: name,
Message: f.Description,
Meta: findingMeta(f),
})
}
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "dnsviz_zone_warnings",
Message: "DNSViz reported no warnings in any zone",
}}
}
return out
}
func emptyAsUnknown(s string) string {
if s == "" {
return "UNKNOWN"
}
return s
}
func nonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
func findingMeta(f Finding) map[string]any {
m := map[string]any{}
if f.Code != "" {
m["code"] = f.Code
}
if len(f.Servers) > 0 {
m["servers"] = f.Servers
}
if len(m) == 0 {
return nil
}
return m
}

View file

@ -0,0 +1,189 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"errors"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func sampleData() *DNSVizData {
return &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"}},
},
"com.": {Status: "SECURE"},
".": {Status: "SECURE"},
},
}
}
func TestOverallStatusRule(t *testing.T) {
r := &overallStatusRule{}
states := r.Evaluate(context.Background(), stubObs{value: sampleData()}, nil)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
if states[0].Status != sdk.StatusCrit {
t.Errorf("expected StatusCrit for BOGUS leaf, got %v", states[0].Status)
}
if states[0].Subject != "example.com." {
t.Errorf("subject: got %q", states[0].Subject)
}
}
func TestOverallStatusRule_FallbackToFirstZone(t *testing.T) {
d := &DNSVizData{
Domain: "missing",
Order: []string{"other.zone."},
Zones: map[string]ZoneAnalysis{"other.zone.": {Status: "SECURE"}},
}
r := &overallStatusRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if states[0].Subject != "other.zone." {
t.Errorf("expected fallback to first zone, got %q", states[0].Subject)
}
if states[0].Status != sdk.StatusOK {
t.Errorf("expected SECURE -> OK, got %v", states[0].Status)
}
}
func TestOverallStatusRule_NoZones(t *testing.T) {
r := &overallStatusRule{}
states := r.Evaluate(context.Background(), stubObs{value: &DNSVizData{Domain: "x"}}, nil)
if states[0].Status != sdk.StatusUnknown {
t.Errorf("expected Unknown for empty zones, got %v", states[0].Status)
}
}
func TestOverallStatusRule_LoadError(t *testing.T) {
r := &overallStatusRule{}
states := r.Evaluate(context.Background(), stubObs{err: errors.New("nope")}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusError {
t.Errorf("expected one error state, got %+v", states)
}
}
func TestPerZoneStatusRule(t *testing.T) {
r := &perZoneStatusRule{}
states := r.Evaluate(context.Background(), stubObs{value: sampleData()}, nil)
if len(states) != 3 {
t.Fatalf("expected 3 per-zone states, got %d", len(states))
}
subjects := make([]string, len(states))
for i, s := range states {
subjects[i] = s.Subject
}
want := []string{"example.com.", "com.", "."}
for i := range want {
if subjects[i] != want[i] {
t.Errorf("subjects[%d]=%q, want %q", i, subjects[i], want[i])
}
}
}
func TestPerZoneStatusRule_NoZones(t *testing.T) {
r := &perZoneStatusRule{}
states := r.Evaluate(context.Background(), stubObs{value: &DNSVizData{}}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusUnknown {
t.Errorf("expected single Unknown state, got %+v", states)
}
}
func TestZoneErrorsRule(t *testing.T) {
r := &zoneErrorsRule{}
states := r.Evaluate(context.Background(), stubObs{value: sampleData()}, nil)
if len(states) != 1 {
t.Fatalf("expected 1 error state, got %d", len(states))
}
if states[0].Status != sdk.StatusCrit {
t.Errorf("expected Crit, got %v", states[0].Status)
}
if states[0].Code != "RRSIG_EXPIRED" {
t.Errorf("expected the finding code to be used, got %q", states[0].Code)
}
}
func TestZoneErrorsRule_NoFindings(t *testing.T) {
d := &DNSVizData{Order: []string{"a."}, Zones: map[string]ZoneAnalysis{"a.": {Status: "SECURE"}}}
r := &zoneErrorsRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusOK {
t.Errorf("expected OK summary state, got %+v", states)
}
}
func TestZoneWarningsRule(t *testing.T) {
d := &DNSVizData{
Order: []string{"a."},
Zones: map[string]ZoneAnalysis{"a.": {Warnings: []Finding{{Description: "soft"}}}},
}
r := &zoneWarningsRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
t.Errorf("expected Warn state, got %+v", states)
}
// Code falls back to the rule code when finding has none.
if states[0].Code != "dnsviz_zone_warnings" {
t.Errorf("expected fallback code, got %q", states[0].Code)
}
}
func TestEmptyAsUnknown(t *testing.T) {
if emptyAsUnknown("") != "UNKNOWN" {
t.Error("empty should map to UNKNOWN")
}
if emptyAsUnknown("X") != "X" {
t.Error("non-empty should pass through")
}
}
func TestNonEmpty(t *testing.T) {
if nonEmpty("a", "b") != "a" || nonEmpty("", "b") != "b" {
t.Error("nonEmpty did not pick non-empty")
}
}
func TestFindingMeta(t *testing.T) {
if findingMeta(Finding{}) != nil {
t.Error("expected nil for empty finding")
}
m := findingMeta(Finding{Code: "C", Servers: []string{"a"}})
if m["code"] != "C" {
t.Errorf("missing code in meta: %v", m)
}
srvs, _ := m["servers"].([]string)
if len(srvs) != 1 || srvs[0] != "a" {
t.Errorf("missing servers in meta: %v", m)
}
}
func TestZoneErrorsRule_ConcatPerZone(t *testing.T) {
d := &DNSVizData{
Order: []string{"leaf.", "tld."},
Zones: map[string]ZoneAnalysis{
"leaf.": {Errors: []Finding{{Description: "leaf-err"}}},
"tld.": {Errors: []Finding{{Description: "tld-err"}}},
},
}
r := &zoneErrorsRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 2 {
t.Fatalf("expected 2 states, got %d", len(states))
}
// Subjects should both appear, leaf first per Order.
if states[0].Subject != "leaf." || states[1].Subject != "tld." {
t.Errorf("subjects out of order: %q,%q", states[0].Subject, states[1].Subject)
}
if !strings.Contains(states[0].Message, "leaf-err") {
t.Errorf("leaf message lost: %q", states[0].Message)
}
}

View file

@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"encoding/json"
"errors"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// stubObs is a minimal ObservationGetter for rule tests. It JSON round-trips
// the stored value into the destination so it exercises the same code path
// rules see in production.
type stubObs struct {
value any
err error
}
func (s stubObs) Get(_ context.Context, _ sdk.ObservationKey, dest any) error {
if s.err != nil {
return s.err
}
if s.value == nil {
return errors.New("no value")
}
raw, err := json.Marshal(s.value)
if err != nil {
return err
}
return json.Unmarshal(raw, dest)
}
func (s stubObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}

86
checker/types.go Normal file
View file

@ -0,0 +1,86 @@
// SPDX-License-Identifier: MIT
// Package checker implements a happyDomain checker that wraps DNSViz
// (https://github.com/dnsviz/dnsviz). It runs `dnsviz probe` followed by
// `dnsviz grok` against a domain, stores the structured analysis as the
// observation, and turns the per-zone errors/warnings into CheckStates.
//
// The container ships dnsviz alongside this binary, so the checker has no
// external dependency at runtime besides the network.
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ObservationKeyDNSViz is the observation key for DNSViz analysis output.
const ObservationKeyDNSViz sdk.ObservationKey = "dnsviz"
// DNSVizData is what Collect stores. It carries the full grok output
// (parsed into a permissive structure) plus the raw bytes for the report.
//
// DNSViz emits a single top-level object whose keys are zone FQDNs (with
// trailing dot), one per level of the chain. Inside each zone object the
// shape is permissive: many fields are conditional, so we keep most of them
// as map[string]any and only pluck out what the rules need.
type DNSVizData struct {
// Domain is the queried FQDN, with trailing dot stripped.
Domain string `json:"domain"`
// Zones is the per-zone analysis, keyed by zone FQDN (with trailing dot
// preserved, matching DNSViz's output).
Zones map[string]ZoneAnalysis `json:"zones"`
// Order is Zones' keys, sorted from the queried name up to the root.
// We surface it explicitly so the report can render in a stable order
// without having to re-sort on every render.
Order []string `json:"order,omitempty"`
// Raw is the unmodified `dnsviz grok` JSON. Kept around so the report
// can fall back on it for fields the typed view does not capture.
Raw []byte `json:"raw,omitempty"`
// ProbeStderr / GrokStderr capture the diagnostics dnsviz prints to
// stderr. Useful when collection succeeds but the analysis is partial.
ProbeStderr string `json:"probe_stderr,omitempty"`
GrokStderr string `json:"grok_stderr,omitempty"`
}
// ZoneAnalysis is a permissive view over one zone's grok block.
//
// DNSViz output puts the DNSSEC chain status at delegation.status (one of
// "SECURE", "BOGUS", "INSECURE", "INDETERMINATE") while the top-level
// "status" field carries the DNS rcode for the zone apex (e.g. "NOERROR").
// Errors and warnings are not surfaced as a flat per-zone array; instead
// they appear as nested "errors"/"warnings" arrays attached to the record
// where the problem was found (DS, DNSKEY, RRSIG, NSEC proof, query
// response, server, …). We walk the whole zone subtree to collect them.
type ZoneAnalysis struct {
// Status is the DNSSEC chain status taken from delegation.status when
// available, falling back to the top-level "status" field otherwise.
Status string `json:"status,omitempty"`
// DNSStatus is the raw top-level "status" field (DNS rcode such as
// "NOERROR"). Kept for the report so we can distinguish "DNS resolved
// fine" from "DNSSEC chain validates".
DNSStatus string `json:"dns_status,omitempty"`
Errors []Finding `json:"errors,omitempty"`
Warnings []Finding `json:"warnings,omitempty"`
}
// Finding mirrors the shape DNSViz uses for entries in errors/warnings.
// Producers occasionally use slightly different field names across versions
// of dnsviz; we accept both `description`/`message` for the human text and
// fall back to a generic stringification at parse time.
type Finding struct {
Code string `json:"code,omitempty"`
Description string `json:"description"`
Servers []string `json:"servers,omitempty"`
// Path is a slash-separated pointer to the JSON node where the finding
// was attached (e.g. "delegation/ds[0]" or
// "queries/example.com./IN/A/answer[0]/rrsig[0]"). Useful in the
// report so a generic "signature_invalid" can be located precisely.
Path string `json:"path,omitempty"`
// Extra holds the raw finding payload when no human description could
// be extracted. Surfaced by the report as a debug fallback.
Extra map[string]any `json:"extra,omitempty"`
}