diff --git a/.drone-manifest.yml b/.drone-manifest.yml deleted file mode 100644 index 28e620c..0000000 --- a/.drone-manifest.yml +++ /dev/null @@ -1,22 +0,0 @@ -image: happydomain/checker-zonemaster:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} -{{#if build.tags}} -tags: -{{#each build.tags}} - - {{this}} -{{/each}} -{{/if}} -manifests: - - image: happydomain/checker-zonemaster:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 - platform: - architecture: amd64 - os: linux - - image: happydomain/checker-zonemaster:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 - platform: - architecture: arm64 - os: linux - variant: v8 - - image: happydomain/checker-zonemaster:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm - platform: - architecture: arm - os: linux - variant: v7 diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 4077035..0000000 --- a/.drone.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -kind: pipeline -type: docker -name: build-amd64 - -platform: - os: linux - arch: amd64 - -steps: - - name: checker build - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" - CGO_ENABLED: 0 - when: - event: - exclude: - - tag - - - name: checker build tag - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_SEMVER}" - CGO_ENABLED: 0 - when: - event: - - tag - - - name: publish on Docker Hub - image: plugins/docker - settings: - repo: happydomain/checker-zonemaster - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - exclude: - - tag - - - name: publish on Docker Hub (tag) - image: plugins/docker - settings: - repo: happydomain/checker-zonemaster - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_SEMVER} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - - tag - -trigger: - branch: - exclude: - - renovate/* - event: - - cron - - push - - tag - ---- -kind: pipeline -type: docker -name: build-arm64 - -platform: - os: linux - arch: arm64 - -steps: - - name: checker build - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" - CGO_ENABLED: 0 - when: - event: - exclude: - - tag - - - name: checker build tag - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_SEMVER}" - CGO_ENABLED: 0 - when: - event: - - tag - - - name: publish on Docker Hub - image: plugins/docker - settings: - repo: happydomain/checker-zonemaster - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - exclude: - - tag - - - name: publish on Docker Hub (tag) - image: plugins/docker - settings: - repo: happydomain/checker-zonemaster - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_SEMVER} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - - tag - -trigger: - event: - - cron - - push - - tag - ---- -kind: pipeline -name: docker-manifest - -platform: - os: linux - arch: arm64 - -steps: - - name: publish on Docker Hub - image: plugins/manifest - settings: - auto_tag: true - ignore_missing: true - spec: .drone-manifest.yml - username: - from_secret: docker_username - password: - from_secret: docker_password - -trigger: - branch: - exclude: - - renovate/* - event: - - cron - - push - - tag - -depends_on: - - build-amd64 - - build-arm64 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index cd37116..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -checker-zonemaster -checker-zonemaster.so diff --git a/Dockerfile b/Dockerfile index dc5ffda..ddefd02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,5 @@ RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /che FROM scratch COPY --from=builder /checker-zonemaster /checker-zonemaster -USER 65534:65534 EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD ["/checker-zonemaster", "-healthcheck"] ENTRYPOINT ["/checker-zonemaster"] diff --git a/Makefile b/Makefile index ae3f98c..002307b 100644 --- a/Makefile +++ b/Makefile @@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go) GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) -.PHONY: all plugin docker test clean +.PHONY: all plugin docker clean all: $(CHECKER_NAME) $(CHECKER_NAME): $(CHECKER_SOURCES) - go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + go build -ldflags "$(GO_LDFLAGS)" -o $@ . plugin: $(CHECKER_NAME).so @@ -21,8 +21,5 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) docker: docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . -test: - go test -tags standalone ./... - clean: rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/README.md b/README.md index 93353f5..9fc530c 100644 --- a/README.md +++ b/README.md @@ -55,20 +55,6 @@ the running checker-zonemaster server (e.g., `http://checker-zonemaster:8080`). happyDomain will delegate observation collection to this endpoint. -### Deployment - -The `/collect` endpoint has no built-in authentication and will issue -JSON-RPC calls to whatever Zonemaster API URL is configured via the -`zonemasterAPIURL` admin option (defaulting to the official public API -at `https://zonemaster.net/api`). Operators should point this option -only at trusted Zonemaster instances; pointing it at an untrusted host -turns the checker into an SSRF vector, since responses are parsed and -surfaced back to the caller. The checker itself is meant to run on a -trusted network, reachable only by the happyDomain instance that drives -it. Restrict access via a reverse proxy with authentication, a network -ACL, or by binding the listener to a private interface; do not expose -it directly to the public internet. - ## Options | Scope | Id | Description | @@ -78,21 +64,29 @@ it directly to the public internet. | User | `language` | Result language (`en`, `fr`, `de`, …) | | Admin | `zonemasterAPIURL` | Zonemaster JSON-RPC endpoint (default: official API) | -## Rules +## Protocol -Each rule wraps one Zonemaster test module and emits a `.summary` -state plus one `.` state per WARNING-or-worse Zonemaster -message, so downstream consumers can match on stable codes. +### POST /collect -| Code | Description | Severity | -|---------------------------|-----------------------------------------------------------------------------------|----------| -| `zonemaster.dnssec` | DNSSEC tests (signatures, NSEC/NSEC3, DS/DNSKEY coherence). | CRITICAL | -| `zonemaster.delegation` | Delegation tests (parent/child NS agreement, glue, referrals). | CRITICAL | -| `zonemaster.consistency` | Consistency tests (SOA serial, NS set, zone content across servers). | CRITICAL | -| `zonemaster.connectivity` | Connectivity tests (UDP/TCP reachability of authoritative servers, AS diversity). | CRITICAL | -| `zonemaster.nameserver` | Nameserver tests (server behaviour, EDNS, unknown RR handling). | CRITICAL | -| `zonemaster.syntax` | Syntax tests (domain name syntax, hostname legality). | CRITICAL | +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 -MIT (see `LICENSE`). Third-party attributions in `NOTICE`. +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 index 7ee02f1..1ec6009 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "net" "net/http" "strings" "time" @@ -14,37 +13,6 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// maxResponseBytes caps the body size we'll read from the Zonemaster API. -// Real result payloads are tens to a few hundred KB; 8 MiB is generous head- -// room and still bounded so a misbehaving or hostile endpoint can't exhaust -// memory. -const maxResponseBytes = 8 << 20 - -// maxCollectDuration caps the total time spent collecting (start + poll + -// fetch). The caller's context still wins if it has a tighter deadline. -const maxCollectDuration = 15 * time.Minute - -// pollInterval is how often we ask the Zonemaster API for test progress. -const pollInterval = 2 * time.Second - -// zmHTTPClient is the HTTP client used for all Zonemaster API calls. It has -// per-phase timeouts so a stalling endpoint can never hang us indefinitely -// even if the caller passes a context without a deadline. -var zmHTTPClient = &http.Client{ - Timeout: 60 * time.Second, - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - TLSHandshakeTimeout: 10 * time.Second, - ResponseHeaderTimeout: 30 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - IdleConnTimeout: 90 * time.Second, - }, -} - func (p *zonemasterProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { domainName, ok := opts["domainName"].(string) if !ok || domainName == "" { @@ -68,11 +36,6 @@ func (p *zonemasterProvider) Collect(ctx context.Context, opts sdk.CheckerOption profile = prof } - // Cap the total collection time even when the caller's context has no - // deadline. The caller's deadline still wins if it's tighter. - ctx, cancel := context.WithTimeout(ctx, maxCollectDuration) - defer cancel() - // Step 1: start the test. startResult, err := zmCallJSONRPC(ctx, apiURL, "start_domain_test", zmStartTestParams{ Domain: domainName, @@ -93,10 +56,9 @@ func (p *zonemasterProvider) Collect(ctx context.Context, opts sdk.CheckerOption } // Step 2: poll for completion. - ticker := time.NewTicker(pollInterval) + ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() -poll: for { select { case <-ctx.Done(): @@ -113,11 +75,12 @@ poll: } if progress >= 100 { - break poll + goto testComplete } } } +testComplete: // Step 3: fetch results. rawResults, err := zmCallJSONRPC(ctx, apiURL, "get_test_results", zmGetResultsParams{ ID: testID, @@ -154,37 +117,19 @@ func zmCallJSONRPC(ctx context.Context, apiURL, method string, params any) (json } req.Header.Set("Content-Type", "application/json") - resp, err := zmHTTPClient.Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to call API: %w", err) } defer resp.Body.Close() - // Cap the body we'll ever read so a misbehaving endpoint can't exhaust - // memory. +1 lets us detect that the cap was hit. - limited := io.LimitReader(resp.Body, maxResponseBytes+1) - if resp.StatusCode != http.StatusOK { - b, readErr := io.ReadAll(limited) - if readErr != nil { - return nil, fmt.Errorf("API returned status %d (failed to read body: %v)", resp.StatusCode, readErr) - } - if len(b) > maxResponseBytes { - b = b[:maxResponseBytes] - } + b, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(b)) } - body, readErr := io.ReadAll(limited) - if readErr != nil { - return nil, fmt.Errorf("failed to read response: %w", readErr) - } - if len(body) > maxResponseBytes { - return nil, fmt.Errorf("API response exceeds %d bytes", maxResponseBytes) - } - var rpcResp zmJSONRPCResponse - if err := json.Unmarshal(body, &rpcResp); err != nil { + if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } diff --git a/checker/definition.go b/checker/definition.go index b94bb7b..bcff0d0 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -17,7 +17,7 @@ import ( var Version = "built-in" // Definition returns the CheckerDefinition for the zonemaster checker. -func (p *zonemasterProvider) Definition() *sdk.CheckerDefinition { +func Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "zonemaster", Name: "Zonemaster", @@ -75,11 +75,13 @@ func (p *zonemasterProvider) Definition() *sdk.CheckerDefinition { }, }, }, - Rules: Rules(), + Rules: []sdk.CheckRule{ + Rule(), + }, Interval: &sdk.CheckIntervalSpec{ - Min: 12 * time.Hour, - Max: 30 * 24 * time.Hour, - Default: 7 * 24 * time.Hour, + Min: 1 * time.Hour, + Max: 7 * 24 * time.Hour, + Default: 24 * time.Hour, }, } } diff --git a/checker/interactive.go b/checker/interactive.go deleted file mode 100644 index 95dcca6..0000000 --- a/checker/interactive.go +++ /dev/null @@ -1,69 +0,0 @@ -//go:build standalone - -package checker - -import ( - "errors" - "net/http" - "strings" - - sdk "git.happydns.org/checker-sdk-go/checker" -) - -func (p *zonemasterProvider) RenderForm() []sdk.CheckerOptionField { - return []sdk.CheckerOptionField{ - { - Id: "domainName", - Type: "string", - Label: "Domain name to check", - Placeholder: "example.com", - Required: true, - Description: "Fully-qualified domain name to submit to the Zonemaster engine.", - }, - { - Id: "profile", - Type: "string", - Label: "Profile", - Placeholder: "default", - Description: "Zonemaster test profile to apply (engine-defined; usually \"default\").", - }, - { - Id: "language", - Type: "string", - Label: "Result language", - Placeholder: "en", - Description: "Language for human-readable test messages (en, fr, de, es, sv, da, fi, nb, nl, pt).", - }, - { - Id: "zonemasterAPIURL", - Type: "string", - Label: "Zonemaster API URL", - Placeholder: "https://zonemaster.net/api", - Description: "JSON-RPC endpoint of the Zonemaster backend to query.", - }, - } -} - -func (p *zonemasterProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { - domainName := strings.TrimSpace(r.FormValue("domainName")) - if domainName == "" { - return nil, errors.New("domainName is required") - } - domainName = strings.TrimSuffix(domainName, ".") - - opts := sdk.CheckerOptions{ - "domainName": domainName, - } - - if v := strings.TrimSpace(r.FormValue("profile")); v != "" { - opts["profile"] = v - } - if v := strings.TrimSpace(r.FormValue("language")); v != "" { - opts["language"] = v - } - if v := strings.TrimSpace(r.FormValue("zonemasterAPIURL")); v != "" { - opts["zonemasterAPIURL"] = strings.TrimSuffix(v, "/") - } - - return opts, nil -} diff --git a/checker/provider.go b/checker/provider.go index 3efec93..d3c6767 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -14,3 +14,8 @@ 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 index 834274e..b241b15 100644 --- a/checker/report.go +++ b/checker/report.go @@ -15,14 +15,13 @@ import ( // zmLevelDisplayOrder defines the severity order used for sorting and display. var zmLevelDisplayOrder = []string{"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"} -var zmLevelRank = map[string]int{ - "CRITICAL": 6, - "ERROR": 5, - "WARNING": 4, - "NOTICE": 3, - "INFO": 2, - "DEBUG": 1, -} +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 @@ -51,7 +50,7 @@ var zonemasterHTMLTemplate = template.Must( template.New("zonemaster"). Funcs(template.FuncMap{ "badgeClass": func(level string) string { - switch normLevel(level) { + switch strings.ToUpper(level) { case "CRITICAL": return "badge-critical" case "ERROR": @@ -72,7 +71,7 @@ var zonemasterHTMLTemplate = template.Must( -Zonemaster{{if .Domain}}, {{.Domain}}{{end}} +Zonemaster{{if .Domain}} — {{.Domain}}{{end}}