329 lines
11 KiB
Go
329 lines
11 KiB
Go
// SPDX-License-Identifier: MIT
|
|
|
|
package checker
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"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.
|
|
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()
|
|
|
|
var b strings.Builder
|
|
b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">`)
|
|
b.WriteString(`<title>DNSSEC report — ` + html.EscapeString(data.Domain) + `</title>`)
|
|
b.WriteString(`<style>` + reportCSS + `</style></head><body>`)
|
|
|
|
fmt.Fprintf(&b, `<header><h1>DNSSEC analysis</h1><p class="domain">%s</p></header>`,
|
|
html.EscapeString(emptyAsUnknown(data.Domain)))
|
|
|
|
if len(states) == 0 && len(data.Zones) == 0 {
|
|
b.WriteString(`<p class="empty">No DNSViz data and no rule states. The check probably failed before producing any output.</p>`)
|
|
b.WriteString(`</body></html>`)
|
|
return b.String(), nil
|
|
}
|
|
|
|
writeOverallBanner(&b, &data, states)
|
|
writeFixFirst(&b, states)
|
|
writeChain(&b, &data)
|
|
writeAllStates(&b, states)
|
|
writeRawSection(&b, &data)
|
|
|
|
b.WriteString(`</body></html>`)
|
|
return b.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
|
|
}
|
|
|
|
// ── HTML rendering helpers ───────────────────────────────────────────────
|
|
|
|
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}
|
|
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{border-left:4px solid #ccc;padding:.5rem .75rem;margin:.5rem 0;background:#fafafa;border-radius:0 .3rem .3rem 0}
|
|
.zone.s-OK{border-color:#2e7d32}.zone.s-INFO{border-color:#0277bd}.zone.s-WARN{border-color:#ef6c00}.zone.s-CRIT{border-color:#c62828}.zone.s-UNKNOWN{border-color:#777}
|
|
.zone h3{margin:0 0 .25rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:1rem}
|
|
.zone .status{font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#fff;padding:.1rem .4rem;border-radius:.2rem;margin-left:.5rem}
|
|
.findings{margin:.5rem 0 0;padding:0;list-style:none}
|
|
.findings li{padding:.35rem .5rem;border-radius:.25rem;margin:.2rem 0;font-size:.9rem}
|
|
.findings li.err{background:#ffebee;color:#b71c1c}
|
|
.findings li.warn{background:#fff3e0;color:#bf360c}
|
|
.findings li code{background:rgba(0,0,0,.06);padding:0 .2rem;border-radius:.15rem;font-size:.8rem}
|
|
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}
|
|
`
|
|
|
|
func writeOverallBanner(b *strings.Builder, data *DNSVizData, states []sdk.CheckState) {
|
|
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
|
|
}
|
|
fmt.Fprintf(b,
|
|
`<div class="banner s-%s">Overall: %s<small>DNSViz status of %s: %s</small></div>`,
|
|
st.String(), st.String(),
|
|
html.EscapeString(strings.TrimSuffix(leaf, ".")),
|
|
html.EscapeString(emptyAsUnknown(z.Status)),
|
|
)
|
|
}
|
|
|
|
func writeFixFirst(b *strings.Builder, states []sdk.CheckState) {
|
|
type item struct {
|
|
state sdk.CheckState
|
|
}
|
|
var items []item
|
|
for _, s := range states {
|
|
if s.Status < sdk.StatusWarn {
|
|
continue
|
|
}
|
|
items = append(items, item{state: s})
|
|
}
|
|
if len(items) == 0 {
|
|
return
|
|
}
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
if items[i].state.Status != items[j].state.Status {
|
|
return items[i].state.Status > items[j].state.Status
|
|
}
|
|
return items[i].state.Subject < items[j].state.Subject
|
|
})
|
|
|
|
b.WriteString(`<section class="section"><h2>Fix these first</h2>`)
|
|
for _, it := range items {
|
|
s := it.state
|
|
title, hint := titleAndHint(s)
|
|
klass := "fix-card"
|
|
if s.Status == sdk.StatusWarn {
|
|
klass = "fix-card warn"
|
|
}
|
|
fmt.Fprintf(b, `<div class="%s"><h4>%s</h4><div class="where">at <code>%s</code>, rule <code>%s</code></div>`,
|
|
klass,
|
|
html.EscapeString(title),
|
|
html.EscapeString(s.Subject),
|
|
html.EscapeString(s.Code),
|
|
)
|
|
if hint != "" {
|
|
fmt.Fprintf(b, `<p class="hint">%s</p>`, html.EscapeString(hint))
|
|
} else if s.Message != "" {
|
|
fmt.Fprintf(b, `<p class="hint">%s</p>`, html.EscapeString(s.Message))
|
|
}
|
|
b.WriteString(`</div>`)
|
|
}
|
|
b.WriteString(`</section>`)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func writeChain(b *strings.Builder, data *DNSVizData) {
|
|
zones := orderedZones(data)
|
|
if len(zones) == 0 {
|
|
return
|
|
}
|
|
|
|
b.WriteString(`<section class="section"><h2>Per-zone analysis (root → leaf)</h2>`)
|
|
// Render root first, leaf last for a chain narrative.
|
|
for i := len(zones) - 1; i >= 0; i-- {
|
|
name := zones[i]
|
|
z := data.Zones[name]
|
|
st := statusFromGrok(z.Status)
|
|
fmt.Fprintf(b, `<div class="zone s-%s"><h3>%s<span class="status s-%s">%s</span></h3>`,
|
|
st.String(),
|
|
html.EscapeString(name),
|
|
st.String(),
|
|
html.EscapeString(emptyAsUnknown(z.Status)),
|
|
)
|
|
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>`)
|
|
}
|
|
if len(z.Errors) == 0 && len(z.Warnings) == 0 {
|
|
b.WriteString(`<p style="margin:.4rem 0;color:#2e7d32">No DNSViz finding at this level.</p>`)
|
|
}
|
|
b.WriteString(`</div>`)
|
|
}
|
|
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, ", ")))
|
|
}
|
|
b.WriteString(`</li>`)
|
|
}
|
|
|
|
func writeAllStates(b *strings.Builder, states []sdk.CheckState) {
|
|
if len(states) == 0 {
|
|
return
|
|
}
|
|
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
|
|
})
|
|
b.WriteString(`<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>`)
|
|
for _, s := range sorted {
|
|
fmt.Fprintf(b, `<tr><td><span class="status s-%s">%s</span></td><td><code>%s</code></td><td><code>%s</code></td><td>%s</td></tr>`,
|
|
s.Status.String(), s.Status.String(),
|
|
html.EscapeString(s.Subject),
|
|
html.EscapeString(s.Code),
|
|
html.EscapeString(s.Message),
|
|
)
|
|
}
|
|
b.WriteString(`</tbody></table></section>`)
|
|
}
|
|
|
|
func writeRawSection(b *strings.Builder, data *DNSVizData) {
|
|
if len(data.Raw) == 0 {
|
|
return
|
|
}
|
|
b.WriteString(`<section class="section"><details><summary>Raw <code>dnsviz grok</code> output</summary><pre>`)
|
|
b.WriteString(html.EscapeString(string(data.Raw)))
|
|
b.WriteString(`</pre></details>`)
|
|
if data.ProbeStderr != "" {
|
|
b.WriteString(`<details><summary>dnsviz probe stderr</summary><pre>`)
|
|
b.WriteString(html.EscapeString(data.ProbeStderr))
|
|
b.WriteString(`</pre></details>`)
|
|
}
|
|
if data.GrokStderr != "" {
|
|
b.WriteString(`<details><summary>dnsviz grok stderr</summary><pre>`)
|
|
b.WriteString(html.EscapeString(data.GrokStderr))
|
|
b.WriteString(`</pre></details>`)
|
|
}
|
|
b.WriteString(`</section>`)
|
|
}
|
|
|
|
// worstStatus returns the highest-severity status in states, using the same
|
|
// ordering writeFixFirst 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
|
|
}
|