733 lines
22 KiB
Go
733 lines
22 KiB
Go
package checker
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html/template"
|
||
"sort"
|
||
"strings"
|
||
|
||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||
)
|
||
|
||
// GetHTMLReport implements sdk.CheckerHTMLReporter.
|
||
//
|
||
// The report is laid out top-down by decreasing importance:
|
||
// 1. a "Fix these first" banner listing the common failures (drift,
|
||
// DNSSEC, NXDOMAIN, SERVFAIL, regional split, etc.) with a plain-English
|
||
// remediation for each;
|
||
// 2. a per-RRset consensus table that shows which answers dominate and
|
||
// which resolvers disagree: the meat of the check;
|
||
// 3. a per-region matrix (consensus / drift / error per region × RRset);
|
||
// 4. a detailed per-resolver table for operators who want the raw data.
|
||
func (p *resolverPropagationProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||
var data ResolverPropagationData
|
||
if raw := ctx.Data(); len(raw) > 0 {
|
||
if err := json.Unmarshal(raw, &data); err != nil {
|
||
return "", fmt.Errorf("parse resolver-propagation data: %w", err)
|
||
}
|
||
}
|
||
|
||
deriveView(&data)
|
||
findings := statesToFindings(ctx.States())
|
||
view := buildReportView(&data, findings)
|
||
|
||
buf := &bytes.Buffer{}
|
||
if err := reportTmpl.Execute(buf, view); err != nil {
|
||
return "", err
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
// topFailureOrder is the priority used by the "Fix these first" banner.
|
||
// Items at the top reflect more impactful / more actionable issues so the
|
||
// reader has a triage path.
|
||
var topFailureOrder = []string{
|
||
CodeAllResolversDown,
|
||
CodeUnexpectedSERVFAIL,
|
||
CodeDNSSECFailure,
|
||
CodeAnswerDrift,
|
||
CodeUnexpectedNXDOMAIN,
|
||
CodeSerialDrift,
|
||
CodeRegionalSplit,
|
||
CodePartialPropagation,
|
||
CodeDNSSECUnvalidated,
|
||
CodeStaleCache,
|
||
CodeResolverRewrote,
|
||
CodeResolverUnreachable,
|
||
CodeResolverHighLatency,
|
||
CodeResolverFilteredHit,
|
||
CodeNoResolvers,
|
||
}
|
||
|
||
// reportView is the flattened shape the HTML template consumes.
|
||
type reportView struct {
|
||
Zone string
|
||
OverallStatus string
|
||
OverallClass string
|
||
OverallMessage string
|
||
Stats Stats
|
||
|
||
TopFailures []topFailure
|
||
OtherFindings []Finding
|
||
|
||
RRsets []rrsetRow
|
||
Regions []regionRow
|
||
|
||
Resolvers []resolverRow
|
||
}
|
||
|
||
type topFailure struct {
|
||
Code string
|
||
Severity string
|
||
Message string
|
||
Remedy string
|
||
Count int
|
||
Class string
|
||
Headline string // short, human-readable label for the card
|
||
}
|
||
|
||
type rrsetRow struct {
|
||
Key string
|
||
Name string
|
||
Type string
|
||
MatchesExpected bool
|
||
Expected []string
|
||
HasExpected bool
|
||
Groups []groupRow
|
||
Agreeing int
|
||
Dissenting int
|
||
StatusClass string
|
||
StatusLabel string
|
||
}
|
||
|
||
type groupRow struct {
|
||
Rcode string
|
||
Records []string
|
||
Resolvers []string
|
||
IsConsensus bool
|
||
}
|
||
|
||
type regionRow struct {
|
||
Region string
|
||
Label string
|
||
Resolvers int
|
||
Reachable int
|
||
Agreeing int
|
||
Disagreeing int
|
||
Errored int
|
||
}
|
||
|
||
type resolverRow struct {
|
||
ID string
|
||
Name string
|
||
IP string
|
||
Region string
|
||
Transport string
|
||
Filtered bool
|
||
Reachable bool
|
||
AvgMs int64
|
||
Probes []probeRow
|
||
}
|
||
|
||
type probeRow struct {
|
||
Key string
|
||
Rcode string
|
||
Records []string
|
||
MinTTL uint32
|
||
AD bool
|
||
AgreesWithConsensus bool
|
||
Error string
|
||
LatencyMs int64
|
||
}
|
||
|
||
func buildReportView(d *ResolverPropagationData, findings []Finding) *reportView {
|
||
v := &reportView{
|
||
Zone: d.Zone,
|
||
Stats: d.Stats,
|
||
}
|
||
|
||
// Overall banner: worst severity drives colour.
|
||
worst := ""
|
||
for _, f := range findings {
|
||
switch f.Severity {
|
||
case SeverityCrit:
|
||
worst = "crit"
|
||
case SeverityWarn:
|
||
if worst == "" {
|
||
worst = "warn"
|
||
}
|
||
case SeverityInfo:
|
||
if worst == "" {
|
||
worst = "info"
|
||
}
|
||
}
|
||
if worst == "crit" {
|
||
break
|
||
}
|
||
}
|
||
switch worst {
|
||
case "crit":
|
||
v.OverallStatus = "Critical issues"
|
||
v.OverallClass = "banner-crit"
|
||
v.OverallMessage = fmt.Sprintf("%s is not propagating correctly across public resolvers.", d.Zone)
|
||
case "warn":
|
||
v.OverallStatus = "Warnings"
|
||
v.OverallClass = "banner-warn"
|
||
v.OverallMessage = fmt.Sprintf("%s is propagating, but some resolvers or resource sets disagree.", d.Zone)
|
||
case "info":
|
||
v.OverallStatus = "Informational"
|
||
v.OverallClass = "banner-info"
|
||
v.OverallMessage = fmt.Sprintf("%s looks healthy; a few advisory notes below.", d.Zone)
|
||
default:
|
||
v.OverallStatus = "OK"
|
||
v.OverallClass = "banner-ok"
|
||
v.OverallMessage = fmt.Sprintf("%s is propagated consistently across %d of %d unfiltered resolvers.",
|
||
d.Zone, d.Stats.UnfilteredAgreeing, d.Stats.UnfilteredProbed)
|
||
}
|
||
|
||
// Top failures: bucket findings by code, keep each code's most severe
|
||
// occurrence, render in topFailureOrder.
|
||
byCode := map[string][]Finding{}
|
||
for _, f := range findings {
|
||
byCode[f.Code] = append(byCode[f.Code], f)
|
||
}
|
||
order := map[string]int{}
|
||
for i, c := range topFailureOrder {
|
||
order[c] = i + 1
|
||
}
|
||
used := map[string]bool{}
|
||
for _, code := range topFailureOrder {
|
||
list, ok := byCode[code]
|
||
if !ok {
|
||
continue
|
||
}
|
||
used[code] = true
|
||
f := list[0]
|
||
tf := topFailure{
|
||
Code: code,
|
||
Severity: string(f.Severity),
|
||
Message: f.Message,
|
||
Remedy: f.Remedy,
|
||
Count: len(list),
|
||
Class: "severity-" + string(f.Severity),
|
||
Headline: headlineFor(code),
|
||
}
|
||
v.TopFailures = append(v.TopFailures, tf)
|
||
}
|
||
// Anything else → "Other findings"
|
||
for code, list := range byCode {
|
||
if used[code] {
|
||
continue
|
||
}
|
||
for _, f := range list {
|
||
v.OtherFindings = append(v.OtherFindings, f)
|
||
}
|
||
}
|
||
sort.SliceStable(v.OtherFindings, func(i, j int) bool {
|
||
return severityRank(v.OtherFindings[i].Severity) > severityRank(v.OtherFindings[j].Severity)
|
||
})
|
||
|
||
// RRset rows, sorted by "name/type".
|
||
keys := make([]string, 0, len(d.RRsets))
|
||
for k := range d.RRsets {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
for _, k := range keys {
|
||
rv := d.RRsets[k]
|
||
row := rrsetRow{
|
||
Key: k,
|
||
Name: rv.Name,
|
||
Type: rv.Type,
|
||
MatchesExpected: rv.MatchesExpected,
|
||
Expected: rv.ExpectedRecords,
|
||
HasExpected: rv.Expected != "",
|
||
Agreeing: len(rv.Agreeing),
|
||
Dissenting: len(rv.Dissenting),
|
||
}
|
||
for _, g := range rv.Groups {
|
||
row.Groups = append(row.Groups, groupRow{
|
||
Rcode: g.Rcode,
|
||
Records: g.Records,
|
||
Resolvers: g.Resolvers,
|
||
IsConsensus: g.Signature == rv.ConsensusSig,
|
||
})
|
||
}
|
||
switch {
|
||
case rv.Expected != "" && !rv.MatchesExpected:
|
||
row.StatusClass = "pill-crit"
|
||
row.StatusLabel = "drift"
|
||
case len(rv.Groups) > 1:
|
||
row.StatusClass = "pill-warn"
|
||
row.StatusLabel = "partial"
|
||
case len(rv.Groups) == 1:
|
||
row.StatusClass = "pill-ok"
|
||
row.StatusLabel = "consensus"
|
||
default:
|
||
row.StatusClass = "pill-info"
|
||
row.StatusLabel = "no data"
|
||
}
|
||
v.RRsets = append(v.RRsets, row)
|
||
}
|
||
|
||
// Per-region rollup.
|
||
byRegion := map[string]*regionRow{}
|
||
for _, rv := range d.Resolvers {
|
||
r, ok := byRegion[rv.Region]
|
||
if !ok {
|
||
r = ®ionRow{Region: rv.Region, Label: regionLabel(rv.Region)}
|
||
byRegion[rv.Region] = r
|
||
}
|
||
r.Resolvers++
|
||
if rv.Reachable {
|
||
r.Reachable++
|
||
}
|
||
if rv.Reachable && !rv.Filtered {
|
||
ok := true
|
||
for key, p := range rv.Probes {
|
||
if p == nil || p.Error != "" {
|
||
r.Errored++
|
||
ok = false
|
||
break
|
||
}
|
||
cv := d.RRsets[key]
|
||
if cv == nil || cv.ConsensusSig == "" {
|
||
continue
|
||
}
|
||
if p.Signature != cv.ConsensusSig {
|
||
ok = false
|
||
break
|
||
}
|
||
}
|
||
if ok {
|
||
r.Agreeing++
|
||
} else {
|
||
r.Disagreeing++
|
||
}
|
||
}
|
||
}
|
||
for _, r := range byRegion {
|
||
v.Regions = append(v.Regions, *r)
|
||
}
|
||
sort.Slice(v.Regions, func(i, j int) bool { return v.Regions[i].Label < v.Regions[j].Label })
|
||
|
||
// Per-resolver rows.
|
||
rids := make([]string, 0, len(d.Resolvers))
|
||
for k := range d.Resolvers {
|
||
rids = append(rids, k)
|
||
}
|
||
sort.Strings(rids)
|
||
for _, rid := range rids {
|
||
rv := d.Resolvers[rid]
|
||
var total, n int64
|
||
probes := []probeRow{}
|
||
pkeys := make([]string, 0, len(rv.Probes))
|
||
for k := range rv.Probes {
|
||
pkeys = append(pkeys, k)
|
||
}
|
||
sort.Strings(pkeys)
|
||
for _, k := range pkeys {
|
||
p := rv.Probes[k]
|
||
pr := probeRow{
|
||
Key: k,
|
||
Rcode: p.Rcode,
|
||
Records: p.Records,
|
||
MinTTL: p.MinTTL,
|
||
AD: p.AD,
|
||
Error: p.Error,
|
||
LatencyMs: p.LatencyMs,
|
||
}
|
||
if cv := d.RRsets[k]; cv != nil && cv.ConsensusSig != "" {
|
||
pr.AgreesWithConsensus = p.Signature == cv.ConsensusSig
|
||
}
|
||
if p.Error == "" {
|
||
total += p.LatencyMs
|
||
n++
|
||
}
|
||
probes = append(probes, pr)
|
||
}
|
||
avg := int64(0)
|
||
if n > 0 {
|
||
avg = total / n
|
||
}
|
||
v.Resolvers = append(v.Resolvers, resolverRow{
|
||
ID: rv.ID,
|
||
Name: rv.Name,
|
||
IP: rv.IP,
|
||
Region: regionLabel(rv.Region),
|
||
Transport: string(rv.Transport),
|
||
Filtered: rv.Filtered,
|
||
Reachable: rv.Reachable,
|
||
AvgMs: avg,
|
||
Probes: probes,
|
||
})
|
||
}
|
||
|
||
return v
|
||
}
|
||
|
||
// Kept here (not in rules) so user-facing wording lives in one layer.
|
||
func headlineFor(code string) string {
|
||
switch code {
|
||
case CodeAllResolversDown:
|
||
return "No resolver could be reached"
|
||
case CodeUnexpectedSERVFAIL:
|
||
return "A resolver returns SERVFAIL"
|
||
case CodeDNSSECFailure:
|
||
return "DNSSEC validation fails"
|
||
case CodeAnswerDrift:
|
||
return "Public resolvers disagree with your authoritative answer"
|
||
case CodeUnexpectedNXDOMAIN:
|
||
return "A resolver sees your zone as non-existent"
|
||
case CodeSerialDrift:
|
||
return "SOA serial differs between resolvers"
|
||
case CodeRegionalSplit:
|
||
return "A whole region sees a different answer"
|
||
case CodePartialPropagation:
|
||
return "Change is mid-propagation"
|
||
case CodeDNSSECUnvalidated:
|
||
return "Validating resolver did not set AD"
|
||
case CodeStaleCache:
|
||
return "Resolvers still serve the previous SOA serial"
|
||
case CodeResolverRewrote:
|
||
return "Resolver rewrote the answer"
|
||
case CodeResolverUnreachable:
|
||
return "Resolver unreachable from the checker"
|
||
case CodeResolverHighLatency:
|
||
return "Slow resolver"
|
||
case CodeResolverFilteredHit:
|
||
return "Filtered resolver is blocking your zone"
|
||
case CodeNoResolvers:
|
||
return "No resolver matched the current selection"
|
||
default:
|
||
return code
|
||
}
|
||
}
|
||
|
||
// View-layer translation only: rules own severity/code/message, report adds remedy + subject scoping.
|
||
func statesToFindings(states []sdk.CheckState) []Finding {
|
||
if len(states) == 0 {
|
||
return nil
|
||
}
|
||
var out []Finding
|
||
for _, st := range states {
|
||
sev, ok := severityFromStatus(st.Status)
|
||
if !ok {
|
||
continue
|
||
}
|
||
f := Finding{
|
||
Code: st.Code,
|
||
Severity: sev,
|
||
Message: st.Message,
|
||
Remedy: remedyFor(st.Code),
|
||
}
|
||
if isResolverScopedCode(st.Code) {
|
||
f.Resolver = st.Subject
|
||
} else if st.Subject != "" && strings.Contains(st.Subject, "/") {
|
||
f.RRset = st.Subject
|
||
}
|
||
out = append(out, f)
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
if a, b := severityRank(out[i].Severity), severityRank(out[j].Severity); a != b {
|
||
return a > b
|
||
}
|
||
if out[i].Code != out[j].Code {
|
||
return out[i].Code < out[j].Code
|
||
}
|
||
if out[i].RRset != out[j].RRset {
|
||
return out[i].RRset < out[j].RRset
|
||
}
|
||
return out[i].Resolver < out[j].Resolver
|
||
})
|
||
return out
|
||
}
|
||
|
||
func severityFromStatus(s sdk.Status) (Severity, bool) {
|
||
switch s {
|
||
case sdk.StatusCrit:
|
||
return SeverityCrit, true
|
||
case sdk.StatusWarn:
|
||
return SeverityWarn, true
|
||
case sdk.StatusInfo:
|
||
return SeverityInfo, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func isResolverScopedCode(code string) bool {
|
||
switch code {
|
||
case CodeResolverUnreachable, CodeResolverTimeout, CodeResolverRewrote,
|
||
CodeResolverFilteredHit, CodeResolverHighLatency,
|
||
CodeDNSSECFailure, CodeDNSSECUnvalidated:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Wording lives here, not in rules: severity is judgment, copy is presentation.
|
||
func remedyFor(code string) string {
|
||
switch code {
|
||
case CodeNoResolvers:
|
||
return "loosen the region filter or reset the allowlist in the checker options"
|
||
case CodeAllResolversDown:
|
||
return "retry later, or verify the checker host's outgoing UDP/53 connectivity"
|
||
case CodeSerialDrift:
|
||
return "usually transient caching right after a zone push"
|
||
case CodeStaleCache:
|
||
return "the resolvers cached the previous zone version"
|
||
case CodeDNSSECFailure:
|
||
return "check that the DS record at the parent matches the DNSKEY at the zone apex"
|
||
case CodeDNSSECUnvalidated:
|
||
return "enable DNSSEC signing at your provider to get full validation downstream"
|
||
case CodeRegionalSplit:
|
||
return "possible GeoDNS misconfiguration or regional censorship"
|
||
case CodePartialPropagation:
|
||
return "wait up to the previous TTL for the old cached answer to expire everywhere"
|
||
case CodeAnswerDrift:
|
||
return "wait for the old TTL to expire or force a flush on the affected resolvers"
|
||
case CodeUnexpectedNXDOMAIN:
|
||
return "a resolver returning NXDOMAIN while others return NOERROR usually means a poisoned cache or lame delegation"
|
||
case CodeUnexpectedSERVFAIL:
|
||
return "check DNSSEC signatures and that every authoritative NS is reachable over UDP and TCP"
|
||
case CodeResolverUnreachable:
|
||
return "the resolver might be blocking the checker's traffic, firewalled, or temporarily down"
|
||
case CodeResolverRewrote:
|
||
return "the resolver appears to rewrite answers; users relying on it will see a different zone"
|
||
case CodeResolverFilteredHit:
|
||
return "normal for a filtered resolver when the zone is on a blocklist"
|
||
case CodeResolverHighLatency:
|
||
return "usually reflects the checker-to-resolver network path"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// severityRank orders severities for sorting; higher = more severe.
|
||
func severityRank(s Severity) int {
|
||
switch s {
|
||
case SeverityCrit:
|
||
return 3
|
||
case SeverityWarn:
|
||
return 2
|
||
case SeverityInfo:
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// reportFuncs exposes small helpers to the template so it can stay concise.
|
||
var reportFuncs = template.FuncMap{
|
||
"join": func(sep string, s []string) string { return strings.Join(s, sep) },
|
||
"len": func(s []string) int { return len(s) },
|
||
}
|
||
|
||
var reportTmpl = template.Must(template.New("report").Funcs(reportFuncs).Parse(reportTemplateHTML))
|
||
|
||
const reportTemplateHTML = `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Resolver propagation report — {{.Zone}}</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light dark;
|
||
--bg: #f6f7fb;
|
||
--fg: #1c1f2a;
|
||
--muted: #6c7488;
|
||
--card: #ffffff;
|
||
--border: #e3e6ef;
|
||
--ok: #1e8a4a;
|
||
--info: #2563eb;
|
||
--warn: #b7791f;
|
||
--crit: #c0392b;
|
||
--code-bg: #f0f2f8;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg: #11131a;
|
||
--fg: #eceff6;
|
||
--muted: #96a0b5;
|
||
--card: #181b24;
|
||
--border: #252a36;
|
||
--code-bg: #1f2330;
|
||
}
|
||
}
|
||
body { margin: 0; padding: 24px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.45; }
|
||
h1 { font-size: 1.5rem; margin: 0 0 4px 0; }
|
||
h2 { font-size: 1.1rem; margin: 24px 0 12px 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||
h3 { font-size: 0.95rem; margin: 16px 0 6px 0; }
|
||
.muted { color: var(--muted); }
|
||
.banner { padding: 14px 16px; border-radius: 10px; margin-bottom: 16px; }
|
||
.banner-ok { background: rgba(30,138,74,.1); border-left: 4px solid var(--ok); }
|
||
.banner-info { background: rgba(37,99,235,.1); border-left: 4px solid var(--info); }
|
||
.banner-warn { background: rgba(183,121,31,.12); border-left: 4px solid var(--warn); }
|
||
.banner-crit { background: rgba(192,57,43,.12); border-left: 4px solid var(--crit); }
|
||
.banner strong { display:block; font-size: 1.05rem; margin-bottom: 2px; }
|
||
.stats { display: flex; flex-wrap: wrap; gap: 12px; margin: 16px 0; }
|
||
.stat { background: var(--card); border:1px solid var(--border); border-radius:8px; padding: 10px 14px; min-width: 120px; }
|
||
.stat .n { font-size: 1.4rem; font-weight: 600; }
|
||
.stat .l { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; }
|
||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; }
|
||
.card.severity-crit { border-left: 4px solid var(--crit); }
|
||
.card.severity-warn { border-left: 4px solid var(--warn); }
|
||
.card.severity-info { border-left: 4px solid var(--info); }
|
||
.card-title { display:flex; justify-content:space-between; align-items:center; gap: 12px; }
|
||
.card-title .title { font-weight: 600; }
|
||
.count-pill { background: var(--code-bg); border-radius: 999px; padding: 2px 10px; font-size: .8rem; }
|
||
.remedy { background: var(--code-bg); padding: 8px 10px; border-radius: 6px; margin-top: 8px; font-size: .9rem; }
|
||
table { width: 100%; border-collapse: collapse; margin: 8px 0 16px 0; font-size: .9rem; }
|
||
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||
th { font-weight: 600; color: var(--muted); text-transform: uppercase; font-size: .75rem; letter-spacing: .04em; }
|
||
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||
code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; font-size: .85em; }
|
||
pre { background: var(--code-bg); padding: 10px; border-radius: 6px; overflow-x: auto; font-size: .8em; }
|
||
.pill { display:inline-block; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 600; }
|
||
.pill-ok { background: rgba(30,138,74,.15); color: var(--ok); }
|
||
.pill-warn { background: rgba(183,121,31,.18); color: var(--warn); }
|
||
.pill-crit { background: rgba(192,57,43,.18); color: var(--crit); }
|
||
.pill-info { background: rgba(37,99,235,.15); color: var(--info); }
|
||
.group-consensus { background: rgba(30,138,74,.07); }
|
||
.tag { display:inline-block; padding: 1px 6px; border-radius: 4px; font-size: .7rem; margin-right: 4px; background: var(--code-bg); color: var(--muted); }
|
||
.filtered { opacity: .7; }
|
||
details > summary { cursor: pointer; color: var(--muted); font-size: .85rem; }
|
||
.ok { color: var(--ok); }
|
||
.crit { color: var(--crit); }
|
||
.warn { color: var(--warn); }
|
||
.info { color: var(--info); }
|
||
.records { margin: 0; padding-left: 16px; font-family: ui-monospace, monospace; font-size: .8rem; }
|
||
.resolver-list { font-size: .75rem; color: var(--muted); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<h1>Worldwide DNS propagation — <code>{{.Zone}}</code></h1>
|
||
<div class="muted">Probe across public recursive resolvers; consensus compared to the zone's own authoritative answer.</div>
|
||
|
||
<div class="banner {{.OverallClass}}">
|
||
<strong>{{.OverallStatus}}</strong>
|
||
{{.OverallMessage}}
|
||
</div>
|
||
|
||
<div class="stats">
|
||
<div class="stat"><div class="n">{{.Stats.ReachableResolvers}} / {{.Stats.TotalResolvers}}</div><div class="l">Resolvers reachable</div></div>
|
||
<div class="stat"><div class="n">{{.Stats.UnfilteredAgreeing}} / {{.Stats.UnfilteredProbed}}</div><div class="l">Unfiltered agreeing</div></div>
|
||
<div class="stat"><div class="n">{{.Stats.CountriesCovered}}</div><div class="l">Regions covered</div></div>
|
||
{{if .Stats.FilteredProbed}}<div class="stat"><div class="n">{{.Stats.FilteredProbed}}</div><div class="l">Filtered probed</div></div>{{end}}
|
||
</div>
|
||
|
||
{{if .TopFailures}}
|
||
<h2>Fix these first</h2>
|
||
{{range .TopFailures}}
|
||
<div class="card {{.Class}}">
|
||
<div class="card-title">
|
||
<span class="title">{{.Headline}}</span>
|
||
<span class="count-pill">{{.Count}}× · <span class="{{.Severity}}">{{.Severity}}</span></span>
|
||
</div>
|
||
<div>{{.Message}}</div>
|
||
{{if .Remedy}}<div class="remedy"><strong>What to do:</strong> {{.Remedy}}</div>{{end}}
|
||
</div>
|
||
{{end}}
|
||
{{end}}
|
||
|
||
<h2>Per-RRset consensus</h2>
|
||
<table>
|
||
<thead><tr><th>Record</th><th>Status</th><th>Expected (authoritative)</th><th>What resolvers see</th></tr></thead>
|
||
<tbody>
|
||
{{range .RRsets}}
|
||
<tr>
|
||
<td><code>{{.Name}}</code><br><span class="tag">{{.Type}}</span></td>
|
||
<td><span class="pill {{.StatusClass}}">{{.StatusLabel}}</span><br>
|
||
<span class="muted">{{.Agreeing}} ok · {{.Dissenting}} diff</span></td>
|
||
<td>
|
||
{{if .HasExpected}}
|
||
{{if .Expected}}<ul class="records">{{range .Expected}}<li>{{.}}</li>{{end}}</ul>{{else}}<span class="muted">(no data / NODATA)</span>{{end}}
|
||
{{else}}
|
||
<span class="muted">(auth unreachable)</span>
|
||
{{end}}
|
||
</td>
|
||
<td>
|
||
{{range .Groups}}
|
||
<div class="{{if .IsConsensus}}group-consensus{{end}}" style="margin-bottom:8px; padding:6px 8px; border-radius:6px;">
|
||
<span class="pill {{if eq .Rcode "NOERROR"}}pill-ok{{else if eq .Rcode "NXDOMAIN"}}pill-crit{{else if eq .Rcode "SERVFAIL"}}pill-crit{{else}}pill-warn{{end}}">{{.Rcode}}</span>
|
||
{{if .IsConsensus}}<span class="pill pill-info">consensus</span>{{end}}
|
||
{{if .Records}}<ul class="records">{{range .Records}}<li>{{.}}</li>{{end}}</ul>{{else}}<span class="muted">(empty)</span>{{end}}
|
||
<div class="resolver-list">{{len .Resolvers}} resolver(s): {{join ", " .Resolvers}}</div>
|
||
</div>
|
||
{{end}}
|
||
</td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
|
||
<h2>Per-region view</h2>
|
||
<table>
|
||
<thead><tr><th>Region</th><th>Reachable</th><th>Agreeing</th><th>Disagreeing</th><th>Errored</th></tr></thead>
|
||
<tbody>
|
||
{{range .Regions}}
|
||
<tr>
|
||
<td>{{.Label}}</td>
|
||
<td>{{.Reachable}} / {{.Resolvers}}</td>
|
||
<td><span class="ok">{{.Agreeing}}</span></td>
|
||
<td>{{if .Disagreeing}}<span class="warn">{{.Disagreeing}}</span>{{else}}0{{end}}</td>
|
||
<td>{{if .Errored}}<span class="crit">{{.Errored}}</span>{{else}}0{{end}}</td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
|
||
<h2>Per-resolver details</h2>
|
||
<table>
|
||
<thead><tr><th>Resolver</th><th>Region</th><th>Transport</th><th>Avg ms</th><th>Answers</th></tr></thead>
|
||
<tbody>
|
||
{{range .Resolvers}}
|
||
<tr class="{{if .Filtered}}filtered{{end}}">
|
||
<td>
|
||
<strong>{{.Name}}</strong>{{if .Filtered}} <span class="tag">filtered</span>{{end}}<br>
|
||
<span class="muted"><code>{{.IP}}</code> · {{.ID}}</span>
|
||
</td>
|
||
<td>{{.Region}}</td>
|
||
<td>{{.Transport}}</td>
|
||
<td>{{if .Reachable}}{{.AvgMs}}{{else}}<span class="crit">unreachable</span>{{end}}</td>
|
||
<td>
|
||
{{range .Probes}}
|
||
<details>
|
||
<summary>
|
||
<code>{{.Key}}</code>
|
||
<span class="pill {{if .Error}}pill-crit{{else if eq .Rcode "NOERROR"}}{{if .AgreesWithConsensus}}pill-ok{{else}}pill-warn{{end}}{{else}}pill-crit{{end}}">
|
||
{{if .Error}}error{{else}}{{.Rcode}}{{if .AgreesWithConsensus}} · ✓{{else}} · ≠{{end}}{{end}}
|
||
</span>
|
||
{{if .AD}}<span class="tag">AD</span>{{end}}
|
||
<span class="muted">{{.LatencyMs}}ms{{if .MinTTL}} · TTL {{.MinTTL}}{{end}}</span>
|
||
</summary>
|
||
{{if .Error}}<pre>{{.Error}}</pre>{{else if .Records}}<ul class="records">{{range .Records}}<li>{{.}}</li>{{end}}</ul>{{else}}<span class="muted">(empty answer)</span>{{end}}
|
||
</details>
|
||
{{end}}
|
||
</td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
|
||
{{if .OtherFindings}}
|
||
<h2>Other findings</h2>
|
||
<table>
|
||
<thead><tr><th>Severity</th><th>Code</th><th>Message</th><th>Remedy</th></tr></thead>
|
||
<tbody>
|
||
{{range .OtherFindings}}
|
||
<tr>
|
||
<td><span class="{{.Severity}}">{{.Severity}}</span></td>
|
||
<td><code>{{.Code}}</code></td>
|
||
<td>{{.Message}}</td>
|
||
<td class="muted">{{.Remedy}}</td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
{{end}}
|
||
|
||
</body>
|
||
</html>
|
||
`
|