Compare commits

...

9 commits

Author SHA1 Message Date
3490b5ee2b Add CI/CD pipeline
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-10 19:33:53 +08:00
5acf464d4e Update rules 2026-04-30 08:35:11 +07:00
84649767c9 Include rules in README 2026-04-29 22:54:19 +07:00
5ba745982e checker: add standalone interactive form and test target
Implement RenderForm/ParseForm for the zonemasterProvider under the
standalone build tag, enabling browser-based interactive use. Also add
a `make test` target that builds with the same tag.
2026-04-26 17:19:32 +07:00
2710dfb459 checker: harden HTTP client, cap response size, drop dead legacy rule 2026-04-26 17:12:13 +07:00
181c5961f1 checker: split monolithic rule into per-concern rules 2026-04-26 16:53:57 +07:00
463e3fb457 docker: add HEALTHCHECK probing /health
The binary doubles as its own healthcheck client via the SDK's
-healthcheck flag, so the probe works in the scratch image
(no shell, no curl, no wget).
2026-04-26 16:41:08 +07:00
bf409ba33c Run container as non-root user
Add USER 65534:65534 to the scratch runtime image so the checker
process does not run as root.
2026-04-26 16:41:06 +07:00
a9a704c0ff Migrate to checker-sdk-go v1.3.0 with new server subpackage
The SDK split the HTTP server scaffolding into the new
checker-sdk-go/checker/server subpackage. Update main.go to import
server and call server.New.
2026-04-26 16:41:03 +07:00
26 changed files with 1003 additions and 120 deletions

22
.drone-manifest.yml Normal file
View file

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

187
.drone.yml Normal file
View file

@ -0,0 +1,187 @@
---
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 Normal file
View file

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

View file

