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

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"`
}