Initial commit

This commit is contained in:
nemunaire 2026-04-08 03:27:27 +07:00
commit 979757b5a8
15 changed files with 966 additions and 0 deletions

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-zonemaster .
FROM scratch
COPY --from=builder /checker-zonemaster /checker-zonemaster
EXPOSE 8080
ENTRYPOINT ["/checker-zonemaster"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

25
Makefile Normal file
View file

@ -0,0 +1,25 @@
CHECKER_NAME := checker-zonemaster
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

26
NOTICE Normal file
View file

@ -0,0 +1,26 @@
checker-zonemaster
Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE).
-------------------------------------------------------------------------------
Third-party notices
-------------------------------------------------------------------------------
This product includes software developed as part of the checker-sdk-go
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
under the Apache License, Version 2.0:
checker-sdk-go
Copyright 2020-2026 The happyDomain Authors
This product includes software developed as part of the happyDomain
project (https://happydomain.org).
Portions of this code were originally written for the happyDomain
server (licensed under AGPL-3.0 and a commercial license) and are
made available there under the Apache License, Version 2.0 to enable
a permissively licensed ecosystem of checker plugins.
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0

92
README.md Normal file
View file

@ -0,0 +1,92 @@
# checker-zonemaster
Zonemaster DNS validation checker for [happyDomain](https://www.happydomain.org/).
Runs the [Zonemaster](https://zonemaster.net/) test suite against a domain via
its public JSON-RPC API and stores the full results as an observation. The
checker also produces a rich HTML report grouped by Zonemaster module and
severity.
## Usage
### Standalone HTTP server
```bash
make
./checker-zonemaster -listen :8080
```
The server exposes the standard happyDomain external checker endpoints
(`/health`, `/definition`, `/collect`, `/evaluate`, `/html-report`).
### Docker
```bash
make docker
docker run -p 8080:8080 happydomain/checker-zonemaster
```
### happyDomain plugin
```bash
make plugin
# produces checker-zonemaster.so, loadable by happyDomain as a Go plugin
```
The plugin exposes a `NewCheckerPlugin` symbol returning the checker
definition and observation provider, which happyDomain registers in its
global registries at load time.
### Versioning
The binary, plugin, and Docker image embed a version string overridable
at build time:
```bash
make CHECKER_VERSION=1.2.3
make plugin CHECKER_VERSION=1.2.3
make docker CHECKER_VERSION=1.2.3
```
### happyDomain remote endpoint
Set the `endpoint` admin option for the zonemaster checker to the URL of
the running checker-zonemaster server (e.g.,
`http://checker-zonemaster:8080`). happyDomain will delegate observation
collection to this endpoint.
## Options
| Scope | Id | Description |
| --------- | ------------------ | ---------------------------------------------------- |
| Run | `domainName` | Domain name to test (auto-filled from the domain) |
| Run | `profile` | Zonemaster profile name (default: `default`) |
| User | `language` | Result language (`en`, `fr`, `de`, …) |
| Admin | `zonemasterAPIURL` | Zonemaster JSON-RPC endpoint (default: official API) |
## Protocol
### POST /collect
Request:
```json
{
"key": "zonemaster",
"options": {
"domainName": "example.com",
"zonemasterAPIURL": "https://zonemaster.net/api",
"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
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.

141
checker/collect.go Normal file
View file

@ -0,0 +1,141 @@
package checker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func (p *zonemasterProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domainName, ok := opts["domainName"].(string)
if !ok || domainName == "" {
return nil, fmt.Errorf("domainName is required")
}
domainName = strings.TrimSuffix(domainName, ".")
apiURL, ok := opts["zonemasterAPIURL"].(string)
if !ok || apiURL == "" {
apiURL = "https://zonemaster.net/api"
}
apiURL = strings.TrimSuffix(apiURL, "/")
language := "en"
if lang, ok := opts["language"].(string); ok && lang != "" {
language = lang
}
profile := "default"
if prof, ok := opts["profile"].(string); ok && prof != "" {
profile = prof
}
// Step 1: start the test.
startResult, err := zmCallJSONRPC(ctx, apiURL, "start_domain_test", zmStartTestParams{
Domain: domainName,
Profile: profile,
IPv4: true,
IPv6: true,
})
if err != nil {
return nil, fmt.Errorf("failed to start test: %w", err)
}
var testID string
if err = json.Unmarshal(startResult, &testID); err != nil {
return nil, fmt.Errorf("failed to parse test ID: %w", err)
}
if testID == "" {
return nil, fmt.Errorf("received empty test ID")
}
// Step 2: poll for completion.
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("test cancelled (test ID: %s): %w", testID, ctx.Err())
case <-ticker.C:
progressResult, err := zmCallJSONRPC(ctx, apiURL, "test_progress", zmProgressParams{TestID: testID})
if err != nil {
return nil, fmt.Errorf("failed to check progress: %w", err)
}
var progress float64
if err := json.Unmarshal(progressResult, &progress); err != nil {
return nil, fmt.Errorf("failed to parse progress: %w", err)
}
if progress >= 100 {
goto testComplete
}
}
}
testComplete:
// Step 3: fetch results.
rawResults, err := zmCallJSONRPC(ctx, apiURL, "get_test_results", zmGetResultsParams{
ID: testID,
Language: language,
})
if err != nil {
return nil, fmt.Errorf("failed to get results: %w", err)
}
var data ZonemasterData
if err := json.Unmarshal(rawResults, &data); err != nil {
return nil, fmt.Errorf("failed to parse results: %w", err)
}
data.Language = language
return &data, nil
}
// zmCallJSONRPC performs a single JSON-RPC 2.0 call and returns the raw result.
func zmCallJSONRPC(ctx context.Context, apiURL, method string, params any) (json.RawMessage, error) {
body, err := json.Marshal(zmJSONRPCRequest{
Jsonrpc: "2.0",
Method: method,
Params: params,
ID: 1,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(b))
}
var rpcResp zmJSONRPCResponse
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("API error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
}
return rpcResp.Result, nil
}

87
checker/definition.go Normal file
View file

@ -0,0 +1,87 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the checker version reported in CheckerDefinition.Version.
//
// It defaults to "built-in", which is appropriate when the checker package is
// imported directly (built-in or plugin mode). Standalone binaries (like
// main.go) should override this from their own Version variable at the start
// of main(), which makes it easy for CI to inject a version with a single
// -ldflags "-X main.Version=..." flag instead of targeting the nested
// package path.
var Version = "built-in"
// Definition returns the CheckerDefinition for the zonemaster checker.
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "zonemaster",
Name: "Zonemaster",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToDomain: true,
},
HasHTMLReport: true,
ObservationKeys: []sdk.ObservationKey{ObservationKeyZonemaster},
Options: sdk.CheckerOptionsDocumentation{
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domainName",
Type: "string",
Label: "Domain name to check",
Required: true,
Placeholder: "example.com.",
AutoFill: sdk.AutoFillDomainName,
},
{
Id: "profile",
Type: "string",
Label: "Profile",
Placeholder: "default",
Default: "default",
},
},
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: "language",
Type: "string",
Label: "Result language",
Default: "en",
Choices: []string{
"en", // English
"fr", // French
"de", // German
"es", // Spanish
"sv", // Swedish
"da", // Danish
"fi", // Finnish
"nb", // Norwegian Bokmål
"nl", // Dutch
"pt", // Portuguese
},
},
},
AdminOpts: []sdk.CheckerOptionDocumentation{
{
Id: "zonemasterAPIURL",
Type: "string",
Label: "Zonemaster API URL",
Placeholder: "https://zonemaster.net/api",
Default: "https://zonemaster.net/api",
},
},
},
Rules: []sdk.CheckRule{
Rule(),
},
Interval: &sdk.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
}
}

21
checker/provider.go Normal file
View file

@ -0,0 +1,21 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new zonemaster observation provider.
func Provider() sdk.ObservationProvider {
return &zonemasterProvider{}
}
type zonemasterProvider struct{}
func (p *zonemasterProvider) Key() sdk.ObservationKey {
return ObservationKeyZonemaster
}
// Definition implements sdk.CheckerDefinitionProvider.
func (p *zonemasterProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

299
checker/report.go Normal file
View file

@ -0,0 +1,299 @@
package checker
import (
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
)
// ── HTML report ───────────────────────────────────────────────────────────────
// zmLevelDisplayOrder defines the severity order used for sorting and display.
var zmLevelDisplayOrder = []string{"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"}
var zmLevelRank = func() map[string]int {
m := make(map[string]int, len(zmLevelDisplayOrder))
for i, l := range zmLevelDisplayOrder {
m[l] = len(zmLevelDisplayOrder) - i
}
return m
}()
type zmLevelCount struct {
Level string
Count int
}
type zmModuleGroup struct {
Name string
Position int // first-seen index, used as tiebreaker in sort
Results []ZonemasterTestResult
Levels []zmLevelCount // sorted by severity desc, zeros omitted
Worst string
Open bool
}
type zmTemplateData struct {
Domain string
CreatedAt string
HashID string
Language string
Modules []zmModuleGroup
Totals []zmLevelCount // sorted by severity desc, zeros omitted
}
var zonemasterHTMLTemplate = template.Must(
template.New("zonemaster").
Funcs(template.FuncMap{
"badgeClass": func(level string) string {
switch strings.ToUpper(level) {
case "CRITICAL":
return "badge-critical"
case "ERROR":
return "badge-error"
case "WARNING":
return "badge-warning"
case "NOTICE":
return "badge-notice"
case "INFO":
return "badge-info"
default:
return "badge-debug"
}
},
}).
Parse(`<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zonemaster{{if .Domain}} {{.Domain}}{{end}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
a { color: inherit; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
/* Header card */
.hd {
background: #fff;
border-radius: 10px;
padding: 1rem 1.25rem 1.1rem;
margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd h1 { margin: 0 0 .2rem; font-size: 1.15rem; font-weight: 700; }
.hd .meta { color: #6b7280; font-size: .82rem; margin-bottom: .6rem; }
.totals { display: flex; gap: .35rem; flex-wrap: wrap; }
/* Badges */
.badge {
display: inline-flex; align-items: center;
padding: .18em .55em;
border-radius: 9999px;
font-size: .72rem; font-weight: 700;
letter-spacing: .02em; white-space: nowrap;
}
.badge-critical { background: #fee2e2; color: #991b1b; }
.badge-error { background: #ffedd5; color: #9a3412; }
.badge-warning { background: #fef3c7; color: #92400e; }
.badge-notice { background: #e0f2fe; color: #075985; }
.badge-info { background: #dbeafe; color: #1e40af; }
.badge-debug { background: #f3f4f6; color: #4b5563; }
/* Accordion */
details {
background: #fff;
border-radius: 8px;
margin-bottom: .45rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
overflow: hidden;
}
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem;
cursor: pointer;
user-select: none;
list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before {
content: "▶";
font-size: .65rem;
color: #9ca3af;
transition: transform .15s;
flex-shrink: 0;
}
details[open] > summary::before { transform: rotate(90deg); }
.mod-name { font-weight: 600; flex: 1; font-size: .9rem; }
.mod-badges { display: flex; gap: .25rem; flex-wrap: wrap; }
/* Result rows */
.results { border-top: 1px solid #f3f4f6; }
.row {
display: grid;
grid-template-columns: max-content 1fr;
gap: .6rem;
padding: .45rem 1rem;
border-bottom: 1px solid #f9fafb;
align-items: start;
}
.row:last-child { border-bottom: none; }
.row-msg { color: #374151; }
.row-tc { font-size: .75rem; color: #9ca3af; }
</style>
</head>
<body>
<div class="hd">
<h1>Zonemaster{{if .Domain}} <code>{{.Domain}}</code>{{end}}</h1>
<div class="meta">
{{- if .CreatedAt}}Run at {{.CreatedAt}}{{end -}}
{{- if and .CreatedAt .HashID}} &middot; {{end -}}
{{- if .HashID}}ID: <code>{{.HashID}}</code>{{end -}}
</div>
<div class="totals">
{{- range .Totals}}
<span class="badge {{badgeClass .Level}}">{{.Level}}&nbsp;{{.Count}}</span>
{{- end}}
</div>
</div>
{{range .Modules -}}
<details{{if .Open}} open{{end}}>
<summary>
<span class="mod-name">{{.Name}}</span>
<span class="mod-badges">
{{- range .Levels}}
<span class="badge {{badgeClass .Level}}">{{.Count}}</span>
{{- end}}
</span>
</summary>
<div class="results">
{{- range .Results}}
<div class="row">
<span class="badge {{badgeClass .Level}}">{{.Level}}</span>
<div>
<div class="row-msg">{{.Message}}</div>
{{- if .Testcase}}<div class="row-tc">{{.Testcase}}</div>{{end}}
</div>
</div>
{{- end}}
</div>
</details>
{{end -}}
</body>
</html>`),
)
// GetHTMLReport implements sdk.CheckerHTMLReporter.
func (p *zonemasterProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
var data ZonemasterData
if err := json.Unmarshal(raw, &data); err != nil {
return "", fmt.Errorf("failed to unmarshal zonemaster results: %w", err)
}
// Group results by module, preserving first-seen order.
moduleOrder := []string{}
moduleMap := map[string][]ZonemasterTestResult{}
for _, r := range data.Results {
if _, seen := moduleMap[r.Module]; !seen {
moduleOrder = append(moduleOrder, r.Module)
}
moduleMap[r.Module] = append(moduleMap[r.Module], r)
}
totalCounts := map[string]int{}
var modules []zmModuleGroup
for _, name := range moduleOrder {
rs := moduleMap[name]
counts := map[string]int{}
for _, r := range rs {
lvl := strings.ToUpper(r.Level)
counts[lvl]++
totalCounts[lvl]++
}
// Find worst level and build sorted level-count slice.
worst := ""
worstRank := -1
var levels []zmLevelCount
for _, l := range zmLevelDisplayOrder {
if n, ok := counts[l]; ok && n > 0 {
levels = append(levels, zmLevelCount{Level: l, Count: n})
if zmLevelRank[l] > worstRank {
worstRank = zmLevelRank[l]
worst = l
}
}
}
// Append any unknown levels last.
for l, n := range counts {
if _, known := zmLevelRank[l]; !known {
levels = append(levels, zmLevelCount{Level: l, Count: n})
}
}
modules = append(modules, zmModuleGroup{
Name: name,
Position: len(modules),
Results: rs,
Levels: levels,
Worst: worst,
Open: worst == "CRITICAL" || worst == "ERROR",
})
}
// Sort modules: most severe first, then by original appearance order.
sort.Slice(modules, func(i, j int) bool {
ri, rj := zmLevelRank[modules[i].Worst], zmLevelRank[modules[j].Worst]
if ri != rj {
return ri > rj
}
return modules[i].Position < modules[j].Position
})
// Build sorted totals slice.
var totals []zmLevelCount
for _, l := range zmLevelDisplayOrder {
if n, ok := totalCounts[l]; ok && n > 0 {
totals = append(totals, zmLevelCount{Level: l, Count: n})
}
}
domain := ""
if d, ok := data.Params["domain"]; ok {
domain = fmt.Sprintf("%v", d)
}
lang := data.Language
if lang == "" {
lang = "en"
}
td := zmTemplateData{
Domain: domain,
CreatedAt: data.CreatedAt,
HashID: data.HashID,
Language: lang,
Modules: modules,
Totals: totals,
}
var buf strings.Builder
if err := zonemasterHTMLTemplate.Execute(&buf, td); err != nil {
return "", fmt.Errorf("failed to render zonemaster HTML report: %w", err)
}
return buf.String(), nil
}

112
checker/rule.go Normal file
View file

@ -0,0 +1,112 @@
package checker
import (
"context"
"fmt"
"net/url"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule returns a new zonemaster check rule.
func Rule() sdk.CheckRule {
return &zonemasterRule{}
}
type zonemasterRule struct{}
func (r *zonemasterRule) Name() string { return "zonemaster" }
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 {
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
}
func (r *zonemasterRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState {
var data ZonemasterData
if err := obs.Get(ctx, ObservationKeyZonemaster, &data); err != nil {
return sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to get Zonemaster data: %v", err),
Code: "zonemaster_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++
}
}
meta := map[string]any{
"errorCount": errorCount,
"warningCount": warningCount,
"totalChecks": len(data.Results),
"hashId": data.HashID,
"createdAt": data.CreatedAt,
}
if errorCount > 0 {
statusLine := fmt.Sprintf("%d error(s), %d warning(s) found", errorCount, warningCount)
if len(criticalMsgs) > 0 {
n := 2
if len(criticalMsgs) < n {
n = len(criticalMsgs)
}
statusLine += ": " + strings.Join(criticalMsgs[:n], "; ")
}
return sdk.CheckState{
Status: sdk.StatusCrit,
Message: statusLine,
Code: "zonemaster_errors",
Meta: meta,
}
}
if warningCount > 0 {
return sdk.CheckState{
Status: sdk.StatusWarn,
Message: fmt.Sprintf("%d warning(s) found", warningCount),
Code: "zonemaster_warnings",
Meta: meta,
}
}
return sdk.CheckState{
Status: sdk.StatusOK,
Message: fmt.Sprintf("All checks passed (%d checks)", len(data.Results)),
Code: "zonemaster_ok",
Meta: meta,
}
}

67
checker/types.go Normal file
View file

@ -0,0 +1,67 @@
package checker
import (
"encoding/json"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ObservationKeyZonemaster is the observation key for Zonemaster test data.
const ObservationKeyZonemaster sdk.ObservationKey = "zonemaster"
// ── JSON-RPC structures ────────────────────────────────────────────────────────
type zmJSONRPCRequest struct {
Jsonrpc string `json:"jsonrpc"`
Method string `json:"method"`
Params any `json:"params"`
ID int `json:"id"`
}
type zmJSONRPCResponse struct {
Jsonrpc string `json:"jsonrpc"`
Result json.RawMessage `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
ID int `json:"id"`
}
// ── Zonemaster API parameter types ────────────────────────────────────────────
type zmStartTestParams struct {
Domain string `json:"domain"`
Profile string `json:"profile,omitempty"`
IPv4 bool `json:"ipv4,omitempty"`
IPv6 bool `json:"ipv6,omitempty"`
}
type zmProgressParams struct {
TestID string `json:"test_id"`
}
type zmGetResultsParams struct {
ID string `json:"id"`
Language string `json:"language"`
}
// ── Observation data types ─────────────────────────────────────────────────────
// ZonemasterTestResult is a single result entry returned by the Zonemaster API.
type ZonemasterTestResult struct {
Module string `json:"module"`
Message string `json:"message"`
Level string `json:"level"`
Testcase string `json:"testcase,omitempty"`
}
// ZonemasterData holds the full Zonemaster test output stored as an observation.
type ZonemasterData struct {
CreatedAt string `json:"created_at"`
HashID string `json:"hash_id"`
Language string `json:"language,omitempty"`
Params map[string]any `json:"params"`
Results []ZonemasterTestResult `json:"results"`
TestcaseDescriptions map[string]string `json:"testcase_descriptions,omitempty"`
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module git.happydns.org/checker-zonemaster
go 1.25.0
require git.happydns.org/checker-sdk-go v0.0.1

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
git.happydns.org/checker-sdk-go v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI=
git.happydns.org/checker-sdk-go v0.0.1/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=

30
main.go Normal file
View file

@ -0,0 +1,30 @@
package main
import (
"flag"
"log"
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"
// and is meant to be overridden by the CI at link time:
//
// go build -ldflags "-X main.Version=1.2.3" .
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
// Propagate the binary version to the checker package so it shows up in
// CheckerDefinition.Version.
zonemaster.Version = Version
server := sdk.NewServer(zonemaster.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

24
plugin/plugin.go Normal file
View file

@ -0,0 +1,24 @@
// Command plugin is the happyDomain plugin entrypoint for the zonemaster checker.
//
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
// runtime by happyDomain.
package main
import (
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
// meant to be overridden by the CI at link time:
//
// go build -buildmode=plugin -ldflags "-X main.Version=1.2.3" -o checker-zonemaster.so ./plugin
var Version = "custom-build"
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
// .so file. It returns the checker definition and the observation provider
// that the host will register in its global registries.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
zonemaster.Version = Version
return zonemaster.Definition(), zonemaster.Provider(), nil
}