checker-authoritative-consi.../checker/report.go

553 lines
18 KiB
Go

package checker
import (
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Concrete remediation: the user should know what to do next without
// leaving the report page.
var remediationHints = map[string]string{
CodeSerialDrift: "Some authoritative servers are lagging behind. On the hidden primary, trigger a NOTIFY (rndc notify / nsd-control notify / knsc zone-reload); if that doesn't help, check that the primary is reachable from the secondaries (port 53 TCP for AXFR/IXFR) and that their zone file isn't frozen.",
CodeSerialStaleVsSaved: "You edited the zone in happyDomain but the changes have not been pushed to your DNS provider yet. Open the zone and click \"Apply changes\": the provider's API will receive the new serial and propagate it.",
CodeSerialAheadOfSaved: "The zone was modified outside happyDomain. Re-import the zone from the provider so happyDomain's view is up to date.",
CodeNSUnreachable: "This server did not answer any query. Check that the host is up and that UDP/TCP 53 is not filtered by a firewall.",
CodeNSUDPFailed: "UDP/53 is filtered or the server is down. Verify the service, firewall and any upstream load balancer. A DNS server that cannot be reached over UDP is effectively offline.",
CodeNSTCPFailed: "TCP/53 is required by RFC 7766 and by DNSSEC: truncated UDP answers fall back to TCP. Check your firewall and any middleboxes (many consumer firewalls block TCP/53 by default).",
CodeNSUnresolvable: "This NS hostname has no A or AAAA record. Add glue at the registrar if it is in-bailiwick, or point it to a resolvable hostname otherwise.",
CodeLame: "This server answers but is not authoritative for the zone; it has no copy of the zone file. Either configure the zone on it, or remove it from the NS RRset to stop resolvers from wasting queries on it.",
CodeNoSOA: "The server claims authority but does not return a SOA record. Check the zone is fully loaded (no parse error in the zone file, no uncommitted transaction).",
CodeNSRRsetDrift: "The NS RRset differs between authoritative servers. Force a zone transfer from the primary to the lagging server(s), or align the NS records manually.",
CodeNSRRsetMismatchConfig: "The NS records served by the zone do not match what you configured in happyDomain. Either update the service to match reality, or push the declared NS list to your DNS provider.",
CodeParentDrift: "The NS RRset at the parent zone (your registrar) does not match the NS declared here. Log into your registrar and reconcile the delegation.",
CodeParentQueryFailed: "The parent delegation could not be resolved. The cross-check with the parent is skipped for this run; verify the zone name and that its parent is reachable.",
CodeSOAFieldsDrift: "The SOA RDATA (MNAME, RNAME, TTL fields) differs between authoritative servers. This usually means a secondary still serves an old zone file. Force a fresh AXFR.",
CodeSlowNS: "This server answers slowly. It still works, but users on distant networks will see sluggish resolution. Consider an anycast upgrade or moving the server closer to your audience.",
CodeEDNSUnsupported: "This server does not correctly handle EDNS0 (RFC 6891). DNSSEC validation and large answers will fail. Upgrade the DNS software or, on a firewall, allow DNS packets larger than 512 bytes and the OPT record.",
CodeTooFewNS: "A zone with a single NS is fragile. RFC 1034 recommends at least two, ideally on separate networks.",
CodeNoNS: "No authoritative servers were discovered. The zone cannot be served in its current state.",
}
type reportNS struct {
Name string
Addresses string
UDP bool
TCP bool
AA bool
Serial uint32
Latency int64
EDNS bool
BadUDP bool
BadTCP bool
BadAA bool
BadEDNS bool
Errors []string
}
type reportFinding struct {
Code string
Severity string
Message string
Server string
Hint string
Class string // CSS class
}
type reportSerialGroup struct {
Serial uint32
Servers []string
Majority bool
}
type reportData struct {
Zone string
HasSOA bool
DeclaredSerial uint32
DeclaredNS []string
ParentNS []string
ParentError string
Headline string
HeadlineClass string
HeadlineHint string
Totals map[string]int
NS []reportNS
SerialGroups []reportSerialGroup
ShowSerialTable bool
Findings []reportFinding
}
var htmlTemplate = template.Must(
template.New("authoritative-consistency").
Funcs(template.FuncMap{
"join": func(s []string) string { return strings.Join(s, ", ") },
"boolBadge": func(ok bool) template.HTML {
if ok {
return template.HTML(`<span class="pill pill-ok">OK</span>`)
}
return template.HTML(`<span class="pill pill-bad">KO</span>`)
},
"naBadge": func(ok bool, relevant bool) template.HTML {
if !relevant {
return template.HTML(`<span class="pill pill-na">—</span>`)
}
if ok {
return template.HTML(`<span class="pill pill-ok">OK</span>`)
}
return template.HTML(`<span class="pill pill-bad">KO</span>`)
},
}).
Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authoritative consistency: {{.Zone}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
.card {
background: #fff;
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.card h2 {
font-size: .95rem;
font-weight: 700;
margin: 0 0 .6rem;
color: #374151;
letter-spacing: .02em;
text-transform: uppercase;
}
.headline {
border-left: 4px solid #9ca3af;
padding-left: .8rem;
}
.headline h1 { margin: 0 0 .25rem; font-size: 1.15rem; font-weight: 700; }
.headline .sub { color: #6b7280; font-size: .85rem; }
.headline .hint { margin-top: .5rem; font-size: .9rem; color: #374151; }
.headline-crit { border-left-color: #dc2626; }
.headline-warn { border-left-color: #d97706; }
.headline-info { border-left-color: #2563eb; }
.headline-ok { border-left-color: #16a34a; }
.totals { display: flex; gap: .35rem; flex-wrap: wrap; margin-top: .5rem; }
.badge {
display: inline-flex; align-items: center;
padding: .18em .55em;
border-radius: 9999px;
font-size: .72rem; font-weight: 700;
letter-spacing: .02em; white-space: nowrap;
}
.badge-crit { background: #fee2e2; color: #991b1b; }
.badge-warn { background: #fef3c7; color: #92400e; }
.badge-info { background: #dbeafe; color: #1e40af; }
.badge-ok { background: #dcfce7; color: #166534; }
table { width: 100%; border-collapse: collapse; font-size: .88rem; }
th, td {
padding: .45rem .6rem;
border-bottom: 1px solid #f3f4f6;
text-align: left;
vertical-align: top;
}
th { color: #6b7280; font-weight: 600; font-size: .75rem; text-transform: uppercase; letter-spacing: .03em; }
tr:last-child td { border-bottom: none; }
.ns-name { font-weight: 600; }
.ns-addrs { color: #6b7280; font-size: .75rem; }
.pill {
display: inline-block; padding: .1em .5em;
border-radius: 9999px;
font-size: .72rem; font-weight: 700;
}
.pill-ok { background: #dcfce7; color: #166534; }
.pill-bad { background: #fee2e2; color: #991b1b; }
.pill-na { background: #f3f4f6; color: #6b7280; }
.serial-row td { padding-top: .3rem; padding-bottom: .3rem; }
.serial-majority { font-weight: 700; color: #166534; }
.serial-lag { color: #991b1b; }
.serial-ahead { color: #1e40af; }
.finding {
border-left: 4px solid #d1d5db;
padding: .5rem .8rem;
margin-bottom: .5rem;
background: #fafafa;
border-radius: 4px;
}
.finding-crit { border-left-color: #dc2626; background: #fef2f2; }
.finding-warn { border-left-color: #d97706; background: #fffbeb; }
.finding-info { border-left-color: #2563eb; background: #eff6ff; }
.finding .title { font-weight: 600; margin-bottom: .2rem; }
.finding .server { font-size: .78rem; color: #6b7280; }
.finding .hint { margin-top: .4rem; font-size: .85rem; color: #374151; }
.small { color: #6b7280; font-size: .82rem; }
.muted { color: #9ca3af; }
</style>
</head>
<body>
<div class="card headline headline-{{.HeadlineClass}}">
<h1>{{.Headline}}</h1>
<div class="sub"><code>{{.Zone}}</code>{{if .HasSOA}}, saved SOA serial <code>{{.DeclaredSerial}}</code>{{end}}</div>
<div class="totals">
{{- range $lvl, $n := .Totals}}{{if $n}}
<span class="badge badge-{{$lvl}}">{{$lvl}}&nbsp;{{$n}}</span>
{{end}}{{end}}
</div>
{{if .HeadlineHint}}<div class="hint">{{.HeadlineHint}}</div>{{end}}
</div>
{{if .ShowSerialTable}}
<div class="card">
<h2>Serial consistency</h2>
<table>
<thead>
<tr><th>SOA serial</th><th>Servers</th></tr>
</thead>
<tbody>
{{- range .SerialGroups}}
<tr class="serial-row">
<td>
<code>{{.Serial}}</code>
{{if .Majority}}<span class="serial-majority"> ← consensus</span>{{end}}
</td>
<td>{{join .Servers}}</td>
</tr>
{{- end}}
</tbody>
</table>
{{if .DeclaredSerial}}<div class="small" style="margin-top:.5rem">Saved in happyDomain: <code>{{.DeclaredSerial}}</code></div>{{end}}
</div>
{{end}}
<div class="card">
<h2>Per-server probe</h2>
<table>
<thead>
<tr>
<th>Name server</th>
<th>UDP/53</th>
<th>TCP/53</th>
<th>Authoritative</th>
<th>Serial</th>
<th>Latency</th>
<th>EDNS0</th>
</tr>
</thead>
<tbody>
{{- range .NS}}
<tr>
<td>
<div class="ns-name">{{.Name}}</div>
{{if .Addresses}}<div class="ns-addrs">{{.Addresses}}</div>{{end}}
{{- range .Errors}}<div class="ns-addrs">⚠ {{.}}</div>{{end}}
</td>
<td>{{boolBadge .UDP}}</td>
<td>{{boolBadge .TCP}}</td>
<td>{{boolBadge .AA}}</td>
<td>{{if .Serial}}<code>{{.Serial}}</code>{{else}}<span class="muted">—</span>{{end}}</td>
<td>{{if .Latency}}{{.Latency}} ms{{else}}<span class="muted">—</span>{{end}}</td>
<td>{{naBadge .EDNS .UDP}}</td>
</tr>
{{- end}}
</tbody>
</table>
</div>
{{if .DeclaredNS}}
<div class="card">
<h2>Declared vs observed NS</h2>
<table>
<tbody>
<tr><td class="small">Declared in service</td><td><code>{{join .DeclaredNS}}</code></td></tr>
{{if .ParentNS}}<tr><td class="small">Parent delegation</td><td><code>{{join .ParentNS}}</code></td></tr>{{end}}
{{if .ParentError}}<tr><td class="small">Parent query</td><td class="serial-lag">{{.ParentError}}</td></tr>{{end}}
</tbody>
</table>
</div>
{{end}}
<div class="card">
<h2>Findings</h2>
{{if .Findings}}
{{- range .Findings}}
<div class="finding finding-{{.Class}}">
<div class="title">
<span class="badge badge-{{.Class}}">{{.Severity}}</span>
{{.Message}}
</div>
{{if .Server}}<div class="server">on <code>{{.Server}}</code></div>{{end}}
{{if .Hint}}<div class="hint">💡 {{.Hint}}</div>{{end}}
</div>
{{- end}}
{{else}}
<div class="small">No issue detected. Every authoritative server agrees on the zone.</div>
{{end}}
</div>
</body>
</html>`),
)
// Implements sdk.CheckerHTMLReporter.
func (p *authoritativeConsistencyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data ObservationData
if err := json.Unmarshal(ctx.Data(), &data); err != nil {
return "", fmt.Errorf("checker: unmarshal observation: %w", err)
}
rd := reportData{
Zone: data.Zone,
HasSOA: data.HasSOA,
DeclaredSerial: data.DeclaredSerial,
DeclaredNS: data.DeclaredNS,
ParentNS: data.ParentNS,
ParentError: data.ParentQueryError,
Totals: map[string]int{"crit": 0, "warn": 0, "info": 0},
}
for _, name := range data.Probed {
r := data.Results[name]
if r == nil {
rd.NS = append(rd.NS, reportNS{Name: name, Errors: []string{"no probe result"}})
continue
}
rd.NS = append(rd.NS, reportNS{
Name: name,
Addresses: strings.Join(r.Addresses, ", "),
UDP: r.UDPReachable,
TCP: r.TCPReachable,
AA: r.Authoritative,
Serial: r.Serial,
Latency: r.LatencyMs,
EDNS: r.EDNSSupported,
Errors: r.Errors,
})
}
if data.HasSOA {
groups := map[uint32][]string{}
for _, name := range data.Probed {
r := data.Results[name]
if r == nil || !r.Authoritative || r.SOA == nil {
continue
}
groups[r.Serial] = append(groups[r.Serial], name)
}
if len(groups) > 0 {
rd.ShowSerialTable = len(groups) > 1 || data.DeclaredSerial != 0
serials := make([]uint32, 0, len(groups))
for s := range groups {
serials = append(serials, s)
}
sort.Slice(serials, func(i, j int) bool { return len(groups[serials[i]]) > len(groups[serials[j]]) })
majority := serials[0]
for _, s := range serials {
srv := groups[s]
sort.Strings(srv)
rd.SerialGroups = append(rd.SerialGroups, reportSerialGroup{
Serial: s,
Servers: srv,
Majority: s == majority && len(groups) > 1,
})
}
}
}
// When the host doesn't pass states (data-only render), Findings stays
// empty and the page shows only the raw per-NS observations.
states := ctx.States()
for _, st := range states {
sev := statusToSeverity(st.Status)
if sev == "" {
continue
}
rf := reportFinding{
Code: st.Code,
Severity: strings.ToUpper(string(sev)),
Message: st.Message,
Server: st.Subject,
}
if st.Meta != nil {
if fix, ok := st.Meta["fix"].(string); ok {
rf.Hint = fix
}
}
switch sev {
case SeverityCrit:
rf.Class = "crit"
rd.Totals["crit"]++
case SeverityWarn:
rf.Class = "warn"
rd.Totals["warn"]++
case SeverityInfo:
rf.Class = "info"
rd.Totals["info"]++
}
rd.Findings = append(rd.Findings, rf)
}
// Headline surfaces the worst issue so the remediation hint stays above
// the fold; data-only renders fall back to a neutral title.
if len(states) == 0 {
rd.Headline = fmt.Sprintf("Raw authoritative-consistency observation for %s", data.Zone)
rd.HeadlineClass = "info"
rd.HeadlineHint = ""
} else {
rd.Headline, rd.HeadlineClass, rd.HeadlineHint = headlineFromStates(&data, states, rd.Findings)
}
var buf strings.Builder
if err := htmlTemplate.Execute(&buf, rd); err != nil {
return "", fmt.Errorf("checker: rendering HTML: %w", err)
}
return buf.String(), nil
}
// Returns "" for OK/Unknown: those don't go in the findings list.
func statusToSeverity(s sdk.Status) Severity {
switch s {
case sdk.StatusCrit:
return SeverityCrit
case sdk.StatusWarn:
return SeverityWarn
case sdk.StatusInfo:
return SeverityInfo
}
return ""
}
// Priority order is hand-curated: unreachable/lame trump drift, drift trumps
// merely-slow servers. Reorder with care: the headline drives user attention.
func headlineFromStates(data *ObservationData, states []sdk.CheckState, renderedFindings []reportFinding) (title, class, hint string) {
codesPresent := map[string]bool{}
for _, st := range states {
if statusToSeverity(st.Status) == "" {
continue
}
codesPresent[st.Code] = true
}
priorities := []string{
CodeNSUDPFailed,
CodeNSUnreachable,
CodeLame,
CodeSerialDrift,
CodeSerialStaleVsSaved,
CodeNSRRsetDrift,
CodeNSRRsetMismatchConfig,
CodeParentDrift,
CodeSOAFieldsDrift,
CodeNSTCPFailed,
CodeEDNSUnsupported,
CodeSerialAheadOfSaved,
CodeSlowNS,
}
for _, code := range priorities {
if codesPresent[code] {
return headlineCopyFor(code, data)
}
}
if len(renderedFindings) == 0 {
if data.HasSOA {
return "Zone is propagated consistently on every name server", "ok", fmt.Sprintf("Serial %d is served identically by all %d probed servers.", mostCommonSerial(data), len(data.Probed))
}
return "Every declared name server is reachable and authoritative", "ok", ""
}
return fmt.Sprintf("%d issue(s) detected", len(renderedFindings)), "warn", "See the findings list below for details."
}
func headlineCopyFor(code string, data *ObservationData) (title, class, hint string) {
class = "warn"
switch code {
case CodeNSUDPFailed, CodeNSUnreachable:
return "One or more name servers are unreachable",
"crit",
remediationHints[code]
case CodeLame:
return "Lame delegation detected",
"crit",
remediationHints[CodeLame]
case CodeSerialDrift:
return "Zone is not fully propagated: SOA serials disagree",
"crit",
remediationHints[CodeSerialDrift]
case CodeSerialStaleVsSaved:
return "Pending changes have not reached the authoritative servers",
"warn",
remediationHints[CodeSerialStaleVsSaved]
case CodeNSRRsetDrift:
return "NS RRset differs between servers",
"warn",
remediationHints[CodeNSRRsetDrift]
case CodeNSRRsetMismatchConfig:
return "NS RRset served does not match the configured one",
"warn",
remediationHints[CodeNSRRsetMismatchConfig]
case CodeParentDrift:
return "Parent delegation does not match the configured NS list",
"warn",
remediationHints[CodeParentDrift]
case CodeSOAFieldsDrift:
return "SOA fields disagree between servers",
"warn",
remediationHints[CodeSOAFieldsDrift]
case CodeNSTCPFailed:
return "TCP/53 is not answered by every server",
"warn",
remediationHints[CodeNSTCPFailed]
case CodeEDNSUnsupported:
return "EDNS0 is not supported by every server",
"warn",
remediationHints[CodeEDNSUnsupported]
case CodeSerialAheadOfSaved:
return "Live serial is ahead of happyDomain's saved value",
"info",
remediationHints[CodeSerialAheadOfSaved]
case CodeSlowNS:
return "At least one name server responds slowly",
"info",
remediationHints[CodeSlowNS]
}
return "Issues detected", "warn", ""
}
// Only meaningful when HasSOA.
func mostCommonSerial(data *ObservationData) uint32 {
counts := map[uint32]int{}
for _, r := range data.Results {
if r == nil || !r.Authoritative || r.SOA == nil {
continue
}
counts[r.Serial]++
}
var best uint32
var bestN int
for s, n := range counts {
if n > bestN {
best = s
bestN = n
}
}
return best
}