Compare commits

..

No commits in common. "master" and "v0.1.0" have entirely different histories.

26 changed files with 120 additions and 1003 deletions

View file

@ -1,22 +0,0 @@
image: happydomain/checker-zonemaster:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
{{#if build.tags}}
tags:
{{#each build.tags}}
- {{this}}
{{/each}}
{{/if}}
manifests:
- image: happydomain/checker-zonemaster:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
platform:
architecture: amd64
os: linux
- image: happydomain/checker-zonemaster:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
platform:
architecture: arm64
os: linux
variant: v8
- image: happydomain/checker-zonemaster:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
platform:
architecture: arm
os: linux
variant: v7

View file

@ -1,187 +0,0 @@
---
kind: pipeline
type: docker
name: build-amd64
platform:
os: linux
arch: amd64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-zonemaster
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-zonemaster
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
---
kind: pipeline
type: docker
name: build-arm64
platform:
os: linux
arch: arm64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-zonemaster
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-zonemaster
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
event:
- cron
- push
- tag
---
kind: pipeline
name: docker-manifest
platform:
os: linux
arch: arm64
steps:
- name: publish on Docker Hub
image: plugins/manifest
settings:
auto_tag: true
ignore_missing: true
spec: .drone-manifest.yml
username:
from_secret: docker_username
password:
from_secret: docker_password
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
depends_on:
- build-amd64
- build-arm64

2
.gitignore vendored
View file

@ -1,2 +0,0 @@
checker-zonemaster
checker-zonemaster.so

View file

@ -10,8 +10,5 @@ RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /che
FROM scratch FROM scratch
COPY --from=builder /checker-zonemaster /checker-zonemaster COPY --from=builder /checker-zonemaster /checker-zonemaster
USER 65534:65534
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker-zonemaster", "-healthcheck"]
ENTRYPOINT ["/checker-zonemaster"] ENTRYPOINT ["/checker-zonemaster"]

View file

@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker test clean .PHONY: all plugin docker clean
all: $(CHECKER_NAME) all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES) $(CHECKER_NAME): $(CHECKER_SOURCES)
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . go build -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so plugin: $(CHECKER_NAME).so
@ -21,8 +21,5 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
docker: docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean: clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

View file

@ -55,20 +55,6 @@ the running checker-zonemaster server (e.g.,
`http://checker-zonemaster:8080`). happyDomain will delegate observation `http://checker-zonemaster:8080`). happyDomain will delegate observation
collection to this endpoint. collection to this endpoint.
### Deployment
The `/collect` endpoint has no built-in authentication and will issue
JSON-RPC calls to whatever Zonemaster API URL is configured via the
`zonemasterAPIURL` admin option (defaulting to the official public API
at `https://zonemaster.net/api`). Operators should point this option
only at trusted Zonemaster instances; pointing it at an untrusted host
turns the checker into an SSRF vector, since responses are parsed and
surfaced back to the caller. The checker itself is meant to run on a
trusted network, reachable only by the happyDomain instance that drives
it. Restrict access via a reverse proxy with authentication, a network
ACL, or by binding the listener to a private interface; do not expose
it directly to the public internet.
## Options ## Options
| Scope | Id | Description | | Scope | Id | Description |
@ -78,21 +64,29 @@ it directly to the public internet.
| User | `language` | Result language (`en`, `fr`, `de`, …) | | User | `language` | Result language (`en`, `fr`, `de`, …) |
| Admin | `zonemasterAPIURL` | Zonemaster JSON-RPC endpoint (default: official API) | | Admin | `zonemasterAPIURL` | Zonemaster JSON-RPC endpoint (default: official API) |
## Rules ## Protocol
Each rule wraps one Zonemaster test module and emits a `<rule>.summary` ### POST /collect
state plus one `<rule>.<level>` state per WARNING-or-worse Zonemaster
message, so downstream consumers can match on stable codes.
| Code | Description | Severity | Request:
|---------------------------|-----------------------------------------------------------------------------------|----------| ```json
| `zonemaster.dnssec` | DNSSEC tests (signatures, NSEC/NSEC3, DS/DNSKEY coherence). | CRITICAL | {
| `zonemaster.delegation` | Delegation tests (parent/child NS agreement, glue, referrals). | CRITICAL | "key": "zonemaster",
| `zonemaster.consistency` | Consistency tests (SOA serial, NS set, zone content across servers). | CRITICAL | "options": {
| `zonemaster.connectivity` | Connectivity tests (UDP/TCP reachability of authoritative servers, AS diversity). | CRITICAL | "domainName": "example.com",
| `zonemaster.nameserver` | Nameserver tests (server behaviour, EDNS, unknown RR handling). | CRITICAL | "zonemasterAPIURL": "https://zonemaster.net/api",
| `zonemaster.syntax` | Syntax tests (domain name syntax, hostname legality). | CRITICAL | "language": "en",
"profile": "default"
}
}
```
The collect call is long-running: it starts a Zonemaster test, polls until
completion, and returns the full result tree as the observation payload.
## License ## License
MIT (see `LICENSE`). Third-party attributions in `NOTICE`. This project is licensed under the **MIT License** (see `LICENSE`). The
third-party Apache-2.0 attributions for `checker-sdk-go` are recorded in
`NOTICE` and must accompany any binary or source redistribution of this
project.

View file

@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -14,37 +13,6 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// maxResponseBytes caps the body size we'll read from the Zonemaster API.
// Real result payloads are tens to a few hundred KB; 8 MiB is generous head-
// room and still bounded so a misbehaving or hostile endpoint can't exhaust
// memory.
const maxResponseBytes = 8 << 20
// maxCollectDuration caps the total time spent collecting (start + poll +
// fetch). The caller's context still wins if it has a tighter deadline.
const maxCollectDuration = 15 * time.Minute
// pollInterval is how often we ask the Zonemaster API for test progress.
const pollInterval = 2 * time.Second
// zmHTTPClient is the HTTP client used for all Zonemaster API calls. It has
// per-phase timeouts so a stalling endpoint can never hang us indefinitely
// even if the caller passes a context without a deadline.
var zmHTTPClient = &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 90 * time.Second,
},
}
func (p *zonemasterProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { func (p *zonemasterProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domainName, ok := opts["domainName"].(string) domainName, ok := opts["domainName"].(string)
if !ok || domainName == "" { if !ok || domainName == "" {
@ -68,11 +36,6 @@ func (p *zonemasterProvider) Collect(ctx context.Context, opts sdk.CheckerOption
profile = prof profile = prof
} }
// Cap the total collection time even when the caller's context has no
// deadline. The caller's deadline still wins if it's tighter.
ctx, cancel := context.WithTimeout(ctx, maxCollectDuration)
defer cancel()
// Step 1: start the test. // Step 1: start the test.
startResult, err := zmCallJSONRPC(ctx, apiURL, "start_domain_test", zmStartTestParams{ startResult, err := zmCallJSONRPC(ctx, apiURL, "start_domain_test", zmStartTestParams{
Domain: domainName, Domain: domainName,
@ -93,10 +56,9 @@ func (p *zonemasterProvider) Collect(ctx context.Context, opts sdk.CheckerOption
} }
// Step 2: poll for completion. // Step 2: poll for completion.
ticker := time.NewTicker(pollInterval) ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop() defer ticker.Stop()
poll:
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -113,11 +75,12 @@ poll:
} }
if progress >= 100 { if progress >= 100 {
break poll goto testComplete
} }
} }
} }
testComplete:
// Step 3: fetch results. // Step 3: fetch results.
rawResults, err := zmCallJSONRPC(ctx, apiURL, "get_test_results", zmGetResultsParams{ rawResults, err := zmCallJSONRPC(ctx, apiURL, "get_test_results", zmGetResultsParams{
ID: testID, ID: testID,
@ -154,37 +117,19 @@ func zmCallJSONRPC(ctx context.Context, apiURL, method string, params any) (json
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := zmHTTPClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to call API: %w", err) return nil, fmt.Errorf("failed to call API: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
// Cap the body we'll ever read so a misbehaving endpoint can't exhaust
// memory. +1 lets us detect that the cap was hit.
limited := io.LimitReader(resp.Body, maxResponseBytes+1)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
b, readErr := io.ReadAll(limited) b, _ := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("API returned status %d (failed to read body: %v)", resp.StatusCode, readErr)
}
if len(b) > maxResponseBytes {
b = b[:maxResponseBytes]
}
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(b)) return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(b))
} }
body, readErr := io.ReadAll(limited)
if readErr != nil {
return nil, fmt.Errorf("failed to read response: %w", readErr)
}
if len(body) > maxResponseBytes {
return nil, fmt.Errorf("API response exceeds %d bytes", maxResponseBytes)
}
var rpcResp zmJSONRPCResponse var rpcResp zmJSONRPCResponse
if err := json.Unmarshal(body, &rpcResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err) return nil, fmt.Errorf("failed to decode response: %w", err)
} }

