commit 979757b5a8fc3f167f4b1ccebd88114f89e8600a Author: Pierre-Olivier Mercier Date: Wed Apr 8 03:27:27 2026 +0700 Initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ddefd02 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..002307b --- /dev/null +++ b/Makefile @@ -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 diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..33314a0 --- /dev/null +++ b/NOTICE @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fc530c --- /dev/null +++ b/README.md @@ -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. diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..1ec6009 --- /dev/null +++ b/checker/collect.go @@ -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 +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..bcff0d0 --- /dev/null +++ b/checker/definition.go @@ -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, + }, + } +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..d3c6767 --- /dev/null +++ b/checker/provider.go @@ -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() +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..7317de9 --- /dev/null +++ b/checker/report.go @@ -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(` + + + + +Zonemaster{{if .Domain}} — {{.Domain}}{{end}} + + + + +
+

Zonemaster{{if .Domain}} — {{.Domain}}{{end}}

+
+ {{- if .CreatedAt}}Run at {{.CreatedAt}}{{end -}} + {{- if and .CreatedAt .HashID}} · {{end -}} + {{- if .HashID}}ID: {{.HashID}}{{end -}} +
+
+ {{- range .Totals}} + {{.Level}} {{.Count}} + {{- end}} +
+
+ +{{range .Modules -}} + + + {{.Name}} + + {{- range .Levels}} + {{.Count}} + {{- end}} + + +
+ {{- range .Results}} +
+ {{.Level}} +
+
{{.Message}}
+ {{- if .Testcase}}
{{.Testcase}}
{{end}} +
+
+ {{- end}} +
+ +{{end -}} + + +`), +) + +// 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 +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..f336ba5 --- /dev/null +++ b/checker/rule.go @@ -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, + } +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..5f70f4f --- /dev/null +++ b/checker/types.go @@ -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"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dd1b138 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.happydns.org/checker-zonemaster + +go 1.25.0 + +require git.happydns.org/checker-sdk-go v0.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5282be1 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..2c212db --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..f1c2846 --- /dev/null +++ b/plugin/plugin.go @@ -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 +}