// 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(``) b.WriteString(`DNSSEC report — ` + html.EscapeString(data.Domain) + ``) b.WriteString(``) fmt.Fprintf(&b, `

DNSSEC analysis

%s

`, html.EscapeString(emptyAsUnknown(data.Domain))) if len(states) == 0 && len(data.Zones) == 0 { b.WriteString(`

No DNSViz data and no rule states. The check probably failed before producing any output.

`) b.WriteString(``) return b.String(), nil } writeOverallBanner(&b, &data, states) writeFixFirst(&b, states) writeChain(&b, &data) writeAllStates(&b, states) writeRawSection(&b, &data) b.WriteString(``) 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, ``, 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(`

Fix these first

`) 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, `

%s

at %s — rule %s
`, klass, html.EscapeString(title), html.EscapeString(s.Subject), html.EscapeString(s.Code), ) if hint != "" { fmt.Fprintf(b, `

%s

`, html.EscapeString(hint)) } else if s.Message != "" { fmt.Fprintf(b, `

%s

`, html.EscapeString(s.Message)) } b.WriteString(`
`) } b.WriteString(`
`) } 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(`

Per-zone analysis (root → leaf)

`) // 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, `

%s%s

`, st.String(), html.EscapeString(name), st.String(), html.EscapeString(emptyAsUnknown(z.Status)), ) if len(z.Errors) > 0 { b.WriteString(``) } if len(z.Warnings) > 0 { b.WriteString(``) } if len(z.Errors) == 0 && len(z.Warnings) == 0 { b.WriteString(`

No DNSViz finding at this level.

`) } b.WriteString(`
`) } b.WriteString(`
`) } func writeFindingLI(b *strings.Builder, f Finding, klass string) { fmt.Fprintf(b, `
  • `, klass) if f.Code != "" { fmt.Fprintf(b, `%s `, html.EscapeString(f.Code)) } b.WriteString(html.EscapeString(f.Description)) if len(f.Servers) > 0 { fmt.Fprintf(b, ` (%s)`, html.EscapeString(strings.Join(f.Servers, ", "))) } b.WriteString(`
  • `) } 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(`

    All rule states

    `) for _, s := range sorted { fmt.Fprintf(b, ``, s.Status.String(), s.Status.String(), html.EscapeString(s.Subject), html.EscapeString(s.Code), html.EscapeString(s.Message), ) } b.WriteString(`
    StatusSubjectCodeMessage
    %s%s%s%s
    `) } func writeRawSection(b *strings.Builder, data *DNSVizData) { if len(data.Raw) == 0 { return } b.WriteString(`
    Raw dnsviz grok output
    `)
    	b.WriteString(html.EscapeString(string(data.Raw)))
    	b.WriteString(`
    `) if data.ProbeStderr != "" { b.WriteString(`
    dnsviz probe stderr
    `)
    		b.WriteString(html.EscapeString(data.ProbeStderr))
    		b.WriteString(`
    `) } if data.GrokStderr != "" { b.WriteString(`
    dnsviz grok stderr
    `)
    		b.WriteString(html.EscapeString(data.GrokStderr))
    		b.WriteString(`
    `) } b.WriteString(`
    `) } // 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 }