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"
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rule returns a new zonemaster check rule.
|
// Rules returns the full list of CheckRules exposed by the Zonemaster checker.
|
||||||
func Rule() sdk.CheckRule {
|
// Each rule narrows the Zonemaster results to a single test category so
|
||||||
return &zonemasterRule{}
|
// 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 {
|
func (r *legacyRule) Name() string { return "zonemaster" }
|
||||||
return "Runs Zonemaster DNS validation tests against the zone"
|
|
||||||
|
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 {
|
if v, ok := opts["zonemasterAPIURL"]; ok {
|
||||||
s, ok := v.(string)
|
s, ok := v.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -44,16 +82,180 @@ func (r *zonemasterRule) ValidateOptions(opts sdk.CheckerOptions) error {
|
||||||
return nil
|
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
|
var data ZonemasterData
|
||||||
if err := obs.Get(ctx, ObservationKeyZonemaster, &data); err != nil {
|
if err := obs.Get(ctx, ObservationKeyZonemaster, &data); err != nil {
|
||||||
return []sdk.CheckState{{
|
return nil, &sdk.CheckState{
|
||||||
Status: sdk.StatusError,
|
Status: sdk.StatusError,
|
||||||
Message: fmt.Sprintf("Failed to get Zonemaster data: %v", err),
|
Message: fmt.Sprintf("failed to load Zonemaster observation: %v", err),
|
||||||
Code: "zonemaster_error",
|
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 errorCount, warningCount int
|
||||||
var criticalMsgs []string
|
var criticalMsgs []string
|
||||||
|
|
||||||
|
|
@ -86,27 +288,25 @@ func (r *zonemasterRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter
|
||||||
}
|
}
|
||||||
statusLine += ": " + strings.Join(criticalMsgs[:n], "; ")
|
statusLine += ": " + strings.Join(criticalMsgs[:n], "; ")
|
||||||
}
|
}
|
||||||
return []sdk.CheckState{{
|
return sdk.CheckState{
|
||||||
Status: sdk.StatusCrit,
|
Status: sdk.StatusCrit,
|
||||||
Message: statusLine,
|
Message: statusLine,
|
||||||
Code: "zonemaster_errors",
|
Code: "zonemaster_errors",
|
||||||
Meta: meta,
|
Meta: meta,
|
||||||
}}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if warningCount > 0 {
|
if warningCount > 0 {
|
||||||
return []sdk.CheckState{{
|
return sdk.CheckState{
|
||||||
Status: sdk.StatusWarn,
|
Status: sdk.StatusWarn,
|
||||||
Message: fmt.Sprintf("%d warning(s) found", warningCount),
|
Message: fmt.Sprintf("%d warning(s) found", warningCount),
|
||||||
Code: "zonemaster_warnings",
|
Code: "zonemaster_warnings",
|
||||||
Meta: meta,
|
Meta: meta,
|
||||||
}}
|
}
|
||||||
}
|
}
|
||||||
|
return sdk.CheckState{
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusOK,
|
Status: sdk.StatusOK,
|
||||||
Message: fmt.Sprintf("All checks passed (%d checks)", len(data.Results)),
|
Message: fmt.Sprintf("All checks passed (%d checks)", len(data.Results)),
|
||||||
Code: "zonemaster_ok",
|
Code: "zonemaster_ok",
|
||||||
Meta: meta,
|
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