Initial commit
This commit is contained in:
commit
979757b5a8
15 changed files with 966 additions and 0 deletions
14
Dockerfile
Normal file
14
Dockerfile
Normal 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
21
LICENSE
Normal 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
25
Makefile
Normal 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
26
NOTICE
Normal 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
92
README.md
Normal 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
141
checker/collect.go
Normal 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
87
checker/definition.go
Normal 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
21
checker/provider.go
Normal 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
299
checker/report.go
Normal 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}} · {{end -}}
|
||||
{{- if .HashID}}ID: <code>{{.HashID}}</code>{{end -}}
|
||||
</div>
|
||||
<div class="totals">
|
||||
{{- range .Totals}}
|
||||
<span class="badge {{badgeClass .Level}}">{{.Level}} {{.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
112
checker/rule.go
Normal 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
67
checker/types.go
Normal 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
5
go.mod
Normal 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
2
go.sum
Normal 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
30
main.go
Normal 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
24
plugin/plugin.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue