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..4b0124c 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 | 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}}