// 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
`) } // ── 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(``) } 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(``) } if len(z.Warnings) > 0 { 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 }