checker: split monolithic rule into per-concern rules

This commit is contained in:
nemunaire 2026-04-26 16:53:57 +07:00
commit 181c5961f1
10 changed files with 338 additions and 20 deletions

View file

@ -9,20 +9,58 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule returns a new zonemaster check rule.
func Rule() sdk.CheckRule {
return &zonemasterRule{}
// Rules returns the full list of CheckRules exposed by the Zonemaster checker.
// Each rule narrows the Zonemaster results to a single test category so
// callers can see at a glance which category passed and which did not,
// instead of squashing every Zonemaster message into a single monolithic
// state. The Zonemaster-returned severity (INFO/NOTICE/WARNING/ERROR/
// CRITICAL) is treated as a raw input coming from Zonemaster's own
// judgement; each rule maps it onto happyDomain's CheckState.Status.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
dnssecRule(),
delegationRule(),
consistencyRule(),
connectivityRule(),
nameserverRule(),
syntaxRule(),
zoneRule(),
addressRule(),
basicRule(),
}
}
type zonemasterRule struct{}
// Rule returns the legacy single-rule view of the Zonemaster checker.
//
// Deprecated: use Rules() for per-category CheckRules. This wrapper is kept
// so existing callers that only expect a single rule keep compiling.
func Rule() sdk.CheckRule { return &legacyRule{} }
func (r *zonemasterRule) Name() string { return "zonemaster" }
type legacyRule struct{}
func (r *zonemasterRule) Description() string {
return "Runs Zonemaster DNS validation tests against the zone"
func (r *legacyRule) Name() string { return "zonemaster" }
func (r *legacyRule) Description() string {
return "Runs Zonemaster DNS validation tests against the zone (aggregate view)."
}
func (r *zonemasterRule) ValidateOptions(opts sdk.CheckerOptions) error {
func (r *legacyRule) ValidateOptions(opts sdk.CheckerOptions) error {
return validateZonemasterOptions(opts)
}
func (r *legacyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadZonemasterData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
return []sdk.CheckState{summarizeAll(data)}
}
// ── shared helpers ────────────────────────────────────────────────────────────
// validateZonemasterOptions validates the options accepted by the Zonemaster
// checker. Shared across rules that implement OptionsValidator.
func validateZonemasterOptions(opts sdk.CheckerOptions) error {
if v, ok := opts["zonemasterAPIURL"]; ok {
s, ok := v.(string)
if !ok {
@ -44,16 +82,180 @@ func (r *zonemasterRule) ValidateOptions(opts sdk.CheckerOptions) error {
return nil
}
func (r *zonemasterRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
// loadZonemasterData fetches the Zonemaster observation. On error, returns a
// CheckState the caller should emit to short-circuit its rule.
func loadZonemasterData(ctx context.Context, obs sdk.ObservationGetter) (*ZonemasterData, *sdk.CheckState) {
var data ZonemasterData
if err := obs.Get(ctx, ObservationKeyZonemaster, &data); err != nil {
return []sdk.CheckState{{
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to get Zonemaster data: %v", err),
Code: "zonemaster_error",
Message: fmt.Sprintf("failed to load Zonemaster observation: %v", err),
Code: "zonemaster.observation_error",
}
}
return &data, nil
}
// levelToStatus maps a Zonemaster-returned severity to happyDomain's status.
// Zonemaster's own judgement is treated as raw input; this is happyDomain's
// own mapping onto the SDK status enum.
func levelToStatus(level string) sdk.Status {
switch strings.ToUpper(level) {
case "CRITICAL", "ERROR":
return sdk.StatusCrit
case "WARNING":
return sdk.StatusWarn
case "NOTICE", "INFO":
return sdk.StatusInfo
case "DEBUG":
return sdk.StatusInfo
default:
return sdk.StatusUnknown
}
}
// worstStatus returns the more severe of two statuses. StatusError always
// wins because it means "we could not evaluate".
func worstStatus(a, b sdk.Status) sdk.Status {
rank := func(s sdk.Status) int {
switch s {
case sdk.StatusError:
return 6
case sdk.StatusCrit:
return 5
case sdk.StatusWarn:
return 4
case sdk.StatusInfo:
return 2
case sdk.StatusOK:
return 1
default:
return 0
}
}
if rank(a) >= rank(b) {
return a
}
return b
}
// categoryRule is the common shape used by every per-category Zonemaster
// rule: load the observation, filter messages whose module matches one of
// the declared names, map Zonemaster severities onto CheckState.Status,
// and emit a summary state plus one state per WARNING-or-worse message.
// INFO/NOTICE messages are folded into the summary counts so the state
// list stays readable.
type categoryRule struct {
name string
description string
modules []string // case-insensitive module names handled by this rule
}
func (r *categoryRule) Name() string { return r.name }
func (r *categoryRule) Description() string { return r.description }
func (r *categoryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadZonemasterData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
matched := filterByModules(data.Results, r.modules)
if len(matched) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Message: fmt.Sprintf("No %s messages returned by Zonemaster for this zone.", r.name),
Code: r.name + ".not_tested",
}}
}
var (
critCount, errCount, warnCount, noticeCount, infoCount int
worst = sdk.StatusOK
issueStates []sdk.CheckState
)
for _, res := range matched {
lvl := strings.ToUpper(res.Level)
st := levelToStatus(lvl)
worst = worstStatus(worst, st)
switch lvl {
case "CRITICAL":
critCount++
case "ERROR":
errCount++
case "WARNING":
warnCount++
case "NOTICE":
noticeCount++
default:
infoCount++
}
if st == sdk.StatusCrit || st == sdk.StatusWarn {
issueStates = append(issueStates, sdk.CheckState{
Status: st,
Message: res.Message,
Code: r.name + "." + strings.ToLower(lvl),
Subject: res.Testcase,
Meta: map[string]any{
"module": res.Module,
"testcase": res.Testcase,
"level": lvl,
},
})
}
}
summary := sdk.CheckState{
Status: worst,
Code: r.name + ".summary",
Meta: map[string]any{
"total": len(matched),
"critical": critCount,
"error": errCount,
"warning": warnCount,
"notice": noticeCount,
"info": infoCount,
},
}
switch {
case critCount+errCount > 0:
summary.Message = fmt.Sprintf("%d error(s), %d warning(s) reported by Zonemaster (%d checks).", critCount+errCount, warnCount, len(matched))
case warnCount > 0:
summary.Message = fmt.Sprintf("%d warning(s) reported by Zonemaster (%d checks).", warnCount, len(matched))
default:
summary.Status = sdk.StatusOK
summary.Message = fmt.Sprintf("No issues reported by Zonemaster (%d checks).", len(matched))
}
return append([]sdk.CheckState{summary}, issueStates...)
}
// filterByModules returns the subset of results whose Module matches any of
// the given module names (case-insensitive).
func filterByModules(results []ZonemasterTestResult, modules []string) []ZonemasterTestResult {
if len(modules) == 0 {
return nil
}
set := make(map[string]struct{}, len(modules))
for _, m := range modules {
set[strings.ToLower(m)] = struct{}{}
}
var out []ZonemasterTestResult
for _, r := range results {
if _, ok := set[strings.ToLower(r.Module)]; ok {
out = append(out, r)
}
}
return out
}
// summarizeAll produces the legacy monolithic summary state. Preserved so
// Rule() keeps behaving as before for callers that still use it.
func summarizeAll(data *ZonemasterData) sdk.CheckState {
var errorCount, warningCount int
var criticalMsgs []string
@ -86,27 +288,25 @@ func (r *zonemasterRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter
}
statusLine += ": " + strings.Join(criticalMsgs[:n], "; ")
}
return []sdk.CheckState{{
return sdk.CheckState{
Status: sdk.StatusCrit,
Message: statusLine,
Code: "zonemaster_errors",
Meta: meta,
}}
}
}
if warningCount > 0 {
return []sdk.CheckState{{
return sdk.CheckState{
Status: sdk.StatusWarn,
Message: fmt.Sprintf("%d warning(s) found", warningCount),
Code: "zonemaster_warnings",
Meta: meta,
}}
}
}
return []sdk.CheckState{{
return sdk.CheckState{
Status: sdk.StatusOK,
Message: fmt.Sprintf("All checks passed (%d checks)", len(data.Results)),
Code: "zonemaster_ok",
Meta: meta,
}}
}
}

13
checker/rules_address.go Normal file
View file

@ -0,0 +1,13 @@
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// addressRule covers Zonemaster's address test module (IP addresses of
// nameservers, private/reserved ranges, IPv6 coverage).
func addressRule() sdk.CheckRule {
return &categoryRule{
name: "zonemaster.address",
description: "Zonemaster address tests (IP addresses of nameservers, private/reserved ranges).",
modules: []string{"address"},
}
}

14
checker/rules_basic.go Normal file
View file

@ -0,0 +1,14 @@
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// basicRule covers Zonemaster's basic/system test modules (initial
// reachability and fundamental pre-conditions for running the other test
// categories).
func basicRule() sdk.CheckRule {
return &categoryRule{
name: "zonemaster.basic",
description: "Zonemaster basic tests (initial reachability and fundamental requirements).",
modules: []string{"basic", "system"},
}
}

View file

@ -0,0 +1,13 @@
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// connectivityRule covers Zonemaster's connectivity test module (UDP/TCP
// reachability of authoritative servers, AS diversity).
func connectivityRule() sdk.CheckRule {
return &categoryRule{
name: "zonemaster.connectivity",
description: "Zonemaster connectivity tests (reachability of authoritative servers over UDP/TCP, AS diversity).",
modules: []string{"connectivity"},
}
}

View file

@ -0,0 +1,13 @@
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// consistencyRule covers Zonemaster's consistency test module (SOA serial,
// NS set, zone content identical across authoritative servers).
func consistencyRule() sdk.CheckRule {
return &categoryRule{
name: "zonemaster.consistency",
description: "Zonemaster consistency tests (SOA serial, NS set, zone content across servers).",
modules: []string{"consistency"},
}
}

View file

@ -0,0 +1,13 @@
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// delegationRule covers Zonemaster's delegation test module (parent/child NS
// agreement, glue correctness, referral integrity).
func delegationRule() sdk.CheckRule {
return &categoryRule{
name: "zonemaster.delegation",
description: "Zonemaster delegation tests (parent/child NS agreement, glue, referrals).",
modules: []string{"delegation"},
}
}

13
checker/rules_dnssec.go Normal file
View file

@ -0,0 +1,13 @@
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// dnssecRule covers Zonemaster's DNSSEC test module (signatures, NSEC/NSEC3,
// DS/DNSKEY coherence, algorithm posture).
func dnssecRule() sdk.CheckRule {
return &categoryRule{
name: "zonemaster.dnssec",
description: "Zonemaster DNSSEC tests (signatures, NSEC/NSEC3, DS/DNSKEY coherence).",
modules: []string{"dnssec"},
}
}

View file

@ -0,0 +1,13 @@
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// nameserverRule covers Zonemaster's nameserver test module (server
// behaviour, EDNS posture, unknown RR handling).
func nameserverRule() sdk.CheckRule {
return &categoryRule{
name: "zonemaster.nameserver",
description: "Zonemaster nameserver tests (server behaviour, EDNS, unknown RR handling).",
modules: []string{"nameserver"},
}
}

13
checker/rules_syntax.go Normal file
View file

@ -0,0 +1,13 @@
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// syntaxRule covers Zonemaster's syntax test module (domain name syntax,
// hostname legality).
func syntaxRule() sdk.CheckRule {
return &categoryRule{
name: "zonemaster.syntax",
description: "Zonemaster syntax tests (domain name syntax, hostname legality).",
modules: []string{"syntax"},
}
}

13
checker/rules_zone.go Normal file
View file

@ -0,0 +1,13 @@
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// zoneRule covers Zonemaster's zone test module (SOA values, MX presence,
// mandatory records at the apex).
func zoneRule() sdk.CheckRule {
return &categoryRule{
name: "zonemaster.zone",
description: "Zonemaster zone tests (SOA values, MX presence, mandatory records).",
modules: []string{"zone"},
}
}