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:
{{end}}
Zone
{{.Domain}}
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}}

{{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

{{range .Keys}} {{end}}
KeyTagRoleAlgorithmFlagsSize
{{.KeyTag}} {{.Role}} {{.Algorithm}} {{.Flags}} {{.Size}}
{{end}} {{if .Signatures}}

RRSIGs

{{range .Signatures}} {{end}}
ServerCoversKeyTagInceptionExpirationValidity
{{.Server}} {{.TypeCovered}} {{.KeyTag}} {{.Inception}} {{.Expiration}} {{.Remaining}}
{{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

{{range .OtherFindings}} {{end}}
SeverityRuleSubjectMessage
{{.Severity}} {{.RuleName}} {{.Subject}} {{.Message}}{{if .Hint}}
{{.Hint}}{{end}}
{{end}} `