diff --git a/.drone-manifest.yml b/.drone-manifest.yml new file mode 100644 index 0000000..28e620c --- /dev/null +++ b/.drone-manifest.yml @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..4077035 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,187 @@ +--- +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 new file mode 100644 index 0000000..cd37116 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-zonemaster +checker-zonemaster.so diff --git a/Dockerfile b/Dockerfile index ddefd02..dc5ffda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,5 +10,8 @@ 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 002307b..ae3f98c 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 clean +.PHONY: all plugin docker test clean all: $(CHECKER_NAME) $(CHECKER_NAME): $(CHECKER_SOURCES) - go build -ldflags "$(GO_LDFLAGS)" -o $@ . + go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . plugin: $(CHECKER_NAME).so @@ -21,5 +21,8 @@ $(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 9fc530c..93353f5 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,20 @@ 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 | @@ -64,29 +78,21 @@ collection to this endpoint. | User | `language` | Result language (`en`, `fr`, `de`, …) | | Admin | `zonemasterAPIURL` | Zonemaster JSON-RPC endpoint (default: official API) | -## Protocol +## Rules -### POST /collect +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. -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. +| 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 | ## 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. +MIT (see `LICENSE`). Third-party attributions in `NOTICE`. diff --git a/checker/collect.go b/checker/collect.go index 1ec6009..7ee02f1 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "strings" "time" @@ -13,6 +14,37 @@ 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 == "" { @@ -36,6 +68,11 @@ 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, @@ -56,9 +93,10 @@ func (p *zonemasterProvider) Collect(ctx context.Context, opts sdk.CheckerOption } // Step 2: poll for completion. - ticker := time.NewTicker(2 * time.Second) + ticker := time.NewTicker(pollInterval) defer ticker.Stop() +poll: for { select { case <-ctx.Done(): @@ -75,12 +113,11 @@ func (p *zonemasterProvider) Collect(ctx context.Context, opts sdk.CheckerOption } if progress >= 100 { - goto testComplete + break poll } } } -testComplete: // Step 3: fetch results. rawResults, err := zmCallJSONRPC(ctx, apiURL, "get_test_results", zmGetResultsParams{ ID: testID, @@ -117,19 +154,37 @@ func zmCallJSONRPC(ctx context.Context, apiURL, method string, params any) (json } req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := zmHTTPClient.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, _ := io.ReadAll(resp.Body) + 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] + } 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.NewDecoder(resp.Body).Decode(&rpcResp); err != nil { + if err := json.Unmarshal(body, &rpcResp); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } diff --git a/checker/definition.go b/checker/definition.go index bcff0d0..b94bb7b 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 Definition() *sdk.CheckerDefinition { +func (p *zonemasterProvider) Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "zonemaster", Name: "Zonemaster", @@ -75,13 +75,11 @@ func Definition() *sdk.CheckerDefinition { }, }, }, - Rules: []sdk.CheckRule{ - Rule(), - }, + Rules: Rules(), Interval: &sdk.CheckIntervalSpec{ - Min: 1 * time.Hour, - Max: 7 * 24 * time.Hour, - Default: 24 * time.Hour, + Min: 12 * time.Hour, + Max: 30 * 24 * time.Hour, + Default: 7 * 24 * time.Hour, }, } } diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..95dcca6 --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,69 @@ +//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 d3c6767..3efec93 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -14,8 +14,3 @@ 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 b241b15..834274e 100644 --- a/checker/report.go +++ b/checker/report.go @@ -15,13 +15,14 @@ import ( // 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 -}() +var zmLevelRank = map[string]int{ + "CRITICAL": 6, + "ERROR": 5, + "WARNING": 4, + "NOTICE": 3, + "INFO": 2, + "DEBUG": 1, +} type zmLevelCount struct { Level string @@ -50,7 +51,7 @@ var zonemasterHTMLTemplate = template.Must( template.New("zonemaster"). Funcs(template.FuncMap{ "badgeClass": func(level string) string { - switch strings.ToUpper(level) { + switch normLevel(level) { case "CRITICAL": return "badge-critical" case "ERROR": @@ -71,7 +72,7 @@ var zonemasterHTMLTemplate = template.Must( -Zonemaster{{if .Domain}} — {{.Domain}}{{end}} +Zonemaster{{if .Domain}}, {{.Domain}}{{end}}