checker: split monolithic rule into per-concern rules
This commit is contained in:
parent
463e3fb457
commit
181c5961f1
10 changed files with 338 additions and 20 deletions
240
checker/rule.go
240
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,
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
checker/rules_address.go
Normal file
13
checker/rules_address.go
Normal 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
14
checker/rules_basic.go
Normal 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"},
|
||||
}
|
||||
}
|
||||
13
checker/rules_connectivity.go
Normal file
13
checker/rules_connectivity.go
Normal 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"},
|
||||
}
|
||||
}
|
||||
13
checker/rules_consistency.go
Normal file
13
checker/rules_consistency.go
Normal 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"},
|
||||
}
|
||||
}
|
||||
13
checker/rules_delegation.go
Normal file
13
checker/rules_delegation.go
Normal 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
13
checker/rules_dnssec.go
Normal 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"},
|
||||
}
|
||||
}
|
||||
13
checker/rules_nameserver.go
Normal file
13
checker/rules_nameserver.go
Normal 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
13
checker/rules_syntax.go
Normal 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
13
checker/rules_zone.go
Normal 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"},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue