checker-dnsviz/checker/report.go

1000 lines
32 KiB
Go

// 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(`<!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) ───────
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(`<section class="section"><h2>DNS hierarchy (root → leaf)</h2>`)
if rawDecodeErr != nil {
fmt.Fprintf(&b, `<p class="empty-sub">Raw <code>dnsviz grok</code> JSON could not be decoded (%s); rendering from typed data only.</p>`,
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(`</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
}