Initial commit
This commit is contained in:
commit
2d98ed1b5d
33 changed files with 4644 additions and 0 deletions
733
checker/report.go
Normal file
733
checker/report.go
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
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>
|
||||
`
|
||||
Loading…
Add table
Add a link
Reference in a new issue