237 lines
6.8 KiB
Go
237 lines
6.8 KiB
Go
package checker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
// 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(),
|
|
}
|
|
}
|
|
|
|
// ── 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 {
|
|
return fmt.Errorf("zonemasterAPIURL must be a string")
|
|
}
|
|
if s != "" {
|
|
u, err := url.Parse(s)
|
|
if err != nil {
|
|
return fmt.Errorf("zonemasterAPIURL: %w", err)
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return fmt.Errorf("zonemasterAPIURL must use http or https scheme")
|
|
}
|
|
if u.Host == "" {
|
|
return fmt.Errorf("zonemasterAPIURL must include a host")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 nil, &sdk.CheckState{
|
|
Status: sdk.StatusError,
|
|
Message: fmt.Sprintf("failed to load Zonemaster observation: %v", err),
|
|
Code: "zonemaster.observation_error",
|
|
}
|
|
}
|
|
return &data, nil
|
|
}
|
|
|
|
// normLevel returns the canonical (upper-case) form of a Zonemaster severity
|
|
// string. Use this anywhere a severity needs to be compared, looked up or
|
|
// keyed so the canonical list stays in one place.
|
|
func normLevel(level string) string {
|
|
return strings.ToUpper(level)
|
|
}
|
|
|
|
// 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 normLevel(level) {
|
|
case "CRITICAL", "ERROR":
|
|
return sdk.StatusCrit
|
|
case "WARNING":
|
|
return sdk.StatusWarn
|
|
case "NOTICE", "INFO", "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) ValidateOptions(opts sdk.CheckerOptions) error {
|
|
return validateZonemasterOptions(opts)
|
|
}
|
|
|
|
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 := normLevel(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
|
|
}
|