View file

@ -17,7 +17,7 @@ import (
var Version = "built-in" var Version = "built-in"
// Definition returns the CheckerDefinition for the zonemaster checker. // Definition returns the CheckerDefinition for the zonemaster checker.
func (p *zonemasterProvider) Definition() *sdk.CheckerDefinition { func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{ return &sdk.CheckerDefinition{
ID: "zonemaster", ID: "zonemaster",
Name: "Zonemaster", Name: "Zonemaster",
@ -75,11 +75,13 @@ func (p *zonemasterProvider) Definition() *sdk.CheckerDefinition {
}, },
}, },
}, },
Rules: Rules(), Rules: []sdk.CheckRule{
Rule(),
},
Interval: &sdk.CheckIntervalSpec{ Interval: &sdk.CheckIntervalSpec{
Min: 12 * time.Hour, Min: 1 * time.Hour,
Max: 30 * 24 * time.Hour, Max: 7 * 24 * time.Hour,
Default: 7 * 24 * time.Hour, Default: 24 * time.Hour,
}, },
} }
} }

View file

@ -1,69 +0,0 @@
//go:build standalone
package checker
import (
"errors"
"net/http"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func (p *zonemasterProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "domainName",
Type: "string",
Label: "Domain name to check",
Placeholder: "example.com",
Required: true,
Description: "Fully-qualified domain name to submit to the Zonemaster engine.",
},
{
Id: "profile",
Type: "string",
Label: "Profile",
Placeholder: "default",
Description: "Zonemaster test profile to apply (engine-defined; usually \"default\").",
},
{
Id: "language",
Type: "string",
Label: "Result language",
Placeholder: "en",
Description: "Language for human-readable test messages (en, fr, de, es, sv, da, fi, nb, nl, pt).",
},
{
Id: "zonemasterAPIURL",
Type: "string",
Label: "Zonemaster API URL",
Placeholder: "https://zonemaster.net/api",
Description: "JSON-RPC endpoint of the Zonemaster backend to query.",
},
}
}
func (p *zonemasterProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
domainName := strings.TrimSpace(r.FormValue("domainName"))
if domainName == "" {
return nil, errors.New("domainName is required")
}
domainName = strings.TrimSuffix(domainName, ".")
opts := sdk.CheckerOptions{
"domainName": domainName,
}
if v := strings.TrimSpace(r.FormValue("profile")); v != "" {
opts["profile"] = v
}
if v := strings.TrimSpace(r.FormValue("language")); v != "" {
opts["language"] = v
}
if v := strings.TrimSpace(r.FormValue("zonemasterAPIURL")); v != "" {
opts["zonemasterAPIURL"] = strings.TrimSuffix(v, "/")
}
return opts, nil
}

