package checker import ( "bytes" "encoding/json" "fmt" "html/template" "sort" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) // GetHTMLReport implements sdk.CheckerHTMLReporter. // // The report is laid out top-down by decreasing importance: // 1. a "Fix these first" banner listing the common failures (drift, // DNSSEC, NXDOMAIN, SERVFAIL, regional split, etc.) with a plain-English // remediation for each; // 2. a per-RRset consensus table that shows which answers dominate and // which resolvers disagree: the meat of the check; // 3. a per-region matrix (consensus / drift / error per region × RRset); // 4. a detailed per-resolver table for operators who want the raw data. func (p *resolverPropagationProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var data ResolverPropagationData if raw := ctx.Data(); len(raw) > 0 { if err := json.Unmarshal(raw, &data); err != nil { return "", fmt.Errorf("parse resolver-propagation data: %w", err) } } deriveView(&data) findings := statesToFindings(ctx.States()) view := buildReportView(&data, findings) buf := &bytes.Buffer{} if err := reportTmpl.Execute(buf, view); err != nil { return "", err } return buf.String(), nil } // topFailureOrder is the priority used by the "Fix these first" banner. // Items at the top reflect more impactful / more actionable issues so the // reader has a triage path. var topFailureOrder = []string{ CodeAllResolversDown, CodeUnexpectedSERVFAIL, CodeDNSSECFailure, CodeAnswerDrift, CodeUnexpectedNXDOMAIN, CodeSerialDrift, CodeRegionalSplit, CodePartialPropagation, CodeDNSSECUnvalidated, CodeStaleCache, CodeResolverRewrote, CodeResolverUnreachable, CodeResolverHighLatency, CodeResolverFilteredHit, CodeNoResolvers, } // reportView is the flattened shape the HTML template consumes. type reportView struct { Zone string OverallStatus string OverallClass string OverallMessage string Stats Stats TopFailures []topFailure OtherFindings []Finding RRsets []rrsetRow Regions []regionRow Resolvers []resolverRow } type topFailure struct { Code string Severity string Message string Remedy string Count int Class string Headline string // short, human-readable label for the card } type rrsetRow struct { Key string Name string Type string MatchesExpected bool Expected []string HasExpected bool Groups []groupRow Agreeing int Dissenting int StatusClass string StatusLabel string } type groupRow struct { Rcode string Records []string Resolvers []string IsConsensus bool } type regionRow struct { Region string Label string Resolvers int Reachable int Agreeing int Disagreeing int Errored int } type resolverRow struct { ID string Name string IP string Region string Transport string Filtered bool Reachable bool AvgMs int64 Probes []probeRow } type probeRow struct { Key string Rcode string Records []string MinTTL uint32 AD bool AgreesWithConsensus bool Error string LatencyMs int64 } func buildReportView(d *ResolverPropagationData, findings []Finding) *reportView { v := &reportView{ Zone: d.Zone, Stats: d.Stats, } // Overall banner: worst severity drives colour. worst := "" for _, f := range findings { switch f.Severity { case SeverityCrit: worst = "crit" case SeverityWarn: if worst == "" { worst = "warn" } case SeverityInfo: if worst == "" { worst = "info" } } if worst == "crit" { break } } switch worst { case "crit": v.OverallStatus = "Critical issues" v.OverallClass = "banner-crit" v.OverallMessage = fmt.Sprintf("%s is not propagating correctly across public resolvers.", d.Zone) case "warn": v.OverallStatus = "Warnings" v.OverallClass = "banner-warn" v.OverallMessage = fmt.Sprintf("%s is propagating, but some resolvers or resource sets disagree.", d.Zone) case "info": v.OverallStatus = "Informational" v.OverallClass = "banner-info" v.OverallMessage = fmt.Sprintf("%s looks healthy; a few advisory notes below.", d.Zone) default: v.OverallStatus = "OK" v.OverallClass = "banner-ok" v.OverallMessage = fmt.Sprintf("%s is propagated consistently across %d of %d unfiltered resolvers.", d.Zone, d.Stats.UnfilteredAgreeing, d.Stats.UnfilteredProbed) } // Top failures: bucket findings by code, keep each code's most severe // occurrence, render in topFailureOrder. byCode := map[string][]Finding{} for _, f := range findings { byCode[f.Code] = append(byCode[f.Code], f) } order := map[string]int{} for i, c := range topFailureOrder { order[c] = i + 1 } used := map[string]bool{} for _, code := range topFailureOrder { list, ok := byCode[code] if !ok { continue } used[code] = true f := list[0] tf := topFailure{ Code: code, Severity: string(f.Severity), Message: f.Message, Remedy: f.Remedy, Count: len(list), Class: "severity-" + string(f.Severity), Headline: headlineFor(code), } v.TopFailures = append(v.TopFailures, tf) } // Anything else → "Other findings" for code, list := range byCode { if used[code] { continue } for _, f := range list { v.OtherFindings = append(v.OtherFindings, f) } } sort.SliceStable(v.OtherFindings, func(i, j int) bool { return severityRank(v.OtherFindings[i].Severity) > severityRank(v.OtherFindings[j].Severity) }) // RRset rows, sorted by "name/type". keys := make([]string, 0, len(d.RRsets)) for k := range d.RRsets { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { rv := d.RRsets[k] row := rrsetRow{ Key: k, Name: rv.Name, Type: rv.Type, MatchesExpected: rv.MatchesExpected, Expected: rv.ExpectedRecords, HasExpected: rv.Expected != "", Agreeing: len(rv.Agreeing), Dissenting: len(rv.Dissenting), } for _, g := range rv.Groups { row.Groups = append(row.Groups, groupRow{ Rcode: g.Rcode, Records: g.Records, Resolvers: g.Resolvers, IsConsensus: g.Signature == rv.ConsensusSig, }) } switch { case rv.Expected != "" && !rv.MatchesExpected: row.StatusClass = "pill-crit" row.StatusLabel = "drift" case len(rv.Groups) > 1: row.StatusClass = "pill-warn" row.StatusLabel = "partial" case len(rv.Groups) == 1: row.StatusClass = "pill-ok" row.StatusLabel = "consensus" default: row.StatusClass = "pill-info" row.StatusLabel = "no data" } v.RRsets = append(v.RRsets, row) } // Per-region rollup. byRegion := map[string]*regionRow{} for _, rv := range d.Resolvers { r, ok := byRegion[rv.Region] if !ok { r = ®ionRow{Region: rv.Region, Label: regionLabel(rv.Region)} byRegion[rv.Region] = r } r.Resolvers++ if rv.Reachable { r.Reachable++ } if rv.Reachable && !rv.Filtered { ok := true for key, p := range rv.Probes { if p == nil || p.Error != "" { r.Errored++ ok = false break } cv := d.RRsets[key] if cv == nil || cv.ConsensusSig == "" { continue } if p.Signature != cv.ConsensusSig { ok = false break } } if ok { r.Agreeing++ } else { r.Disagreeing++ } } } for _, r := range byRegion { v.Regions = append(v.Regions, *r) } sort.Slice(v.Regions, func(i, j int) bool { return v.Regions[i].Label < v.Regions[j].Label }) // Per-resolver rows. rids := make([]string, 0, len(d.Resolvers)) for k := range d.Resolvers { rids = append(rids, k) } sort.Strings(rids) for _, rid := range rids { rv := d.Resolvers[rid] var total, n int64 probes := []probeRow{} pkeys := make([]string, 0, len(rv.Probes)) for k := range rv.Probes { pkeys = append(pkeys, k) } sort.Strings(pkeys) for _, k := range pkeys { p := rv.Probes[k] pr := probeRow{ Key: k, Rcode: p.Rcode, Records: p.Records, MinTTL: p.MinTTL, AD: p.AD, Error: p.Error, LatencyMs: p.LatencyMs, } if cv := d.RRsets[k]; cv != nil && cv.ConsensusSig != "" { pr.AgreesWithConsensus = p.Signature == cv.ConsensusSig } if p.Error == "" { total += p.LatencyMs n++ } probes = append(probes, pr) } avg := int64(0) if n > 0 { avg = total / n } v.Resolvers = append(v.Resolvers, resolverRow{ ID: rv.ID, Name: rv.Name, IP: rv.IP, Region: regionLabel(rv.Region), Transport: string(rv.Transport), Filtered: rv.Filtered, Reachable: rv.Reachable, AvgMs: avg, Probes: probes, }) } return v } // Kept here (not in rules) so user-facing wording lives in one layer. func headlineFor(code string) string { switch code { case CodeAllResolversDown: return "No resolver could be reached" case CodeUnexpectedSERVFAIL: return "A resolver returns SERVFAIL" case CodeDNSSECFailure: return "DNSSEC validation fails" case CodeAnswerDrift: return "Public resolvers disagree with your authoritative answer" case CodeUnexpectedNXDOMAIN: return "A resolver sees your zone as non-existent" case CodeSerialDrift: return "SOA serial differs between resolvers" case CodeRegionalSplit: return "A whole region sees a different answer" case CodePartialPropagation: return "Change is mid-propagation" case CodeDNSSECUnvalidated: return "Validating resolver did not set AD" case CodeStaleCache: return "Resolvers still serve the previous SOA serial" case CodeResolverRewrote: return "Resolver rewrote the answer" case CodeResolverUnreachable: return "Resolver unreachable from the checker" case CodeResolverHighLatency: return "Slow resolver" case CodeResolverFilteredHit: return "Filtered resolver is blocking your zone" case CodeNoResolvers: return "No resolver matched the current selection" default: return code } } // View-layer translation only: rules own severity/code/message, report adds remedy + subject scoping. func statesToFindings(states []sdk.CheckState) []Finding { if len(states) == 0 { return nil } var out []Finding for _, st := range states { sev, ok := severityFromStatus(st.Status) if !ok { continue } f := Finding{ Code: st.Code, Severity: sev, Message: st.Message, Remedy: remedyFor(st.Code), } if isResolverScopedCode(st.Code) { f.Resolver = st.Subject } else if st.Subject != "" && strings.Contains(st.Subject, "/") { f.RRset = st.Subject } out = append(out, f) } sort.SliceStable(out, func(i, j int) bool { if a, b := severityRank(out[i].Severity), severityRank(out[j].Severity); a != b { return a > b } if out[i].Code != out[j].Code { return out[i].Code < out[j].Code } if out[i].RRset != out[j].RRset { return out[i].RRset < out[j].RRset } return out[i].Resolver < out[j].Resolver }) return out } func severityFromStatus(s sdk.Status) (Severity, bool) { switch s { case sdk.StatusCrit: return SeverityCrit, true case sdk.StatusWarn: return SeverityWarn, true case sdk.StatusInfo: return SeverityInfo, true } return "", false } func isResolverScopedCode(code string) bool { switch code { case CodeResolverUnreachable, CodeResolverTimeout, CodeResolverRewrote, CodeResolverFilteredHit, CodeResolverHighLatency, CodeDNSSECFailure, CodeDNSSECUnvalidated: return true } return false } // Wording lives here, not in rules: severity is judgment, copy is presentation. func remedyFor(code string) string { switch code { case CodeNoResolvers: return "loosen the region filter or reset the allowlist in the checker options" case CodeAllResolversDown: return "retry later, or verify the checker host's outgoing UDP/53 connectivity" case CodeSerialDrift: return "usually transient caching right after a zone push" case CodeStaleCache: return "the resolvers cached the previous zone version" case CodeDNSSECFailure: return "check that the DS record at the parent matches the DNSKEY at the zone apex" case CodeDNSSECUnvalidated: return "enable DNSSEC signing at your provider to get full validation downstream" case CodeRegionalSplit: return "possible GeoDNS misconfiguration or regional censorship" case CodePartialPropagation: return "wait up to the previous TTL for the old cached answer to expire everywhere" case CodeAnswerDrift: return "wait for the old TTL to expire or force a flush on the affected resolvers" case CodeUnexpectedNXDOMAIN: return "a resolver returning NXDOMAIN while others return NOERROR usually means a poisoned cache or lame delegation" case CodeUnexpectedSERVFAIL: return "check DNSSEC signatures and that every authoritative NS is reachable over UDP and TCP" case CodeResolverUnreachable: return "the resolver might be blocking the checker's traffic, firewalled, or temporarily down" case CodeResolverRewrote: return "the resolver appears to rewrite answers; users relying on it will see a different zone" case CodeResolverFilteredHit: return "normal for a filtered resolver when the zone is on a blocklist" case CodeResolverHighLatency: return "usually reflects the checker-to-resolver network path" } return "" } // severityRank orders severities for sorting; higher = more severe. func severityRank(s Severity) int { switch s { case SeverityCrit: return 3 case SeverityWarn: return 2 case SeverityInfo: return 1 } return 0 } // reportFuncs exposes small helpers to the template so it can stay concise. var reportFuncs = template.FuncMap{ "join": func(sep string, s []string) string { return strings.Join(s, sep) }, "len": func(s []string) int { return len(s) }, } var reportTmpl = template.Must(template.New("report").Funcs(reportFuncs).Parse(reportTemplateHTML)) const reportTemplateHTML = `
{{.Zone}}| Record | Status | Expected (authoritative) | What resolvers see |
|---|---|---|---|
{{.Name}}{{.Type}} |
{{.StatusLabel}} {{.Agreeing}} ok · {{.Dissenting}} diff |
{{if .HasExpected}}
{{if .Expected}}
|
{{range .Groups}}
{{.Rcode}}
{{if .IsConsensus}}consensus{{end}}
{{if .Records}}
{{end}}
{{len .Resolvers}} resolver(s): {{join ", " .Resolvers}}
|
| Region | Reachable | Agreeing | Disagreeing | Errored |
|---|---|---|---|---|
| {{.Label}} | {{.Reachable}} / {{.Resolvers}} | {{.Agreeing}} | {{if .Disagreeing}}{{.Disagreeing}}{{else}}0{{end}} | {{if .Errored}}{{.Errored}}{{else}}0{{end}} |
| Resolver | Region | Transport | Avg ms | Answers |
|---|---|---|---|---|
{{.Name}}{{if .Filtered}} filtered{{end}}{{.IP}} · {{.ID}}
|
{{.Region}} | {{.Transport}} | {{if .Reachable}}{{.AvgMs}}{{else}}unreachable{{end}} |
{{range .Probes}}
{{if .Error}} |
| Severity | Code | Message | Remedy |
|---|---|---|---|
| {{.Severity}} | {{.Code}} |
{{.Message}} | {{.Remedy}} |