Initial commit

This commit is contained in:
nemunaire 2026-04-26 18:44:22 +07:00
commit 257c7e494f
21 changed files with 1891 additions and 0 deletions

329
checker/report.go Normal file
View file

@ -0,0 +1,329 @@
// 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
}