View file

@ -14,3 +14,8 @@ type zonemasterProvider struct{}
func (p *zonemasterProvider) Key() sdk.ObservationKey { func (p *zonemasterProvider) Key() sdk.ObservationKey {
return ObservationKeyZonemaster return ObservationKeyZonemaster
} }
// Definition implements sdk.CheckerDefinitionProvider.
func (p *zonemasterProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

View file

@ -15,14 +15,13 @@ import (
// zmLevelDisplayOrder defines the severity order used for sorting and display. // zmLevelDisplayOrder defines the severity order used for sorting and display.
var zmLevelDisplayOrder = []string{"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"} var zmLevelDisplayOrder = []string{"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"}
var zmLevelRank = map[string]int{ var zmLevelRank = func() map[string]int {
"CRITICAL": 6, m := make(map[string]int, len(zmLevelDisplayOrder))
"ERROR": 5, for i, l := range zmLevelDisplayOrder {
"WARNING": 4, m[l] = len(zmLevelDisplayOrder) - i
"NOTICE": 3,
"INFO": 2,
"DEBUG": 1,
} }
return m
}()
type zmLevelCount struct { type zmLevelCount struct {
Level string Level string
@ -51,7 +50,7 @@ var zonemasterHTMLTemplate = template.Must(
template.New("zonemaster"). template.New("zonemaster").
Funcs(template.FuncMap{ Funcs(template.FuncMap{
"badgeClass": func(level string) string { "badgeClass": func(level string) string {
switch normLevel(level) { switch strings.ToUpper(level) {
case "CRITICAL": case "CRITICAL":
return "badge-critical" return "badge-critical"
case "ERROR": case "ERROR":
@ -72,7 +71,7 @@ var zonemasterHTMLTemplate = template.Must(
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zonemaster{{if .Domain}}, {{.Domain}}{{end}}</title> <title>Zonemaster{{if .Domain}} {{.Domain}}{{end}}</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
:root { :root {
@ -158,7 +157,7 @@ details[open] > summary::before { transform: rotate(90deg); }
<body> <body>
<div class="hd"> <div class="hd">
<h1>Zonemaster{{if .Domain}}, <code>{{.Domain}}</code>{{end}}</h1> <h1>Zonemaster{{if .Domain}} <code>{{.Domain}}</code>{{end}}</h1>
<div class="meta"> <div class="meta">
{{- if .CreatedAt}}Run at {{.CreatedAt}}{{end -}} {{- if .CreatedAt}}Run at {{.CreatedAt}}{{end -}}
{{- if and .CreatedAt .HashID}} &middot; {{end -}} {{- if and .CreatedAt .HashID}} &middot; {{end -}}
@ -223,7 +222,7 @@ func (p *zonemasterProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error
rs := moduleMap[name] rs := moduleMap[name]
counts := map[string]int{} counts := map[string]int{}
for _, r := range rs { for _, r := range rs {
lvl := normLevel(r.Level) lvl := strings.ToUpper(r.Level)
counts[lvl]++ counts[lvl]++
totalCounts[lvl]++ totalCounts[lvl]++
} }

View file

@ -9,32 +9,20 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Rules returns the full list of CheckRules exposed by the Zonemaster checker. // Rule returns a new zonemaster check rule.
// Each rule narrows the Zonemaster results to a single test category so func Rule() sdk.CheckRule {
// callers can see at a glance which category passed and which did not, return &zonemasterRule{}
// 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 ──────────────────────────────────────────────────────────── type zonemasterRule struct{}
// validateZonemasterOptions validates the options accepted by the Zonemaster func (r *zonemasterRule) Name() string { return "zonemaster" }
// checker. Shared across rules that implement OptionsValidator.
func validateZonemasterOptions(opts sdk.CheckerOptions) error { func (r *zonemasterRule) Description() string {
return "Runs Zonemaster DNS validation tests against the zone"
}
func (r *zonemasterRule) ValidateOptions(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 {
@ -56,182 +44,69 @@ func validateZonemasterOptions(opts sdk.CheckerOptions) error {
return nil return nil
} }
// loadZonemasterData fetches the Zonemaster observation. On error, returns a func (r *zonemasterRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
// 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 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{{ return []sdk.CheckState{{
Status: sdk.StatusUnknown, Status: sdk.StatusError,
Message: fmt.Sprintf("No %s messages returned by Zonemaster for this zone.", r.name), Message: fmt.Sprintf("Failed to get Zonemaster data: %v", err),
Code: r.name + ".not_tested", Code: "zonemaster_error",
}} }}
} }
var ( var errorCount, warningCount int
critCount, errCount, warnCount, noticeCount, infoCount int var criticalMsgs []string
worst = sdk.StatusOK
issueStates []sdk.CheckState
)
for _, res := range matched { for _, res := range data.Results {
lvl := normLevel(res.Level) switch strings.ToUpper(res.Level) {
st := levelToStatus(lvl) case "CRITICAL", "ERROR":
worst = worstStatus(worst, st) errorCount++
if len(criticalMsgs) < 5 {
switch lvl { criticalMsgs = append(criticalMsgs, res.Message)
case "CRITICAL": }
critCount++
case "ERROR":
errCount++
case "WARNING": case "WARNING":
warnCount++ warningCount++
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{ meta := map[string]any{
Status: worst, "errorCount": errorCount,
Code: r.name + ".summary", "warningCount": warningCount,
Meta: map[string]any{ "totalChecks": len(data.Results),
"total": len(matched), "hashId": data.HashID,
"critical": critCount, "createdAt": data.CreatedAt,
"error": errCount,
"warning": warnCount,
"notice": noticeCount,
"info": infoCount,
},
} }
switch { if errorCount > 0 {
case critCount+errCount > 0: statusLine := fmt.Sprintf("%d error(s), %d warning(s) found", errorCount, warningCount)
summary.Message = fmt.Sprintf("%d error(s), %d warning(s) reported by Zonemaster (%d checks).", critCount+errCount, warnCount, len(matched)) if len(criticalMsgs) > 0 {
case warnCount > 0: n := 2
summary.Message = fmt.Sprintf("%d warning(s) reported by Zonemaster (%d checks).", warnCount, len(matched)) if len(criticalMsgs) < n {
default: n = len(criticalMsgs)
summary.Status = sdk.StatusOK }
summary.Message = fmt.Sprintf("No issues reported by Zonemaster (%d checks).", len(matched)) statusLine += ": " + strings.Join(criticalMsgs[:n], "; ")
}
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Message: statusLine,
Code: "zonemaster_errors",
Meta: meta,
}}
} }
return append([]sdk.CheckState{summary}, issueStates...) if warningCount > 0 {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Message: fmt.Sprintf("%d warning(s) found", warningCount),
Code: "zonemaster_warnings",
Meta: meta,
}}
} }
// filterByModules returns the subset of results whose Module matches any of return []sdk.CheckState{{
// the given module names (case-insensitive). Status: sdk.StatusOK,
func filterByModules(results []ZonemasterTestResult, modules []string) []ZonemasterTestResult { Message: fmt.Sprintf("All checks passed (%d checks)", len(data.Results)),
if len(modules) == 0 { Code: "zonemaster_ok",
return nil Meta: meta,
} }}
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
} }

View file

@ -1,298 +0,0 @@
package checker
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// fakeObs is a minimal ObservationGetter for tests. If err is non-nil, Get
// returns it; otherwise, it JSON-roundtrips data into dest.
type fakeObs struct {
data any
err error
}
func (f *fakeObs) Get(_ context.Context, _ sdk.ObservationKey, dest any) error {
if f.err != nil {
return f.err
}
b, err := json.Marshal(f.data)
if err != nil {
return err
}
return json.Unmarshal(b, dest)
}
func (f *fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}
func TestLevelToStatus(t *testing.T) {
cases := []struct {
level string
want sdk.Status
}{
{"CRITICAL", sdk.StatusCrit},
{"ERROR", sdk.StatusCrit},
{"critical", sdk.StatusCrit}, // case-insensitive
{"WARNING", sdk.StatusWarn},
{"NOTICE", sdk.StatusInfo},
{"INFO", sdk.StatusInfo},
{"DEBUG", sdk.StatusInfo},
{"", sdk.StatusUnknown},
{"BANANA", sdk.StatusUnknown},
}
for _, tc := range cases {
t.Run(tc.level, func(t *testing.T) {
if got := levelToStatus(tc.level); got != tc.want {
t.Errorf("levelToStatus(%q) = %v, want %v", tc.level, got, tc.want)
}
})
}
}
func TestWorstStatus(t *testing.T) {
// Severity ordering used by worstStatus:
// Error > Crit > Warn > Info > OK > Unknown
cases := []struct {
a, b, want sdk.Status
}{
{sdk.StatusOK, sdk.StatusOK, sdk.StatusOK},
{sdk.StatusOK, sdk.StatusInfo, sdk.StatusInfo},
{sdk.StatusInfo, sdk.StatusWarn, sdk.StatusWarn},
{sdk.StatusWarn, sdk.StatusCrit, sdk.StatusCrit},
{sdk.StatusCrit, sdk.StatusError, sdk.StatusError},
{sdk.StatusError, sdk.StatusCrit, sdk.StatusError},
{sdk.StatusUnknown, sdk.StatusOK, sdk.StatusOK},
{sdk.StatusUnknown, sdk.StatusUnknown, sdk.StatusUnknown},
}
for _, tc := range cases {
if got := worstStatus(tc.a, tc.b); got != tc.want {
t.Errorf("worstStatus(%v, %v) = %v, want %v", tc.a, tc.b, got, tc.want)
}
}
}
func TestFilterByModules(t *testing.T) {
results := []ZonemasterTestResult{
{Module: "DNSSEC", Message: "a"},
{Module: "Delegation", Message: "b"},
{Module: "dnssec", Message: "c"},
{Module: "Syntax", Message: "d"},
}
t.Run("matches case-insensitively", func(t *testing.T) {
got := filterByModules(results, []string{"dnssec"})
if len(got) != 2 {
t.Fatalf("got %d results, want 2: %+v", len(got), got)
}
if got[0].Message != "a" || got[1].Message != "c" {
t.Errorf("unexpected results: %+v", got)
}
})
t.Run("multiple modules", func(t *testing.T) {
got := filterByModules(results, []string{"delegation", "syntax"})
if len(got) != 2 {
t.Errorf("got %d, want 2", len(got))
}
})
t.Run("empty modules returns nil", func(t *testing.T) {
if got := filterByModules(results, nil); got != nil {
t.Errorf("got %+v, want nil", got)
}
})
t.Run("no match returns empty", func(t *testing.T) {
if got := filterByModules(results, []string{"nope"}); len(got) != 0 {
t.Errorf("got %+v, want empty", got)
}
})
}
func TestValidateZonemasterOptions(t *testing.T) {
cases := []struct {
name string
opts sdk.CheckerOptions
wantErr string // substring; empty means no error expected
}{
{"empty opts", sdk.CheckerOptions{}, ""},
{"empty url", sdk.CheckerOptions{"zonemasterAPIURL": ""}, ""},
{"valid http", sdk.CheckerOptions{"zonemasterAPIURL": "http://localhost:5000/api"}, ""},
{"valid https", sdk.CheckerOptions{"zonemasterAPIURL": "https://zonemaster.net/api"}, ""},
{"non-string", sdk.CheckerOptions{"zonemasterAPIURL": 42}, "must be a string"},
{"bad scheme", sdk.CheckerOptions{"zonemasterAPIURL": "ftp://x/api"}, "http or https"},
{"no host", sdk.CheckerOptions{"zonemasterAPIURL": "http:///api"}, "must include a host"},
{"unparseable", sdk.CheckerOptions{"zonemasterAPIURL": "http://[::1"}, "zonemasterAPIURL"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateZonemasterOptions(tc.opts)
switch {
case tc.wantErr == "" && err != nil:
t.Errorf("unexpected error: %v", err)
case tc.wantErr != "" && err == nil:
t.Errorf("expected error containing %q, got nil", tc.wantErr)
case tc.wantErr != "" && !strings.Contains(err.Error(), tc.wantErr):
t.Errorf("error %q does not contain %q", err.Error(), tc.wantErr)
}
})
}
}
func TestNormLevel(t *testing.T) {
cases := map[string]string{
"": "",
"info": "INFO",
"WaRnInG": "WARNING",
"CRITICAL": "CRITICAL",
}
for in, want := range cases {
if got := normLevel(in); got != want {
t.Errorf("normLevel(%q) = %q, want %q", in, got, want)
}
}
}
func TestCategoryRuleEvaluate_NoData(t *testing.T) {
r := &categoryRule{name: "zonemaster.dnssec", modules: []string{"dnssec"}}
obs := &fakeObs{data: ZonemasterData{Results: nil}}
states := r.Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("got %d states, want 1", len(states))
}
if states[0].Status != sdk.StatusUnknown {
t.Errorf("status = %v, want StatusUnknown", states[0].Status)
}
if states[0].Code != "zonemaster.dnssec.not_tested" {
t.Errorf("code = %q", states[0].Code)
}
}
func TestCategoryRuleEvaluate_ObservationError(t *testing.T) {
r := &categoryRule{name: "zonemaster.dnssec", modules: []string{"dnssec"}}
obs := &fakeObs{err: errors.New("boom")}
states := r.Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("got %d states, want 1", len(states))
}
if states[0].Status != sdk.StatusError {
t.Errorf("status = %v, want StatusError", states[0].Status)
}
if states[0].Code != "zonemaster.observation_error" {
t.Errorf("code = %q", states[0].Code)
}
}
func TestCategoryRuleEvaluate_AllOK(t *testing.T) {
r := &categoryRule{name: "zonemaster.dnssec", modules: []string{"dnssec"}}
obs := &fakeObs{data: ZonemasterData{Results: []ZonemasterTestResult{
{Module: "dnssec", Level: "INFO", Message: "ok1"},
{Module: "dnssec", Level: "NOTICE", Message: "ok2"},
{Module: "delegation", Level: "ERROR", Message: "ignored, wrong module"},
}}}
states := r.Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("got %d states, want 1 (summary only): %+v", len(states), states)
}
if states[0].Status != sdk.StatusOK {
t.Errorf("status = %v, want StatusOK", states[0].Status)
}
if got, _ := states[0].Meta["total"].(int); got != 2 {
t.Errorf("total = %d, want 2", got)
}
}
func TestCategoryRuleEvaluate_MixedSeverities(t *testing.T) {
r := &categoryRule{name: "zonemaster.dnssec", modules: []string{"dnssec"}}
obs := &fakeObs{data: ZonemasterData{Results: []ZonemasterTestResult{
{Module: "DNSSEC", Level: "INFO", Message: "i"},
{Module: "dnssec", Level: "WARNING", Message: "w", Testcase: "tc-w"},
{Module: "dnssec", Level: "ERROR", Message: "e", Testcase: "tc-e"},
{Module: "dnssec", Level: "CRITICAL", Message: "c", Testcase: "tc-c"},
}}}
states := r.Evaluate(context.Background(), obs, nil)
// Expect 1 summary + 3 issue states (warning + error + critical).
if len(states) != 4 {
t.Fatalf("got %d states, want 4: %+v", len(states), states)
}
summary := states[0]
if summary.Status != sdk.StatusCrit {
t.Errorf("summary status = %v, want StatusCrit", summary.Status)
}
if got, _ := summary.Meta["critical"].(int); got != 1 {
t.Errorf("critical = %d, want 1", got)
}
if got, _ := summary.Meta["error"].(int); got != 1 {
t.Errorf("error = %d, want 1", got)
}
if got, _ := summary.Meta["warning"].(int); got != 1 {
t.Errorf("warning = %d, want 1", got)
}
if got, _ := summary.Meta["info"].(int); got != 1 {
t.Errorf("info = %d, want 1", got)
}
// Issue states: codes should be dotted, lowercased levels.
wantCodes := map[string]bool{
"zonemaster.dnssec.warning": false,
"zonemaster.dnssec.error": false,
"zonemaster.dnssec.critical": false,
}
for _, s := range states[1:] {
if _, ok := wantCodes[s.Code]; !ok {
t.Errorf("unexpected issue code: %q", s.Code)
continue
}
wantCodes[s.Code] = true
if s.Subject == "" {
t.Errorf("issue state %q missing Subject", s.Code)
}
}
for code, seen := range wantCodes {
if !seen {
t.Errorf("missing issue state for %q", code)
}
}
}
func TestRulesContainsAllCategories(t *testing.T) {
got := Rules()
wantNames := []string{
"zonemaster.dnssec",
"zonemaster.delegation",
"zonemaster.consistency",
"zonemaster.connectivity",
"zonemaster.nameserver",
"zonemaster.syntax",
"zonemaster.zone",
"zonemaster.address",
"zonemaster.basic",
}
if len(got) != len(wantNames) {
t.Fatalf("Rules() returned %d rules, want %d", len(got), len(wantNames))
}
seen := map[string]bool{}
for _, r := range got {
seen[r.Name()] = true
if r.Description() == "" {
t.Errorf("rule %q has empty description", r.Name())
}
}
for _, n := range wantNames {
if !seen[n] {
t.Errorf("Rules() missing %q", n)
}
}
}

View file

@ -1,13 +0,0 @@
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"},
}
}

View file

@ -1,14 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,13 +0,0 @@
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"},
}
}

View file

@ -1,13 +0,0 @@
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

@ -1,13 +0,0 @@
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"},
}
}

