Initial commit
This commit is contained in:
commit
67c955129d
20 changed files with 2203 additions and 0 deletions
443
checker/report.go
Normal file
443
checker/report.go
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
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 = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>PTR / reverse DNS report — {{.Owner}}</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(240px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; }
|
||||
.card .k { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; }
|
||||
.card .v { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .95rem; word-break: break-all; }
|
||||
.top-failure { border-left: 4px solid var(--crit); background: #fef2f2; padding: .8rem 1rem; border-radius: 6px; margin-bottom: .6rem; }
|
||||
.top-failure.severity-warn { border-color: var(--warn); background: #fffbeb; }
|
||||
.top-failure.severity-info { border-color: var(--info); background: #eff6ff; }
|
||||
.top-failure h3 { margin-bottom: .25rem; }
|
||||
.top-failure ul { margin: .25rem 0 .35rem 1.1rem; padding: 0; font-size: .9rem; }
|
||||
.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; }
|
||||
.roundtrip { display: flex; align-items: center; gap: .6rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92rem; margin-bottom: .6rem; flex-wrap: wrap; }
|
||||
.roundtrip .step { padding: .15rem .5rem; border-radius: 4px; background: #eef2ff; }
|
||||
.roundtrip .arrow { color: var(--muted); }
|
||||
.roundtrip.match { border-color: var(--ok); }
|
||||
.roundtrip.miss { border-color: var(--crit); }
|
||||
.badge { display: inline-block; background: #e5e7eb; padding: .05rem .4rem; border-radius: 4px; font-size: .75rem; color: var(--text); }
|
||||
.badge.on { background: #dcfce7; color: #14532d; }
|
||||
.badge.off { background: #fee2e2; color: #7f1d1d; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .88rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
th, td { text-align: left; padding: .45rem .7rem; border-bottom: 1px solid var(--border); }
|
||||
th { background: #f3f4f6; font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.sev { display: inline-block; padding: .08rem .4rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; text-transform: uppercase; }
|
||||
.sev-info { background: var(--info); }
|
||||
.sev-warn { background: var(--warn); }
|
||||
.sev-crit { background: var(--crit); }
|
||||
details { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .8rem; }
|
||||
details pre { max-height: 360px; overflow: auto; font-size: .8rem; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="status-banner {{.OverallClass}}">
|
||||
<div>
|
||||
<div class="label">{{.OverallStatusText}}</div>
|
||||
<div class="sub">for <code>{{.Owner}}</code>{{if .ReverseIP}} (<code>{{.ReverseIP}}</code>){{end}}</div>
|
||||
</div>
|
||||
<div class="sub">
|
||||
{{if .ObservedTargets}}observed PTR: <code>{{index .ObservedTargets 0}}</code>{{else if eq .OverallStatus "crit"}}no PTR served{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if and .ReverseIP .ObservedTargets}}
|
||||
<div class="roundtrip {{if .ForwardMatch}}match{{else}}miss{{end}}">
|
||||
<span class="step"><code>{{.ReverseIP}}</code></span>
|
||||
<span class="arrow">— PTR →</span>
|
||||
<span class="step"><code>{{index .ObservedTargets 0}}</code></span>
|
||||
<span class="arrow">— A/AAAA →</span>
|
||||
<span class="step">
|
||||
{{if .ForwardAddresses}}
|
||||
{{range $i, $a := .ForwardAddresses}}{{if $i}}, {{end}}<code>{{$a.Address}}</code>{{end}}
|
||||
{{else}}
|
||||
<span class="muted">unresolved</span>
|
||||
{{end}}
|
||||
</span>
|
||||
<span class="arrow">·</span>
|
||||
{{if .ForwardMatch}}<span class="badge on">FCrDNS match</span>{{else}}<span class="badge off">FCrDNS mismatch</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="grid">
|
||||
<div class="card"><div class="k">Reverse name</div><div class="v">{{.Owner}}</div></div>
|
||||
<div class="card"><div class="k">Decoded IP</div><div class="v">{{if .ReverseIP}}{{.ReverseIP}}{{else}}<span class="muted">—</span>{{end}}</div></div>
|
||||
<div class="card"><div class="k">Reverse zone</div><div class="v">{{if .ReverseZone}}{{.ReverseZone}}{{else}}<span class="muted">—</span>{{end}}</div></div>
|
||||
<div class="card"><div class="k">Declared PTR target</div><div class="v">{{if .DeclaredTarget}}{{.DeclaredTarget}}{{else}}<span class="muted">—</span>{{end}}</div></div>
|
||||
<div class="card"><div class="k">Observed PTR target(s)</div>
|
||||
<div class="v">{{if .ObservedTargets}}{{range .ObservedTargets}}{{.}}<br>{{end}}{{else}}<span class="muted">none</span>{{end}}</div>
|
||||
</div>
|
||||
<div class="card"><div class="k">Observed TTL</div><div class="v">{{if .ObservedTTL}}{{.ObservedTTL}}s{{else}}<span class="muted">—</span>{{end}}</div></div>
|
||||
<div class="card"><div class="k">Rcode</div><div class="v">{{if .Rcode}}{{.Rcode}}{{else}}<span class="muted">—</span>{{end}}</div></div>
|
||||
<div class="card"><div class="k">FCrDNS</div>
|
||||
<div class="v">
|
||||
{{if .ForwardMatch}}<span class="badge on">match</span>
|
||||
{{else if .TargetResolves}}<span class="badge off">mismatch</span>
|
||||
{{else}}<span class="badge off">target unresolved</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .TopFailures}}
|
||||
<h2>Fix these first</h2>
|
||||
{{range .TopFailures}}
|
||||
<div class="top-failure severity-{{.Severity}}">
|
||||
<h3>{{.Title}} <span class="sev sev-{{.Severity}}">{{.Severity}}</span></h3>
|
||||
<ul>
|
||||
{{range .Messages}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
{{if .Hint}}<div class="fix"><strong>How to fix</strong>{{.Hint}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .ForwardAddresses}}
|
||||
<h2>Forward resolution of the PTR target</h2>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Address</th><th>TTL</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .ForwardAddresses}}
|
||||
<tr>
|
||||
<td><code>{{.Type}}</code></td>
|
||||
<td><code>{{.Address}}</code></td>
|
||||
<td>{{if .TTL}}{{.TTL}}s{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{if .ReverseNS}}
|
||||
<h2>Reverse zone name servers</h2>
|
||||
<table>
|
||||
<thead><tr><th>Server</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .ReverseNS}}<tr><td><code>{{.}}</code></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{if .OtherFindings}}
|
||||
<h2>Additional 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="sev sev-{{.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>`
|
||||
Loading…
Add table
Add a link
Reference in a new issue