package checker import ( "bytes" "encoding/json" "fmt" "html/template" "sort" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) // GetHTMLReport foregrounds FCrDNS failures before other findings. func (p *reverseZoneProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var data ReverseZoneData if raw := ctx.Data(); len(raw) > 0 { if err := json.Unmarshal(raw, &data); err != nil { return "", fmt.Errorf("parse reverse-zone 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 } type fcrdnsFailure struct { Owner string IP string Target string ForwardAddrs []string Reason string // "mismatch" or "unresolved" SuggestedFix string } type findingRow struct { Severity string Code string Subject string Message string Hint string } type reportView struct { Zone string IsReverse bool IsIPv6 bool PTRCount int InspectedCount int Truncated bool LoadError string OverallStatus string OverallStatusText string OverallClass string // Stats OK int Mismatch int Unresolved int Multiple int Generic int LowTTL int InvalidName int FCrDNSFailures []fcrdnsFailure OtherFindings []findingRow // Sample of healthy entries (for context). Sample []sampleRow } type sampleRow struct { Owner string IP string Target string Forward string Match bool Resolved bool } 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 v, ok := meta["hint"].(string); ok { return v } return "" } func buildReportView(data *ReverseZoneData, states []sdk.CheckState) *reportView { v := &reportView{ Zone: data.Zone, IsReverse: data.IsReverseZone, IsIPv6: data.IsIPv6, PTRCount: data.PTRCount, InspectedCount: len(data.Entries), Truncated: data.Truncated, LoadError: data.LoadError, } // Drive from observation data, not rule states, so FCrDNS failures always surface. for _, e := range data.Entries { if len(e.Targets) == 0 || e.ReverseIP == "" { continue } target := e.Targets[0] switch { case !e.TargetResolves: v.Unresolved++ v.FCrDNSFailures = append(v.FCrDNSFailures, fcrdnsFailure{ Owner: e.OwnerName, IP: e.ReverseIP, Target: target, Reason: "unresolved", SuggestedFix: fmt.Sprintf("Publish A/AAAA records for %s pointing at %s.", target, e.ReverseIP), }) case !e.ForwardMatch: v.Mismatch++ addrs := make([]string, len(e.ForwardAddresses)) for i, a := range e.ForwardAddresses { addrs[i] = a.Address } v.FCrDNSFailures = append(v.FCrDNSFailures, fcrdnsFailure{ Owner: e.OwnerName, IP: e.ReverseIP, Target: target, ForwardAddrs: addrs, Reason: "mismatch", SuggestedFix: fmt.Sprintf("Add %s to the A/AAAA records of %s, or repoint the PTR at a name whose forward records already include %s.", e.ReverseIP, target, e.ReverseIP), }) default: v.OK++ } if len(e.Targets) > 1 { v.Multiple++ } if e.TargetLooksGeneric { v.Generic++ } if !e.TargetSyntaxValid { v.InvalidName++ } } sort.SliceStable(v.FCrDNSFailures, func(i, j int) bool { // mismatch is more actionable than unresolved (forward zone exists, // just needs an extra address); show those first. if v.FCrDNSFailures[i].Reason != v.FCrDNSFailures[j].Reason { return v.FCrDNSFailures[i].Reason == "mismatch" } return v.FCrDNSFailures[i].Owner < v.FCrDNSFailures[j].Owner }) for _, e := range data.Entries { if len(v.Sample) >= 10 { break } if len(e.Targets) == 0 { continue } fwd := "" if len(e.ForwardAddresses) > 0 { parts := make([]string, len(e.ForwardAddresses)) for i, a := range e.ForwardAddresses { parts[i] = a.Address } fwd = strings.Join(parts, ", ") } v.Sample = append(v.Sample, sampleRow{ Owner: e.OwnerName, IP: e.ReverseIP, Target: e.Targets[0], Forward: fwd, Match: e.ForwardMatch, Resolved: e.TargetResolves, }) } worst := "" skipCodes := map[string]bool{ "ptr_forward_mismatch": true, "ptr_target_unresolvable": true, } for _, st := range states { sev := statusToSeverity(st.Status) if sev == "" { continue } if severityWeight(sev) > severityWeight(worst) { worst = sev } if skipCodes[st.Code] { continue } v.OtherFindings = append(v.OtherFindings, findingRow{ Severity: sev, Code: st.Code, Subject: st.Subject, Message: st.Message, Hint: hintFromMeta(st.Meta), }) if st.Code == "ptr_low_ttl" { v.LowTTL++ } } if len(v.FCrDNSFailures) > 0 && severityWeight(worst) < severityWeight("crit") { worst = "crit" } switch worst { case "crit": v.OverallStatus = "crit" v.OverallStatusText = fmt.Sprintf("FCrDNS failures detected (%d)", len(v.FCrDNSFailures)) 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 data.LoadError != "" { v.OverallStatusText = "Could not load zone data" v.OverallClass = "status-warn" } else if data.PTRCount == 0 { v.OverallStatusText = "Reverse zone is empty" v.OverallClass = "status-info" } else { v.OverallStatusText = fmt.Sprintf("All %d PTR records pass FCrDNS", v.OK) v.OverallClass = "status-ok" } } return v } var reportTmpl = template.Must(template.New("reverse-zone-report").Funcs(template.FuncMap{ "sub": func(a, b int) int { return a - b }, }).Parse(reportTemplate)) const reportTemplate = `
{{.LoadError}}
Mail servers (and SSH/anti-spam stacks) reject SMTP connections when the PTR target does not resolve back to the connecting IP. Address these failures first.
{{range .FCrDNSFailures}}{{.Owner}}
{{if eq .Reason "mismatch"}}FCrDNS mismatch
{{else}}target unresolved{{end}}
{{.IP}} -PTR-> {{.Target}} -A/AAAA->
{{if .ForwardAddrs}}{{range $i, $a := .ForwardAddrs}}{{if $i}}, {{end}}{{$a}}{{end}}
{{else}}unresolved{{end}}
| PTR owner | IP | Target | Forward A/AAAA | FCrDNS |
|---|---|---|---|---|
{{.Owner}} |
{{.IP}} |
{{.Target}} |
{{if .Forward}}{{.Forward}}{{else}}-{{end}} |
{{if .Match}}match {{else if .Resolved}}mismatch {{else}}unresolved{{end}} |
| Severity | Code | Subject | Message |
|---|---|---|---|
| {{.Severity}} | {{.Code}} |
{{.Subject}} |
{{.Message}}{{if .Hint}} {{.Hint}}{{end}} |