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}}
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}}
{{range .Messages}}- {{.}}
{{end}}
{{if .Hint}}
How to fix{{.Hint}}
{{end}}
{{end}}
{{end}}
{{if .ForwardAddresses}}
Forward resolution of the PTR target
| Type | Address | TTL |
{{range .ForwardAddresses}}
{{.Type}} |
{{.Address}} |
{{if .TTL}}{{.TTL}}s{{else}}—{{end}} |
{{end}}
{{end}}
{{if .ReverseNS}}
Reverse zone name servers
| Server |
{{range .ReverseNS}}{{.}} |
{{end}}
{{end}}
{{if .OtherFindings}}
Additional findings
| Severity | Code | Subject | Message |
{{range .OtherFindings}}
| {{.Severity}} |
{{.Code}} |
{{.Subject}} |
{{.Message}}{{if .Hint}} {{.Hint}}{{end}} |
{{end}}
{{end}}
`