414 lines
13 KiB
Go
414 lines
13 KiB
Go
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 = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Reverse zone report: {{.Zone}}</title>
|
|
<style>
|
|
:root {
|
|
--ok: #1e9e5d;
|
|
--info: #3b82f6;
|
|
--warn: #d97706;
|
|
--crit: #dc2626;
|
|
--bg: #f7f7f8;
|
|
--card: #ffffff;
|
|
--border: #e5e7eb;
|
|
--text: #111827;
|
|
--muted: #6b7280;
|
|
}
|
|
body { margin: 0; padding: 1.2rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); line-height: 1.45; }
|
|
h1 { font-size: 1.4rem; margin: 0 0 .3rem 0; }
|
|
h2 { font-size: 1.05rem; margin: 1.5rem 0 .6rem 0; border-bottom: 1px solid var(--border); padding-bottom: .25rem; }
|
|
h3 { font-size: .95rem; margin: 0 0 .35rem 0; }
|
|
.muted { color: var(--muted); }
|
|
.status-banner { display: flex; align-items: center; justify-content: space-between; padding: .8rem 1rem; border-radius: 8px; color: #fff; margin-bottom: 1rem; }
|
|
.status-ok { background: var(--ok); }
|
|
.status-info { background: var(--info); }
|
|
.status-warn { background: var(--warn); }
|
|
.status-crit { background: var(--crit); }
|
|
.status-banner .label { font-weight: 600; font-size: 1rem; }
|
|
.status-banner .sub { opacity: .9; font-size: .85rem; }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: .6rem; margin-bottom: 1rem; }
|
|
.stat { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .7rem .9rem; }
|
|
.stat .k { color: var(--muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .03em; }
|
|
.stat .v { font-size: 1.4rem; font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
.stat.crit .v { color: var(--crit); }
|
|
.stat.warn .v { color: var(--warn); }
|
|
.stat.ok .v { color: var(--ok); }
|
|
.top-failure { border-left: 4px solid var(--crit); background: #fef2f2; padding: .8rem 1rem; border-radius: 6px; margin-bottom: .6rem; }
|
|
.top-failure h3 { margin-bottom: .25rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem; }
|
|
.top-failure .roundtrip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem; margin: .35rem 0; }
|
|
.top-failure .roundtrip .arrow { color: var(--muted); margin: 0 .25rem; }
|
|
.top-failure .fix { background: rgba(0,0,0,.04); padding: .45rem .6rem; border-radius: 4px; font-size: .9rem; }
|
|
.top-failure .fix strong { display: block; color: var(--text); margin-bottom: .15rem; }
|
|
.top-failure.unresolved { border-color: var(--warn); background: #fffbeb; }
|
|
.badge { display: inline-block; padding: .05rem .4rem; border-radius: 4px; font-size: .72rem; color: #fff; font-weight: 600; text-transform: uppercase; }
|
|
.badge.crit { background: var(--crit); }
|
|
.badge.warn { background: var(--warn); }
|
|
.badge.ok { background: var(--ok); }
|
|
table { width: 100%; border-collapse: collapse; font-size: .85rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid var(--border); }
|
|
th { background: #f3f4f6; font-weight: 600; font-size: .75rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
|
|
tr:last-child td { border-bottom: none; }
|
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
.more { font-size: .8rem; color: var(--muted); margin-top: .25rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="status-banner {{.OverallClass}}">
|
|
<div>
|
|
<div class="label">{{.OverallStatusText}}</div>
|
|
<div class="sub">for <code>{{.Zone}}</code></div>
|
|
</div>
|
|
<div class="sub">
|
|
{{.InspectedCount}} of {{.PTRCount}} PTR{{if gt .PTRCount 1}}s{{end}} inspected{{if .Truncated}} (truncated){{end}}
|
|
</div>
|
|
</div>
|
|
|
|
{{if .LoadError}}
|
|
<div class="top-failure">
|
|
<h3>Zone data could not be loaded</h3>
|
|
<p>{{.LoadError}}</p>
|
|
</div>
|
|
{{end}}
|
|
|
|
<div class="grid">
|
|
<div class="stat ok"><div class="k">FCrDNS OK</div><div class="v">{{.OK}}</div></div>
|
|
<div class="stat crit"><div class="k">FCrDNS mismatch</div><div class="v">{{.Mismatch}}</div></div>
|
|
<div class="stat warn"><div class="k">Target unresolved</div><div class="v">{{.Unresolved}}</div></div>
|
|
<div class="stat warn"><div class="k">Multiple PTR</div><div class="v">{{.Multiple}}</div></div>
|
|
<div class="stat warn"><div class="k">Generic-looking</div><div class="v">{{.Generic}}</div></div>
|
|
<div class="stat warn"><div class="k">Invalid syntax</div><div class="v">{{.InvalidName}}</div></div>
|
|
</div>
|
|
|
|
{{if .FCrDNSFailures}}
|
|
<h2>Fix these first: Forward / reverse round-trip ({{len .FCrDNSFailures}})</h2>
|
|
<p class="muted">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.</p>
|
|
{{range .FCrDNSFailures}}
|
|
<div class="top-failure {{if eq .Reason "unresolved"}}unresolved{{end}}">
|
|
<h3><code>{{.Owner}}</code>
|
|
{{if eq .Reason "mismatch"}}<span class="badge crit">FCrDNS mismatch</span>
|
|
{{else}}<span class="badge warn">target unresolved</span>{{end}}
|
|
</h3>
|
|
<div class="roundtrip">
|
|
<code>{{.IP}}</code> <span class="arrow">-PTR-></span> <code>{{.Target}}</code> <span class="arrow">-A/AAAA-></span>
|
|
{{if .ForwardAddrs}}{{range $i, $a := .ForwardAddrs}}{{if $i}}, {{end}}<code>{{$a}}</code>{{end}}
|
|
{{else}}<span class="muted">unresolved</span>{{end}}
|
|
</div>
|
|
<div class="fix"><strong>How to fix</strong>{{.SuggestedFix}}</div>
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
{{if .Sample}}
|
|
<h2>PTR records inspected (first {{len .Sample}})</h2>
|
|
<table>
|
|
<thead><tr><th>PTR owner</th><th>IP</th><th>Target</th><th>Forward A/AAAA</th><th>FCrDNS</th></tr></thead>
|
|
<tbody>
|
|
{{range .Sample}}
|
|
<tr>
|
|
<td><code>{{.Owner}}</code></td>
|
|
<td><code>{{.IP}}</code></td>
|
|
<td><code>{{.Target}}</code></td>
|
|
<td>{{if .Forward}}<code>{{.Forward}}</code>{{else}}<span class="muted">-</span>{{end}}</td>
|
|
<td>
|
|
{{if .Match}}<span class="badge ok">match</span>
|
|
{{else if .Resolved}}<span class="badge crit">mismatch</span>
|
|
{{else}}<span class="badge warn">unresolved</span>{{end}}
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{if gt .InspectedCount (len .Sample)}}<div class="more">… and {{sub .InspectedCount (len .Sample)}} more</div>{{end}}
|
|
{{end}}
|
|
|
|
{{if .OtherFindings}}
|
|
<h2>Other findings</h2>
|
|
<table>
|
|
<thead><tr><th>Severity</th><th>Code</th><th>Subject</th><th>Message</th></tr></thead>
|
|
<tbody>
|
|
{{range .OtherFindings}}
|
|
<tr>
|
|
<td><span class="badge {{.Severity}}">{{.Severity}}</span></td>
|
|
<td><code>{{.Code}}</code></td>
|
|
<td><code>{{.Subject}}</code></td>
|
|
<td>{{.Message}}{{if .Hint}}<br><span class="muted">{{.Hint}}</span>{{end}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{end}}
|
|
|
|
</body>
|
|
</html>`
|