View file

@ -1,13 +0,0 @@
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"},
}
}

View file

@ -1,13 +0,0 @@
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"},
}
}

2
go.mod
View file

@ -2,4 +2,4 @@ module git.happydns.org/checker-zonemaster
go 1.25.0 go 1.25.0
require git.happydns.org/checker-sdk-go v1.5.0 require git.happydns.org/checker-sdk-go v1.2.0

4
go.sum
View file

@ -1,2 +1,2 @@
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=

View file

@ -4,8 +4,8 @@ import (
"flag" "flag"
"log" "log"
"git.happydns.org/checker-sdk-go/checker/server"
zonemaster "git.happydns.org/checker-zonemaster/checker" zonemaster "git.happydns.org/checker-zonemaster/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Version is the standalone binary's version. It defaults to "custom-build" // Version is the standalone binary's version. It defaults to "custom-build"
@ -23,8 +23,8 @@ func main() {
// CheckerDefinition.Version. // CheckerDefinition.Version.
zonemaster.Version = Version zonemaster.Version = Version
srv := server.New(zonemaster.Provider()) server := sdk.NewServer(zonemaster.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil { if err := server.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err) log.Fatalf("server error: %v", err)
} }
} }

View file

@ -5,8 +5,8 @@
package main package main
import ( import (
sdk "git.happydns.org/checker-sdk-go/checker"
zonemaster "git.happydns.org/checker-zonemaster/checker" zonemaster "git.happydns.org/checker-zonemaster/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Version is the plugin's version. It defaults to "custom-build" and is // Version is the plugin's version. It defaults to "custom-build" and is
@ -20,6 +20,5 @@ var Version = "custom-build"
// that the host will register in its global registries. // that the host will register in its global registries.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
zonemaster.Version = Version zonemaster.Version = Version
prvd := zonemaster.Provider() return zonemaster.Definition(), zonemaster.Provider(), nil
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
} }