@ -10,5 +10,8 @@ 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 clean .PHONY: all plugin docker test clean
all: $(CHECKER_NAME) all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES) $(CHECKER_NAME): $(CHECKER_SOURCES)
go build -ldflags "$(GO_LDFLAGS)" -o $@ . go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so plugin: $(CHECKER_NAME).so
@ -21,5 +21,8 @@ $(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,6 +55,20 @@ 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 |
@ -64,29 +78,21 @@ collection to this endpoint.
| 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) |
## Protocol ## Rules
### POST /collect Each rule wraps one Zonemaster test module and emits a `<rule>.summary`
state plus one `<rule>.<level>` state per WARNING-or-worse Zonemaster
message, so downstream consumers can match on stable codes.
Request: | Code | Description | Severity |
```json |---------------------------|-----------------------------------------------------------------------------------|----------|
{ | `zonemaster.dnssec` | DNSSEC tests (signatures, NSEC/NSEC3, DS/DNSKEY coherence). | CRITICAL |
"key": "zonemaster", | `zonemaster.delegation` | Delegation tests (parent/child NS agreement, glue, referrals). | CRITICAL |
"options": { | `zonemaster.consistency` | Consistency tests (SOA serial, NS set, zone content across servers). | CRITICAL |
"domainName": "example.com", | `zonemaster.connectivity` | Connectivity tests (UDP/TCP reachability of authoritative servers, AS diversity). | CRITICAL |
"zonemasterAPIURL": "https://zonemaster.net/api", | `zonemaster.nameserver` | Nameserver tests (server behaviour, EDNS, unknown RR handling). | CRITICAL |
"language": "en", | `zonemaster.syntax` | Syntax tests (domain name syntax, hostname legality). | CRITICAL |
"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
This project is licensed under the **MIT License** (see `LICENSE`). The MIT (see `LICENSE`). Third-party attributions in `NOTICE`.
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,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -13,6 +14,37 @@ 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 == "" {
@ -36,6 +68,11 @@ 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,
@ -56,9 +93,10 @@ func (p *zonemasterProvider) Collect(ctx context.Context, opts sdk.CheckerOption
} }
// Step 2: poll for completion. // Step 2: poll for completion.
ticker := time.NewTicker(2 * time.Second) ticker := time.NewTicker(pollInterval)
defer ticker.Stop() defer ticker.Stop()
poll:
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -75,12 +113,11 @@ func (p *zonemasterProvider) Collect(ctx context.Context, opts sdk.CheckerOption
} }
if progress >= 100 { if progress >= 100 {
goto testComplete break poll
} }
} }
} }
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,
@ -117,19 +154,37 @@ 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 := http.DefaultClient.Do(req) resp, err := zmHTTPClient.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, _ := io.ReadAll(resp.Body) b, readErr := io.ReadAll(limited)
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.NewDecoder(resp.Body).Decode(&rpcResp); err != nil { if err := json.Unmarshal(body, &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 Definition() *sdk.CheckerDefinition { func (p *zonemasterProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{ return &sdk.CheckerDefinition{
ID: "zonemaster", ID: "zonemaster",
Name: "Zonemaster", Name: "Zonemaster",
@ -75,13 +75,11 @@ func Definition() *sdk.CheckerDefinition {
}, },
}, },
}, },
Rules: []sdk.CheckRule{ Rules: Rules(),
Rule(),
},
Interval: &sdk.CheckIntervalSpec{ Interval: &sdk.CheckIntervalSpec{
Min: 1 * time.Hour, Min: 12 * time.Hour,
Max: 7 * 24 * time.Hour, Max: 30 * 24 * time.Hour,
Default: 24 * time.Hour, Default: 7 * 24 * time.Hour,
}, },
} }
} }

69
checker/interactive.go Normal file
View file

@ -0,0 +1,69 @@
//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,8 +14,3 @@ 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,13 +15,14 @@ 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 = func() map[string]int { var zmLevelRank = map[string]int{
m := make(map[string]int, len(zmLevelDisplayOrder)) "CRITICAL": 6,
for i, l := range zmLevelDisplayOrder { "ERROR": 5,
m[l] = len(zmLevelDisplayOrder) - i "WARNING": 4,
} "NOTICE": 3,
return m "INFO": 2,
}() "DEBUG": 1,
}
type zmLevelCount struct { type zmLevelCount struct {
Level string Level string
@ -50,7 +51,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 strings.ToUpper(level) { switch normLevel(level) {
case "CRITICAL": case "CRITICAL":
return "badge-critical" return "badge-critical"
case "ERROR": case "ERROR":
@ -71,7 +72,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 {
@ -157,7 +158,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 -}}
@ -222,7 +223,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 := strings.ToUpper(r.Level) lvl := normLevel(r.Level)
counts[lvl]++ counts[lvl]++
totalCounts[lvl]++ totalCounts[lvl]++
} }

View file

@ -9,20 +9,32 @@ 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{} // ── shared helpers ────────────────────────────────────────────────────────────
func (r *zonemasterRule) Name() string { return "zonemaster" } // validateZonemasterOptions validates the options accepted by the Zonemaster
// checker. Shared across rules that implement OptionsValidator.
func (r *zonemasterRule) Description() string { func validateZonemasterOptions(opts sdk.CheckerOptions) error {
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 {
@ -44,69 +56,182 @@ 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",
}}
}
var errorCount, warningCount int
var criticalMsgs []string
for _, res := range data.Results {
switch strings.ToUpper(res.Level) {
case "CRITICAL", "ERROR":
errorCount++
if len(criticalMsgs) < 5 {
criticalMsgs = append(criticalMsgs, res.Message)
}
case "WARNING":
warningCount++
} }
} }
return &data, nil
meta := map[string]any{ }
"errorCount": errorCount,
"warningCount": warningCount, // normLevel returns the canonical (upper-case) form of a Zonemaster severity
"totalChecks": len(data.Results), // string. Use this anywhere a severity needs to be compared, looked up or
"hashId": data.HashID, // keyed so the canonical list stays in one place.
"createdAt": data.CreatedAt, func normLevel(level string) string {
} return strings.ToUpper(level)
}
if errorCount > 0 {
statusLine := fmt.Sprintf("%d error(s), %d warning(s) found", errorCount, warningCount) // levelToStatus maps a Zonemaster-returned severity to happyDomain's status.
if len(criticalMsgs) > 0 { // Zonemaster's own judgement is treated as raw input; this is happyDomain's
n := 2 // own mapping onto the SDK status enum.
if len(criticalMsgs) < n { func levelToStatus(level string) sdk.Status {
n = len(criticalMsgs) switch normLevel(level) {
} case "CRITICAL", "ERROR":
statusLine += ": " + strings.Join(criticalMsgs[:n], "; ") return sdk.StatusCrit
} case "WARNING":
return []sdk.CheckState{{ return sdk.StatusWarn
Status: sdk.StatusCrit, case "NOTICE", "INFO", "DEBUG":
Message: statusLine, return sdk.StatusInfo
Code: "zonemaster_errors", default:
Meta: meta, return sdk.StatusUnknown
}} }
} }
if warningCount > 0 { // worstStatus returns the more severe of two statuses. StatusError always
return []sdk.CheckState{{ // wins because it means "we could not evaluate".
Status: sdk.StatusWarn, func worstStatus(a, b sdk.Status) sdk.Status {
Message: fmt.Sprintf("%d warning(s) found", warningCount), rank := func(s sdk.Status) int {
Code: "zonemaster_warnings", switch s {
Meta: meta, case sdk.StatusError:
}} return 6
} case sdk.StatusCrit:
return 5
return []sdk.CheckState{{ case sdk.StatusWarn:
Status: sdk.StatusOK, return 4
Message: fmt.Sprintf("All checks passed (%d checks)", len(data.Results)), case sdk.StatusInfo:
Code: "zonemaster_ok", return 2
Meta: meta, 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
} }

298
checker/rule_test.go Normal file
View file

@ -0,0 +1,298 @@
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)
}
}
}

13
checker/rules_address.go Normal file
View 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
View 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"},
}
}

View 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"},
}
}

View 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"},
}
}

View 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
View 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"},
}
}

View 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
View 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
View 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"},
}
}

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.2.0 require git.happydns.org/checker-sdk-go v1.5.0

4
go.sum
View file

@ -1,2 +1,2 @@
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= git.happydns.org/checker-sdk-go v1.5.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
server := sdk.NewServer(zonemaster.Provider()) srv := server.New(zonemaster.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil { if err := srv.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 (
zonemaster "git.happydns.org/checker-zonemaster/checker"
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
zonemaster "git.happydns.org/checker-zonemaster/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,5 +20,6 @@ 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
return zonemaster.Definition(), zonemaster.Provider(), nil prvd := zonemaster.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
} }