1000 lines
32 KiB
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
|
|
}
|