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
}
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
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 = `
DNSSEC report — {{.Domain}}
{{.OverallText}}
for {{.Domain}}{{if .CollectedAt}} · collected {{.CollectedAt}}{{end}}
{{.Banner.DNSKEYCount}} DNSKEY · denial: {{.Banner.DenialKind}}
{{if .Banner.HasNearestExpiry}} · next RRSIG expiry: {{.Banner.NearestExpiryDays}}{{end}}
{{if .GlobalErrors}}
Collection errors:
{{range .GlobalErrors}}- {{.}}
{{end}}
{{end}}
Algorithms
{{.Banner.Algorithms}}
DNSKEY count
{{.Banner.DNSKEYCount}}
Denial scheme
{{.Banner.DenialKind}}
Authoritative NS
{{range .NameServers}}{{.}}
{{else}}—{{end}}
{{if .TopFailures}}
Fix these first
{{range .TopFailures}}
{{.Title}} {{.Severity}}
{{range .Messages}}- {{.}}
{{end}}
{{if .Hint}}
How to fix{{.Hint}}
{{end}}
{{end}}
{{end}}
Enumerability
{{.Enumerability.Verdict}} scheme: {{.Enumerability.Kind}}
{{.Enumerability.Explanation}}
{{if .Enumerability.HasNSEC3Param}}
iterations = {{.Enumerability.Iterations}}{{if eq .Enumerability.Iterations 0}} RFC 9276 ✓{{else}} > 0{{end}}
· salt length = {{.Enumerability.SaltLength}}{{if eq .Enumerability.SaltLength 0}} empty ✓{{else}} {{.Enumerability.Salt}}{{end}}
· OPT-OUT: {{if .Enumerability.OptOut}}on{{else}}off{{end}}
{{end}}
{{if .Keys}}
DNSKEYs
| KeyTag | Role | Algorithm | Flags | Size |
{{range .Keys}}
{{.KeyTag}} |
{{.Role}} |
{{.Algorithm}} |
{{.Flags}} |
{{.Size}} |
{{end}}
{{end}}
{{if .Signatures}}
RRSIGs
| Server | Covers | KeyTag | Inception | Expiration | Validity |
{{range .Signatures}}
{{.Server}} |
{{.TypeCovered}} |
{{.KeyTag}} |
{{.Inception}} |
{{.Expiration}} |
{{.Remaining}} |
{{end}}
{{end}}
{{if .PerServer}}
Per-server view
{{range .PerServer}}
{{.Server}}
{{.DNSKEYCount}} DNSKEY
denial: {{.DenialKind}}
{{if .NSEC3Summary}}
NSEC3PARAM: {{.NSEC3Summary}}
{{end}}
{{if .ProbeName}}
NXDOMAIN probe: {{.ProbeName}}
{{end}}
{{if .UDPError}}
UDP error: {{.UDPError}}
{{end}}
{{if .TCPError}}
TCP error: {{.TCPError}}
{{end}}
{{if .DenialDump}}
Denial proof records
{{range .DenialDump}}{{.}}
{{end}}
{{end}}
{{end}}
{{end}}
{{if .OtherFindings}}
Additional findings
| Severity | Rule | Subject | Message |
{{range .OtherFindings}}
| {{.Severity}} |
{{.RuleName}} |
{{.Subject}} |
{{.Message}}{{if .Hint}} {{.Hint}}{{end}} |
{{end}}
{{end}}
`