Initial commit
This commit is contained in:
commit
257c7e494f
21 changed files with 1891 additions and 0 deletions
329
checker/report.go
Normal file
329
checker/report.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue