diff --git a/checker/rule.go b/checker/rule.go index d201dd6..c5c2321 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -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, - }} + } } diff --git a/checker/rules_address.go b/checker/rules_address.go new file mode 100644 index 0000000..8901877 --- /dev/null +++ b/checker/rules_address.go @@ -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"}, + } +} diff --git a/checker/rules_basic.go b/checker/rules_basic.go new file mode 100644 index 0000000..3337a97 --- /dev/null +++ b/checker/rules_basic.go @@ -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"}, + } +} diff --git a/checker/rules_connectivity.go b/checker/rules_connectivity.go new file mode 100644 index 0000000..8808d1a --- /dev/null +++ b/checker/rules_connectivity.go @@ -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"}, + } +} diff --git a/checker/rules_consistency.go b/checker/rules_consistency.go new file mode 100644 index 0000000..24db2b5 --- /dev/null +++ b/checker/rules_consistency.go @@ -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"}, + } +} diff --git a/checker/rules_delegation.go b/checker/rules_delegation.go new file mode 100644 index 0000000..0c6e156 --- /dev/null +++ b/checker/rules_delegation.go @@ -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"}, + } +} diff --git a/checker/rules_dnssec.go b/checker/rules_dnssec.go new file mode 100644 index 0000000..e3b5054 --- /dev/null +++ b/checker/rules_dnssec.go @@ -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"}, + } +} diff --git a/checker/rules_nameserver.go b/checker/rules_nameserver.go new file mode 100644 index 0000000..e7ea6b4 --- /dev/null +++ b/checker/rules_nameserver.go @@ -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"}, + } +} diff --git a/checker/rules_syntax.go b/checker/rules_syntax.go new file mode 100644 index 0000000..3daacad --- /dev/null +++ b/checker/rules_syntax.go @@ -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"}, + } +} diff --git a/checker/rules_zone.go b/checker/rules_zone.go new file mode 100644 index 0000000..1aaaae7 --- /dev/null +++ b/checker/rules_zone.go @@ -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"}, + } +}