package checker import ( "bytes" "encoding/json" "fmt" "html/template" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) // GetHTMLReport renders an HTML summary of the last PTR run. Hints and fixes // are driven exclusively by the CheckStates produced by this checker's // rules (exposed via ctx.States()); when no states are available the report // renders the raw PTR observation without hint sections. func (p *ptrProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var data PTRData if raw := ctx.Data(); len(raw) > 0 { if err := json.Unmarshal(raw, &data); err != nil { return "", fmt.Errorf("parse PTR data: %w", err) } } view := buildReportView(&data, ctx.States()) buf := &bytes.Buffer{} if err := reportTmpl.Execute(buf, view); err != nil { return "", err } return buf.String(), nil } // topFailureCodes orders the findings surfaced in the "Fix these first" // section of the report. The order reflects impact: the canonical FCrDNS // failure modes come first because they block mail delivery. var topFailureCodes = []string{ "ptr_missing", "ptr_rcode", "ptr_target_unresolvable", "ptr_forward_mismatch", "ptr_declared_mismatch", "ptr_not_in_reverse_zone", "ptr_no_reverse_zone", "ptr_owner_malformed", "ptr_target_invalid", "ptr_multiple", "ptr_generic_hostname", "ptr_query_failed", "ptr_ipv6_missing", } type reportView struct { Owner string ReverseIP string ReverseZone string ReverseNS []string DeclaredTarget string ObservedTargets []string ObservedTTL uint32 ForwardAddresses []ForwardAddress ForwardMatch bool TargetResolves bool Rcode string OverallStatus string OverallStatusText string OverallClass string TopFailures []topFailure OtherFindings []stateView HasStates bool } type topFailure struct { Code string Title string Severity string Messages []string Hint string Subject string } type stateView struct { Severity string Code string Subject string Message string Hint string } // statusToSeverity maps SDK statuses to the severity strings used by the // HTML template. Empty string = no banner-worthy issue (pass/info/unknown // rendered as "info" when they surface in tables, otherwise ignored). func statusToSeverity(s sdk.Status) string { switch s { case sdk.StatusCrit, sdk.StatusError: return "crit" case sdk.StatusWarn: return "warn" case sdk.StatusInfo: return "info" } return "" } func severityWeight(sev string) int { switch sev { case "crit": return 3 case "warn": return 2 case "info": return 1 } return 0 } func hintFromMeta(meta map[string]any) string { if meta == nil { return "" } // Rules expose the fix under "hint"; also accept "fix" as an alias so // either convention works. for _, key := range []string{"hint", "fix"} { if v, ok := meta[key]; ok { if s, ok := v.(string); ok && s != "" { return s } } } return "" } func buildReportView(data *PTRData, states []sdk.CheckState) *reportView { v := &reportView{ Owner: data.OwnerName, ReverseIP: data.ReverseIP, ReverseZone: data.ReverseZone, ReverseNS: data.ReverseNS, DeclaredTarget: data.DeclaredTarget, ObservedTargets: data.ObservedTargets, ObservedTTL: data.ObservedTTL, ForwardAddresses: data.ForwardAddresses, ForwardMatch: data.ForwardMatch, TargetResolves: data.TargetResolves, Rcode: data.Rcode, HasStates: len(states) > 0, } // Filter to actionable states (crit/warn/info); drop pass/unknown. type issue struct { code string severity string message string subject string hint string } var issues []issue worst := "" for _, st := range states { sev := statusToSeverity(st.Status) if sev == "" { continue } if severityWeight(sev) > severityWeight(worst) { worst = sev } issues = append(issues, issue{ code: st.Code, severity: sev, message: st.Message, subject: st.Subject, hint: hintFromMeta(st.Meta), }) } switch worst { case "crit": v.OverallStatus = "crit" v.OverallStatusText = "Critical issues detected" v.OverallClass = "status-crit" case "warn": v.OverallStatus = "warn" v.OverallStatusText = "Warnings detected" v.OverallClass = "status-warn" case "info": v.OverallStatus = "info" v.OverallStatusText = "Informational notes" v.OverallClass = "status-info" default: v.OverallStatus = "ok" if v.HasStates { v.OverallStatusText = "PTR is healthy (FCrDNS confirmed)" } else { v.OverallStatusText = "PTR observation" } v.OverallClass = "status-ok" } topIndex := map[string]int{} for i, c := range topFailureCodes { topIndex[c] = i } topMap := map[string]*topFailure{} for _, f := range issues { if _, isTop := topIndex[f.code]; isTop { tf, ok := topMap[f.code] if !ok { tf = &topFailure{ Code: f.code, Title: titleFor(f.code), Subject: f.subject, } topMap[f.code] = tf } tf.Messages = append(tf.Messages, f.message) if tf.Hint == "" { tf.Hint = f.hint } if severityWeight(f.severity) > severityWeight(tf.Severity) { tf.Severity = f.severity } continue } v.OtherFindings = append(v.OtherFindings, stateView{ Severity: f.severity, Code: f.code, Subject: f.subject, Message: f.message, Hint: f.hint, }) } for _, code := range topFailureCodes { if tf, ok := topMap[code]; ok { v.TopFailures = append(v.TopFailures, *tf) } } return v } func titleFor(code string) string { switch code { case "ptr_missing": return "No PTR record published" case "ptr_rcode": return "Reverse zone returned an error" case "ptr_target_unresolvable": return "PTR target does not resolve (forward DNS missing)" case "ptr_forward_mismatch": return "Forward / reverse mismatch (FCrDNS fails)" case "ptr_declared_mismatch": return "Authoritative PTR disagrees with the declared target" case "ptr_not_in_reverse_zone": return "Record is not in a reverse (*.arpa) zone" case "ptr_no_reverse_zone": return "Reverse zone not found" case "ptr_owner_malformed": return "Reverse name is malformed" case "ptr_target_invalid": return "PTR target is not a valid hostname" case "ptr_multiple": return "Multiple PTR records on the same IP" case "ptr_generic_hostname": return "PTR target looks auto-generated" case "ptr_query_failed": return "Could not reach the reverse zone servers" case "ptr_ipv6_missing": return "IPv6 PTR record missing" } return strings.ReplaceAll(code, "_", " ") } var reportTmpl = template.Must(template.New("ptr-report").Parse(reportTemplate)) // reportTemplate is the single-file HTML report. Styles are inlined so the // report embeds cleanly in an iframe with no asset dependencies. const reportTemplate = ` PTR / reverse DNS report — {{.Owner}}
{{.OverallStatusText}}
for {{.Owner}}{{if .ReverseIP}} ({{.ReverseIP}}){{end}}
{{if .ObservedTargets}}observed PTR: {{index .ObservedTargets 0}}{{else if eq .OverallStatus "crit"}}no PTR served{{end}}
{{if and .ReverseIP .ObservedTargets}}
{{.ReverseIP}} — PTR → {{index .ObservedTargets 0}} — A/AAAA → {{if .ForwardAddresses}} {{range $i, $a := .ForwardAddresses}}{{if $i}}, {{end}}{{$a.Address}}{{end}} {{else}} unresolved {{end}} · {{if .ForwardMatch}}FCrDNS match{{else}}FCrDNS mismatch{{end}}
{{end}}
Reverse name
{{.Owner}}
Decoded IP
{{if .ReverseIP}}{{.ReverseIP}}{{else}}{{end}}
Reverse zone
{{if .ReverseZone}}{{.ReverseZone}}{{else}}{{end}}
Declared PTR target
{{if .DeclaredTarget}}{{.DeclaredTarget}}{{else}}{{end}}
Observed PTR target(s)
{{if .ObservedTargets}}{{range .ObservedTargets}}{{.}}
{{end}}{{else}}none{{end}}
Observed TTL
{{if .ObservedTTL}}{{.ObservedTTL}}s{{else}}{{end}}
Rcode
{{if .Rcode}}{{.Rcode}}{{else}}{{end}}
FCrDNS
{{if .ForwardMatch}}match {{else if .TargetResolves}}mismatch {{else}}target unresolved{{end}}
{{if .TopFailures}}

Fix these first

{{range .TopFailures}}

{{.Title}} {{.Severity}}

{{if .Hint}}
How to fix{{.Hint}}
{{end}}
{{end}} {{end}} {{if .ForwardAddresses}}

Forward resolution of the PTR target

{{range .ForwardAddresses}} {{end}}
TypeAddressTTL
{{.Type}} {{.Address}} {{if .TTL}}{{.TTL}}s{{else}}{{end}}
{{end}} {{if .ReverseNS}}

Reverse zone name servers

{{range .ReverseNS}}{{end}}
Server
{{.}}
{{end}} {{if .OtherFindings}}

Additional findings

{{range .OtherFindings}} {{end}}
SeverityCodeSubjectMessage
{{.Severity}} {{.Code}} {{.Subject}} {{.Message}}{{if .Hint}}
{{.Hint}}{{end}}
{{end}} `