Initial commit
This commit is contained in:
commit
5a632a3b30
24 changed files with 2901 additions and 0 deletions
755
checker/report.go
Normal file
755
checker/report.go
Normal file
|
|
@ -0,0 +1,755 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func (p *dnssecProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||
var data DNSSECData
|
||||
if raw := ctx.Data(); len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return "", fmt.Errorf("parse dnssec 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
|
||||
}
|
||||
|
||||
// commonFailures drives both the visual order of the "Fix these first" cards
|
||||
// and the curated catalogue of operator-facing scenarios. The order matches
|
||||
// the rough operational severity in production (a NSEC-walkable zone or a
|
||||
// stuck signer hurts more than a missing CDS).
|
||||
var commonFailures = []struct {
|
||||
rule, title string
|
||||
}{
|
||||
{"dnssec_zone_signed", "Zone is missing DNSSEC records"},
|
||||
{"dnssec_rrsig_validity_window", "RRSIG outside its validity window"},
|
||||
{"dnssec_rrsig_freshness", "RRSIG close to expiration"},
|
||||
{"dnssec_dnskey_consistent", "Authoritative servers serve different DNSKEY RRsets"},
|
||||
{"dnssec_denial_uses_nsec3", "Zone is enumerable through NSEC walking"},
|
||||
{"dnssec_nsec3_iterations", "NSEC3 iterations above RFC 9276 ceiling"},
|
||||
{"dnssec_nsec3_salt_empty", "NSEC3 salt is not empty"},
|
||||
{"dnssec_denial_consistent", "Servers disagree on the denial-of-existence scheme"},
|
||||
{"dnssec_algorithm_allowed", "Disallowed DNSSEC algorithm"},
|
||||
{"dnssec_algorithm_modern", "Legacy RSA algorithm in use"},
|
||||
{"dnssec_rsa_keysize", "RSA key too small"},
|
||||
{"dnssec_ksk_present", "No KSK published"},
|
||||
{"dnssec_rrsig_present_dnskey", "DNSKEY RRset has no covering RRSIG"},
|
||||
{"dnssec_rrsig_present_soa", "SOA RRset has no covering RRSIG"},
|
||||
{"dnssec_dnskey_query_ok", "Authoritative server unreachable for DNSKEY"},
|
||||
{"dnssec_dnskey_ttl_min", "DNSKEY TTL below recommended minimum"},
|
||||
{"dnssec_dnskey_count", "Too many DNSKEYs published"},
|
||||
{"dnssec_nsec3_optout_only_when_signed_delegations", "OPT-OUT in a leaf zone"},
|
||||
}
|
||||
|
||||
type reportView struct {
|
||||
Domain string
|
||||
CollectedAt string
|
||||
NameServers []string
|
||||
OverallStatus string
|
||||
OverallText string
|
||||
OverallClass string
|
||||
HasStates bool
|
||||
Banner bannerView
|
||||
TopFailures []topFailure
|
||||
Enumerability enumView
|
||||
Keys []keyRow
|
||||
Signatures []sigRow
|
||||
PerServer []serverView
|
||||
OtherFindings []otherFinding
|
||||
GlobalErrors []string
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
type bannerView struct {
|
||||
Algorithms string
|
||||
DenialKind string
|
||||
DNSKEYCount int
|
||||
NearestExpiryDays string
|
||||
HasNearestExpiry bool
|
||||
NearestExpiryClass string
|
||||
}
|
||||
|
||||
type topFailure struct {
|
||||
RuleName string
|
||||
Title string
|
||||
Severity string
|
||||
Messages []string
|
||||
Hint string
|
||||
Subject string
|
||||
}
|
||||
|
||||
type enumView struct {
|
||||
Kind string
|
||||
KindClass string
|
||||
Verdict string
|
||||
VerdictClass string
|
||||
Explanation string
|
||||
Iterations uint16
|
||||
SaltLength uint8
|
||||
Salt string
|
||||
OptOut bool
|
||||
HasNSEC3Param bool
|
||||
RFC9276Compliant bool
|
||||
WalkableWarning bool
|
||||
}
|
||||
|
||||
type keyRow struct {
|
||||
KeyTag uint16
|
||||
Algorithm string
|
||||
Flags string
|
||||
Size string
|
||||
Role string
|
||||
}
|
||||
|
||||
type sigRow struct {
|
||||
Server string
|
||||
TypeCovered string
|
||||
KeyTag uint16
|
||||
Algorithm string
|
||||
Inception string
|
||||
Expiration string
|
||||
Remaining string
|
||||
BarPercent int
|
||||
BarClass string
|
||||
}
|
||||
|
||||
type serverView struct {
|
||||
Server string
|
||||
UDPError string
|
||||
TCPError string
|
||||
DNSKEYCount int
|
||||
DenialKind string
|
||||
NSEC3Summary string
|
||||
ProbeName string
|
||||
DenialDump []string
|
||||
}
|
||||
|
||||
type otherFinding struct {
|
||||
Severity string
|
||||
RuleName string
|
||||
Subject string
|
||||
Message string
|
||||
Hint string
|
||||
}
|
||||
|
||||
func buildReportView(d *DNSSECData, states []sdk.CheckState) *reportView {
|
||||
v := &reportView{
|
||||
Domain: d.Domain,
|
||||
NameServers: d.NameServers,
|
||||
HasStates: len(states) > 0,
|
||||
}
|
||||
if !d.CollectedAt.IsZero() {
|
||||
v.CollectedAt = d.CollectedAt.Format(time.RFC3339)
|
||||
}
|
||||
v.GlobalErrors = d.Errors
|
||||
|
||||
if raw, err := json.MarshalIndent(d, "", " "); err == nil {
|
||||
v.RawJSON = string(raw)
|
||||
} else {
|
||||
v.GlobalErrors = append(v.GlobalErrors, fmt.Sprintf("render raw JSON: %v", err))
|
||||
}
|
||||
|
||||
v.Banner = buildBanner(d)
|
||||
v.Enumerability = buildEnum(d)
|
||||
v.Keys = buildKeys(d)
|
||||
v.Signatures = buildSignatures(d)
|
||||
v.PerServer = buildServers(d)
|
||||
|
||||
if v.HasStates {
|
||||
worst := worstStatus(states)
|
||||
v.OverallStatus, v.OverallText, v.OverallClass = statusLabel(worst)
|
||||
|
||||
titleByRule := map[string]string{}
|
||||
order := map[string]int{}
|
||||
for i, cf := range commonFailures {
|
||||
titleByRule[cf.rule] = cf.title
|
||||
order[cf.rule] = i
|
||||
}
|
||||
|
||||
topMap := map[string]*topFailure{}
|
||||
for _, s := range states {
|
||||
if s.Status == sdk.StatusOK || s.Status == sdk.StatusUnknown || s.Status == sdk.StatusInfo {
|
||||
continue
|
||||
}
|
||||
if _, isTop := titleByRule[s.RuleName]; !isTop {
|
||||
v.OtherFindings = append(v.OtherFindings, otherFinding{
|
||||
Severity: severityClass(s.Status),
|
||||
RuleName: s.RuleName,
|
||||
Subject: s.Subject,
|
||||
Message: s.Message,
|
||||
Hint: hintOf(s),
|
||||
})
|
||||
continue
|
||||
}
|
||||
tf := topMap[s.RuleName]
|
||||
if tf == nil {
|
||||
tf = &topFailure{
|
||||
RuleName: s.RuleName,
|
||||
Title: titleByRule[s.RuleName],
|
||||
Severity: severityClass(s.Status),
|
||||
Hint: hintOf(s),
|
||||
Subject: s.Subject,
|
||||
}
|
||||
topMap[s.RuleName] = tf
|
||||
}
|
||||
tf.Messages = append(tf.Messages, s.Message)
|
||||
if tf.Hint == "" {
|
||||
tf.Hint = hintOf(s)
|
||||
}
|
||||
if statusRank(s.Status) > severityRankClass(tf.Severity) {
|
||||
tf.Severity = severityClass(s.Status)
|
||||
}
|
||||
}
|
||||
ruleNames := make([]string, 0, len(topMap))
|
||||
for n := range topMap {
|
||||
ruleNames = append(ruleNames, n)
|
||||
}
|
||||
sort.Slice(ruleNames, func(i, j int) bool { return order[ruleNames[i]] < order[ruleNames[j]] })
|
||||
for _, n := range ruleNames {
|
||||
v.TopFailures = append(v.TopFailures, *topMap[n])
|
||||
}
|
||||
} else {
|
||||
v.OverallStatus = "info"
|
||||
v.OverallText = "Rule output not provided"
|
||||
v.OverallClass = "status-info"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func buildBanner(d *DNSSECData) bannerView {
|
||||
algos := map[uint8]bool{}
|
||||
count := 0
|
||||
for _, k := range allDNSKEYs(d) {
|
||||
algos[k.Algorithm] = true
|
||||
count++
|
||||
}
|
||||
algoList := make([]string, 0, len(algos))
|
||||
for a := range algos {
|
||||
algoList = append(algoList, fmt.Sprintf("%d (%s)", a, dns.AlgorithmToString[a]))
|
||||
}
|
||||
sort.Strings(algoList)
|
||||
|
||||
b := bannerView{
|
||||
Algorithms: strings.Join(algoList, ", "),
|
||||
DenialKind: string(majorityDenialKind(d)),
|
||||
DNSKEYCount: count,
|
||||
}
|
||||
if b.Algorithms == "" {
|
||||
b.Algorithms = "—"
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Unix()
|
||||
var nearest int64 = 1 << 30
|
||||
found := false
|
||||
for _, name := range sortedServers(d) {
|
||||
v := d.Servers[name]
|
||||
for _, sig := range v.AllRRSIGs() {
|
||||
diff := int64(int32(sig.Expiration - uint32(now)))
|
||||
if !found || diff < nearest {
|
||||
nearest = diff
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if found {
|
||||
b.HasNearestExpiry = true
|
||||
days := nearest / 86400
|
||||
switch {
|
||||
case nearest < 0:
|
||||
b.NearestExpiryDays = "EXPIRED"
|
||||
b.NearestExpiryClass = "crit"
|
||||
case days < int64(defaultSignatureFreshnessCrit):
|
||||
b.NearestExpiryDays = fmt.Sprintf("%dh", nearest/3600)
|
||||
b.NearestExpiryClass = "crit"
|
||||
case days < int64(defaultSignatureFreshnessDays):
|
||||
b.NearestExpiryDays = fmt.Sprintf("%dd", days)
|
||||
b.NearestExpiryClass = "warn"
|
||||
default:
|
||||
b.NearestExpiryDays = fmt.Sprintf("%dd", days)
|
||||
b.NearestExpiryClass = "ok"
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func buildEnum(d *DNSSECData) enumView {
|
||||
kind := majorityDenialKind(d)
|
||||
param := firstNSEC3Param(d)
|
||||
|
||||
e := enumView{
|
||||
Kind: string(kind),
|
||||
KindClass: enumKindClass(kind),
|
||||
}
|
||||
switch kind {
|
||||
case DenialNSEC:
|
||||
e.WalkableWarning = true
|
||||
e.Verdict = "Zone is enumerable"
|
||||
e.VerdictClass = "warn"
|
||||
e.Explanation = "NSEC publishes a sorted, signed list of every name in the zone; an attacker can iterate it (`zone walking`) and recover every label. RFC 7129 lays out the details. Migrate to NSEC3 with iterations=0 and an empty salt (RFC 9276)."
|
||||
case DenialNSEC3:
|
||||
e.HasNSEC3Param = param != nil
|
||||
if param != nil {
|
||||
e.Iterations = param.Iterations
|
||||
e.SaltLength = param.SaltLength
|
||||
e.Salt = param.Salt
|
||||
e.OptOut = param.Flags&0x01 != 0
|
||||
compliant := param.Iterations == 0 && param.SaltLength == 0
|
||||
e.RFC9276Compliant = compliant
|
||||
if compliant {
|
||||
e.Verdict = "RFC 9276 compliant"
|
||||
e.VerdictClass = "ok"
|
||||
e.Explanation = "NSEC3 with iterations=0 and an empty salt is the modern recommendation: it gives some opacity against casual enumeration without burning resolver CPU."
|
||||
} else {
|
||||
e.Verdict = "NSEC3 in use, but not RFC 9276 compliant"
|
||||
e.VerdictClass = "warn"
|
||||
var issues []string
|
||||
if param.Iterations > 0 {
|
||||
issues = append(issues, fmt.Sprintf("iterations=%d (recommended 0)", param.Iterations))
|
||||
}
|
||||
if param.SaltLength > 0 {
|
||||
issues = append(issues, fmt.Sprintf("salt length=%d (recommended 0)", param.SaltLength))
|
||||
}
|
||||
e.Explanation = fmt.Sprintf("RFC 9276 §3.1: %s. Modern resolvers may treat answers with iterations>0 as insecure or bogus.", strings.Join(issues, "; "))
|
||||
}
|
||||
} else {
|
||||
e.Verdict = "NSEC3 in use"
|
||||
e.VerdictClass = "info"
|
||||
e.Explanation = "Negative answers are protected by NSEC3 hashing. NSEC3PARAM was not observed; rules cannot fully verify RFC 9276 compliance."
|
||||
}
|
||||
case DenialOptOut:
|
||||
e.HasNSEC3Param = param != nil
|
||||
if param != nil {
|
||||
e.Iterations = param.Iterations
|
||||
e.SaltLength = param.SaltLength
|
||||
e.Salt = param.Salt
|
||||
e.OptOut = true
|
||||
}
|
||||
e.Verdict = "NSEC3 with OPT-OUT"
|
||||
e.VerdictClass = "info"
|
||||
e.Explanation = "OPT-OUT skips authenticated denial of existence for unsigned delegations. Appropriate for TLDs/registries; surprising in a leaf zone."
|
||||
default:
|
||||
e.Verdict = "Zone is unsigned"
|
||||
e.VerdictClass = "info"
|
||||
e.Explanation = "No NSEC or NSEC3 records were observed in the NXDOMAIN probe. Either the zone is unsigned, or the probe could not reach the authoritative servers."
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func enumKindClass(k DenialKind) string {
|
||||
switch k {
|
||||
case DenialNSEC:
|
||||
return "kind-nsec"
|
||||
case DenialNSEC3:
|
||||
return "kind-nsec3"
|
||||
case DenialOptOut:
|
||||
return "kind-optout"
|
||||
}
|
||||
return "kind-none"
|
||||
}
|
||||
|
||||
func buildKeys(d *DNSSECData) []keyRow {
|
||||
out := make([]keyRow, 0)
|
||||
for _, k := range allDNSKEYs(d) {
|
||||
role := "ZSK"
|
||||
if k.IsKSK {
|
||||
role = "KSK"
|
||||
}
|
||||
size := "—"
|
||||
if k.KeySize > 0 {
|
||||
size = fmt.Sprintf("%d bits", k.KeySize)
|
||||
}
|
||||
out = append(out, keyRow{
|
||||
KeyTag: k.KeyTag,
|
||||
Algorithm: fmt.Sprintf("%d (%s)", k.Algorithm, dns.AlgorithmToString[k.Algorithm]),
|
||||
Flags: fmt.Sprintf("%d", k.Flags),
|
||||
Size: size,
|
||||
Role: role,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildSignatures(d *DNSSECData) []sigRow {
|
||||
now := time.Now().UTC().Unix()
|
||||
out := make([]sigRow, 0)
|
||||
for _, name := range sortedServers(d) {
|
||||
v := d.Servers[name]
|
||||
for _, s := range v.AllRRSIGs() {
|
||||
incTime := time.Unix(int64(s.Inception), 0).UTC()
|
||||
expTime := time.Unix(int64(s.Expiration), 0).UTC()
|
||||
remaining := int64(int32(s.Expiration - uint32(now)))
|
||||
lifetime := int64(int32(s.Expiration - s.Inception))
|
||||
percent := 0
|
||||
if lifetime > 0 && remaining > 0 {
|
||||
percent = int(remaining * 100 / lifetime)
|
||||
}
|
||||
class := "ok"
|
||||
switch {
|
||||
case remaining < 0:
|
||||
class = "crit"
|
||||
percent = 0
|
||||
case remaining < int64(defaultSignatureFreshnessCrit)*86400:
|
||||
class = "crit"
|
||||
case remaining < int64(defaultSignatureFreshnessDays)*86400:
|
||||
class = "warn"
|
||||
}
|
||||
row := sigRow{
|
||||
Server: name,
|
||||
TypeCovered: dns.TypeToString[s.TypeCovered],
|
||||
KeyTag: s.KeyTag,
|
||||
Algorithm: fmt.Sprintf("%d", s.Algorithm),
|
||||
Inception: incTime.Format(time.RFC3339),
|
||||
Expiration: expTime.Format(time.RFC3339),
|
||||
BarPercent: percent,
|
||||
BarClass: class,
|
||||
}
|
||||
switch {
|
||||
case remaining < 0:
|
||||
row.Remaining = "expired"
|
||||
case remaining < 86400:
|
||||
row.Remaining = fmt.Sprintf("%dh left", remaining/3600)
|
||||
default:
|
||||
row.Remaining = fmt.Sprintf("%dd left", remaining/86400)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildServers(d *DNSSECData) []serverView {
|
||||
out := make([]serverView, 0, len(d.Servers))
|
||||
for _, name := range sortedServers(d) {
|
||||
v := d.Servers[name]
|
||||
row := serverView{
|
||||
Server: name,
|
||||
UDPError: v.UDPError,
|
||||
TCPError: v.TCPError,
|
||||
DNSKEYCount: len(v.DNSKEYs),
|
||||
DenialKind: string(v.DenialKind),
|
||||
ProbeName: v.ProbeName,
|
||||
DenialDump: v.DenialRecords,
|
||||
}
|
||||
if v.NSEC3PARAM != nil {
|
||||
optOut := ""
|
||||
if v.NSEC3PARAM.Flags&0x01 != 0 {
|
||||
optOut = " OPT-OUT"
|
||||
}
|
||||
row.NSEC3Summary = fmt.Sprintf("hash=%d, iter=%d, salt-len=%d%s",
|
||||
v.NSEC3PARAM.HashAlgorithm, v.NSEC3PARAM.Iterations, v.NSEC3PARAM.SaltLength, optOut)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func worstStatus(states []sdk.CheckState) sdk.Status {
|
||||
worst := sdk.StatusOK
|
||||
for _, s := range states {
|
||||
if statusRank(s.Status) > statusRank(worst) {
|
||||
worst = s.Status
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
func statusLabel(s sdk.Status) (status, text, class string) {
|
||||
switch s {
|
||||
case sdk.StatusCrit:
|
||||
return "crit", "Critical issues detected", "status-crit"
|
||||
case sdk.StatusError:
|
||||
return "error", "Checker error", "status-crit"
|
||||
case sdk.StatusWarn:
|
||||
return "warn", "Warnings detected", "status-warn"
|
||||
case sdk.StatusInfo:
|
||||
return "info", "Informational notes", "status-info"
|
||||
default:
|
||||
return "ok", "DNSSEC hygiene looks good", "status-ok"
|
||||
}
|
||||
}
|
||||
|
||||
func severityClass(s sdk.Status) string {
|
||||
switch s {
|
||||
case sdk.StatusCrit, sdk.StatusError:
|
||||
return "crit"
|
||||
case sdk.StatusWarn:
|
||||
return "warn"
|
||||
case sdk.StatusInfo:
|
||||
return "info"
|
||||
default:
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
|
||||
func statusRank(s sdk.Status) int {
|
||||
switch s {
|
||||
case sdk.StatusError, sdk.StatusCrit:
|
||||
return 4
|
||||
case sdk.StatusWarn:
|
||||
return 3
|
||||
case sdk.StatusInfo:
|
||||
return 2
|
||||
case sdk.StatusOK:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func severityRankClass(c string) int {
|
||||
switch c {
|
||||
case "crit":
|
||||
return 4
|
||||
case "warn":
|
||||
return 3
|
||||
case "info":
|
||||
return 2
|
||||
case "ok":
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func hintOf(s sdk.CheckState) string {
|
||||
if s.Meta == nil {
|
||||
return ""
|
||||
}
|
||||
h, _ := s.Meta[hintKey].(string)
|
||||
return h
|
||||
}
|
||||
|
||||
var reportTmpl = template.Must(template.New("dnssec-report").Parse(reportTemplate))
|
||||
|
||||
const reportTemplate = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DNSSEC report — {{.Domain}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--ok: #2e7d32;
|
||||
--info: #0277bd;
|
||||
--warn: #ef6c00;
|
||||
--crit: #c62828;
|
||||
--error: #6a1b9a;
|
||||
--bg: #f7f7f8;
|
||||
--card: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 1.2rem; max-width: 100%; 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.6rem 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); }
|
||||
code { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; word-break: break-all; }
|
||||
.status-banner { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .5rem; 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: .92; font-size: .85rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(220px,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: .75rem; 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; margin-top: .35rem; }
|
||||
.top-failure .fix strong { display: block; color: var(--text); margin-bottom: .15rem; }
|
||||
.sev { display: inline-block; padding: .08rem .4rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; text-transform: uppercase; }
|
||||
.sev-ok { background: var(--ok); }
|
||||
.sev-info { background: var(--info); }
|
||||
.sev-warn { background: var(--warn); }
|
||||
.sev-crit { background: var(--crit); }
|
||||
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); vertical-align: top; }
|
||||
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; }
|
||||
details { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .8rem; }
|
||||
details pre { max-height: 360px; overflow: auto; font-size: .78rem; }
|
||||
.enum-card { padding: .9rem 1rem; border-radius: 8px; margin-bottom: 1rem; border: 1px solid var(--border); background: var(--card); }
|
||||
.enum-card.kind-nsec { border-left: 6px solid var(--warn); background: #fff7ed; }
|
||||
.enum-card.kind-nsec3 { border-left: 6px solid var(--ok); background: #ecfdf5; }
|
||||
.enum-card.kind-optout { border-left: 6px solid var(--info); background: #eff6ff; }
|
||||
.enum-card.kind-none { border-left: 6px solid var(--muted); }
|
||||
.enum-verdict { font-weight: 600; margin-bottom: .25rem; }
|
||||
.enum-verdict.ok { color: var(--ok); }
|
||||
.enum-verdict.warn { color: var(--warn); }
|
||||
.enum-verdict.crit { color: var(--crit); }
|
||||
.enum-verdict.info { color: var(--info); }
|
||||
.enum-params { margin-top: .5rem; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: .85rem; }
|
||||
.bar { display: inline-block; vertical-align: middle; width: 80px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; margin-right: .35rem; }
|
||||
.bar > i { display: block; height: 100%; }
|
||||
.bar-ok > i { background: var(--ok); }
|
||||
.bar-warn > i { background: var(--warn); }
|
||||
.bar-crit > i { background: var(--crit); }
|
||||
.badge { display: inline-block; background: #e5e7eb; padding: .05rem .4rem; border-radius: 4px; font-size: .75rem; }
|
||||
.badge.on { background: #dcfce7; color: #14532d; }
|
||||
.badge.off { background: #fee2e2; color: #7f1d1d; }
|
||||
.servers { display: flex; flex-direction: column; gap: .5rem; }
|
||||
.server-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .7rem .9rem; font-size: .88rem; }
|
||||
.server-card .head { display: flex; justify-content: space-between; flex-wrap: wrap; gap: .35rem; align-items: baseline; }
|
||||
.server-card .err { color: var(--crit); font-size: .82rem; margin-top: .25rem; }
|
||||
.err-list { color: var(--crit); margin: .5rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="status-banner {{.OverallClass}}">
|
||||
<div>
|
||||
<div class="label">{{.OverallText}}</div>
|
||||
<div class="sub">for <code>{{.Domain}}</code>{{if .CollectedAt}} · collected {{.CollectedAt}}{{end}}</div>
|
||||
</div>
|
||||
<div class="sub">
|
||||
{{.Banner.DNSKEYCount}} DNSKEY · denial: <strong>{{.Banner.DenialKind}}</strong>
|
||||
{{if .Banner.HasNearestExpiry}} · next RRSIG expiry: <strong>{{.Banner.NearestExpiryDays}}</strong>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .GlobalErrors}}
|
||||
<div class="card err-list">
|
||||
<strong>Collection errors:</strong>
|
||||
<ul>{{range .GlobalErrors}}<li>{{.}}</li>{{end}}</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="grid">
|
||||
<div class="card"><div class="k">Zone</div><div class="v">{{.Domain}}</div></div>
|
||||
<div class="card"><div class="k">Algorithms</div><div class="v">{{.Banner.Algorithms}}</div></div>
|
||||
<div class="card"><div class="k">DNSKEY count</div><div class="v">{{.Banner.DNSKEYCount}}</div></div>
|
||||
<div class="card"><div class="k">Denial scheme</div><div class="v">{{.Banner.DenialKind}}</div></div>
|
||||
<div class="card"><div class="k">Authoritative NS</div><div class="v">{{range .NameServers}}{{.}}<br>{{else}}—{{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}}
|
||||
|
||||
<h2>Enumerability</h2>
|
||||
<div class="enum-card {{.Enumerability.KindClass}}">
|
||||
<div class="enum-verdict {{.Enumerability.VerdictClass}}">
|
||||
{{.Enumerability.Verdict}} <span class="badge">scheme: {{.Enumerability.Kind}}</span>
|
||||
</div>
|
||||
<div>{{.Enumerability.Explanation}}</div>
|
||||
{{if .Enumerability.HasNSEC3Param}}
|
||||
<div class="enum-params">
|
||||
iterations = <strong>{{.Enumerability.Iterations}}</strong>{{if eq .Enumerability.Iterations 0}} <span class="badge on">RFC 9276 ✓</span>{{else}} <span class="badge off">> 0</span>{{end}}
|
||||
· salt length = <strong>{{.Enumerability.SaltLength}}</strong>{{if eq .Enumerability.SaltLength 0}} <span class="badge on">empty ✓</span>{{else}} <span class="badge off">{{.Enumerability.Salt}}</span>{{end}}
|
||||
· OPT-OUT: {{if .Enumerability.OptOut}}<span class="badge off">on</span>{{else}}<span class="badge on">off</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Keys}}
|
||||
<h2>DNSKEYs</h2>
|
||||
<table>
|
||||
<thead><tr><th>KeyTag</th><th>Role</th><th>Algorithm</th><th>Flags</th><th>Size</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Keys}}
|
||||
<tr>
|
||||
<td><code>{{.KeyTag}}</code></td>
|
||||
<td>{{.Role}}</td>
|
||||
<td>{{.Algorithm}}</td>
|
||||
<td>{{.Flags}}</td>
|
||||
<td>{{.Size}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{if .Signatures}}
|
||||
<h2>RRSIGs</h2>
|
||||
<table>
|
||||
<thead><tr><th>Server</th><th>Covers</th><th>KeyTag</th><th>Inception</th><th>Expiration</th><th>Validity</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Signatures}}
|
||||
<tr>
|
||||
<td><code>{{.Server}}</code></td>
|
||||
<td>{{.TypeCovered}}</td>
|
||||
<td><code>{{.KeyTag}}</code></td>
|
||||
<td><code>{{.Inception}}</code></td>
|
||||
<td><code>{{.Expiration}}</code></td>
|
||||
<td><span class="bar bar-{{.BarClass}}"><i style="width:{{.BarPercent}}%"></i></span> {{.Remaining}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{if .PerServer}}
|
||||
<h2>Per-server view</h2>
|
||||
<div class="servers">
|
||||
{{range .PerServer}}
|
||||
<div class="server-card">
|
||||
<div class="head">
|
||||
<strong><code>{{.Server}}</code></strong>
|
||||
<span>
|
||||
<span class="badge">{{.DNSKEYCount}} DNSKEY</span>
|
||||
<span class="badge">denial: {{.DenialKind}}</span>
|
||||
</span>
|
||||
</div>
|
||||
{{if .NSEC3Summary}}<div class="muted">NSEC3PARAM: <code>{{.NSEC3Summary}}</code></div>{{end}}
|
||||
{{if .ProbeName}}<div class="muted">NXDOMAIN probe: <code>{{.ProbeName}}</code></div>{{end}}
|
||||
{{if .UDPError}}<div class="err">UDP error: {{.UDPError}}</div>{{end}}
|
||||
{{if .TCPError}}<div class="err">TCP error: {{.TCPError}}</div>{{end}}
|
||||
{{if .DenialDump}}
|
||||
<details><summary>Denial proof records</summary><pre>{{range .DenialDump}}{{.}}
|
||||
{{end}}</pre></details>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .OtherFindings}}
|
||||
<h2>Additional findings</h2>
|
||||
<table>
|
||||
<thead><tr><th>Severity</th><th>Rule</th><th>Subject</th><th>Message</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .OtherFindings}}
|
||||
<tr>
|
||||
<td><span class="sev sev-{{.Severity}}">{{.Severity}}</span></td>
|
||||
<td><code>{{.RuleName}}</code></td>
|
||||
<td><code>{{.Subject}}</code></td>
|
||||
<td>{{.Message}}{{if .Hint}}<br><span class="muted">{{.Hint}}</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{if .RawJSON}}
|
||||
<h2>Raw observation</h2>
|
||||
<details><summary>Show JSON</summary><pre>{{.RawJSON}}</pre></details>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
Loading…
Add table
Add a link
Reference in a new issue