diff --git a/README.md b/README.md index 49767f7..ed5f3f6 100644 --- a/README.md +++ b/README.md @@ -30,53 +30,6 @@ go get git.happydns.org/checker-sdk-go/checker See [checker-dummy](https://git.happydns.org/checker-dummy) for a fully working, documented template. -## Extending the server - -`checker.Server` exposes the standard SDK routes (`/health`, `/collect`, -and, depending on the provider's optional interfaces, `/definition`, -`/evaluate`, `/report`). Plugins that need to serve auxiliary endpoints -(debug pages, webhooks, custom UI assets, …) can register them on the -same mux: - -```go -srv := checker.NewServer(provider) - -srv.HandleFunc("GET /debug/state", func(w http.ResponseWriter, r *http.Request) { - // … -}) - -// Opt a custom route into the in-flight / load-average signal -// reported on /health: -srv.Handle("POST /webhook", srv.TrackWork(myWebhookHandler)) - -log.Fatal(srv.ListenAndServe(":8080")) -``` - -Patterns that collide with built-in routes panic at registration: -pick non-overlapping paths. Custom handlers are not wrapped by the -load-tracking middleware unless you opt in via `TrackWork`. - -## Standalone human UI (`/check`) - -Providers that implement `CheckerInteractive` get a built-in human-facing -web form on `/check`, usable outside of happyDomain: - -```go -type CheckerInteractive interface { - RenderForm() []CheckerOptionField - ParseForm(r *http.Request) (CheckerOptions, error) -} -``` - -- `GET /check` renders a form derived from `RenderForm()`. -- `POST /check` calls `ParseForm` to obtain `CheckerOptions`, runs the - standard `Collect` → `Evaluate` → `GetHTMLReport` / `ExtractMetrics` - pipeline, and returns a consolidated HTML page. - -`ParseForm` is where the checker replaces what happyDomain would normally -auto-fill (zone records, service payload, …), typically by issuing its -own DNS queries from the human-supplied inputs. - ## License Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/checker/names.go b/checker/names.go deleted file mode 100644 index 61df52e..0000000 --- a/checker/names.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2020-2026 The happyDomain Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package checker - -import "strings" - -// JoinRelative treats name as relative to origin, as happyDomain encodes -// service-embedded record owners and subdomains. An empty or "@" name -// resolves to the origin itself; an empty origin returns the trimmed name -// unchanged. A name already suffixed by origin is returned as-is so that -// absolute encodings round-trip safely. Trailing dots are stripped. -func JoinRelative(name, origin string) string { - origin = strings.TrimSuffix(origin, ".") - name = strings.TrimSuffix(name, ".") - if origin == "" { - return name - } - if name == "" || name == "@" { - return origin - } - if name == origin || strings.HasSuffix(name, "."+origin) { - return name - } - return name + "." + origin -} diff --git a/checker/registry_test.go b/checker/registry_test.go index ca7450a..e4170b2 100644 --- a/checker/registry_test.go +++ b/checker/registry_test.go @@ -74,7 +74,7 @@ func TestRegisterExternalizableChecker_AppendsEndpointOnce(t *testing.T) { } // Second registration of the same definition pointer must NOT append a - // second "endpoint" AdminOpt, the duplicate check has to fire before + // second "endpoint" AdminOpt — the duplicate check has to fire before // the append, otherwise we silently mutate the live definition. RegisterExternalizableChecker(c) if n := len(c.Options.AdminOpts); n != 1 { diff --git a/checker/server/server.go b/checker/server.go similarity index 54% rename from checker/server/server.go rename to checker/server.go index 763a600..8b2eb31 100644 --- a/checker/server/server.go +++ b/checker/server.go @@ -12,11 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package server provides the HTTP server scaffolding used by standalone -// checkers. It is separated from the core checker package so that plugin -// and builtin builds, which never expose an HTTP endpoint, do not pay the -// cost of net/http, html/template, and their transitive dependencies. -package server +package checker import ( "context" @@ -26,16 +22,11 @@ import ( "log" "math" "net/http" - "os" - "os/signal" "runtime" "strings" "sync" "sync/atomic" - "syscall" "time" - - "git.happydns.org/checker-sdk-go/checker" ) // maxRequestBodySize is the maximum allowed size for incoming request bodies (1 MB). @@ -46,10 +37,6 @@ const maxRequestBodySize = 1 << 20 // 5 seconds matches the Unix kernel's loadavg cadence. const loadSampleInterval = 5 * time.Second -// shutdownTimeout bounds how long ListenAndServe waits for in-flight -// requests to drain after receiving SIGINT or SIGTERM. -const shutdownTimeout = 10 * time.Second - // EWMA smoothing factors for 1, 5, and 15-minute windows sampled every // loadSampleInterval. Derived as 1 - exp(-interval/window) so that the // steady-state response to a constant InFlight of N converges to N. @@ -71,21 +58,19 @@ func updateLoadAvg(prev [3]float64, sample float64) [3]float64 { // Server is a generic HTTP server for external checkers. // It always exposes /health and /collect. If the provider implements -// checker.CheckerDefinitionProvider, it also exposes /definition and /evaluate. -// If the provider implements checker.CheckerHTMLReporter or checker.CheckerMetricsReporter, -// it also exposes /report. If the provider implements Interactive, -// it also exposes /check (a human-facing web form). +// CheckerDefinitionProvider, it also exposes /definition and /evaluate. +// If the provider implements CheckerHTMLReporter or CheckerMetricsReporter, +// it also exposes /report. // // Security: Server does not perform any authentication or authorization. // It is intended to be run behind a reverse proxy or in a trusted network // where access control is handled externally (e.g. by the happyDomain server). type Server struct { - provider checker.ObservationProvider - definition *checker.CheckerDefinition - interactive Interactive - mux *http.ServeMux + provider ObservationProvider + definition *CheckerDefinition + mux *http.ServeMux - // startTime is captured in New and used to compute uptime. + // startTime is captured in NewServer and used to compute uptime. startTime time.Time // inFlight counts work requests (/collect, /evaluate, /report) currently @@ -110,13 +95,13 @@ type Server struct { closeOnce sync.Once } -// New creates a new checker HTTP server backed by the given provider. +// NewServer creates a new checker HTTP server backed by the given provider. // Additional endpoints are registered based on optional interfaces the provider implements. // -// New also starts a background goroutine that samples the in-flight +// NewServer also starts a background goroutine that samples the in-flight // request count every loadSampleInterval to compute the load averages // reported on /health. Call Close to stop it. -func New(provider checker.ObservationProvider) *Server { +func NewServer(provider ObservationProvider) *Server { ctx, cancel := context.WithCancel(context.Background()) s := &Server{ provider: provider, @@ -126,27 +111,19 @@ func New(provider checker.ObservationProvider) *Server { } s.mux = http.NewServeMux() s.mux.HandleFunc("GET /health", s.handleHealth) - s.mux.Handle("POST /collect", s.TrackWork(http.HandlerFunc(s.handleCollect))) + s.mux.Handle("POST /collect", s.trackWork(http.HandlerFunc(s.handleCollect))) - if dp, ok := provider.(checker.CheckerDefinitionProvider); ok { - if def := dp.Definition(); def != nil { - s.definition = def - s.definition.BuildRulesInfo() - s.mux.HandleFunc("GET /definition", s.handleDefinition) - s.mux.Handle("POST /evaluate", s.TrackWork(http.HandlerFunc(s.handleEvaluate))) - } + if dp, ok := provider.(CheckerDefinitionProvider); ok { + s.definition = dp.Definition() + s.definition.BuildRulesInfo() + s.mux.HandleFunc("GET /definition", s.handleDefinition) + s.mux.Handle("POST /evaluate", s.trackWork(http.HandlerFunc(s.handleEvaluate))) } - if _, ok := provider.(checker.CheckerHTMLReporter); ok { - s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport))) - } else if _, ok := provider.(checker.CheckerMetricsReporter); ok { - s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport))) - } - - if ip, ok := provider.(Interactive); ok { - s.interactive = ip - s.mux.HandleFunc("GET /check", s.handleCheckForm) - s.mux.Handle("POST /check", s.TrackWork(http.HandlerFunc(s.handleCheckSubmit))) + if _, ok := provider.(CheckerHTMLReporter); ok { + s.mux.Handle("POST /report", s.trackWork(http.HandlerFunc(s.handleReport))) + } else if _, ok := provider.(CheckerMetricsReporter); ok { + s.mux.Handle("POST /report", s.trackWork(http.HandlerFunc(s.handleReport))) } go s.runSampler(ctx) @@ -160,82 +137,19 @@ func (s *Server) Handler() http.Handler { return requestLogger(s.mux) } -// Handle registers an auxiliary handler on the server's mux. Must be called -// before ListenAndServe or Handler(). Custom handlers are not tracked by -// TrackWork; wrap them explicitly if you want them counted in /health load. -func (s *Server) Handle(pattern string, handler http.Handler) { - s.mux.Handle(pattern, handler) -} - -// HandleFunc is the http.HandlerFunc-flavoured counterpart of Handle. -func (s *Server) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) { - s.mux.HandleFunc(pattern, handler) -} - -// ListenAndServe starts the HTTP server on the given address and blocks -// until the server stops. +// ListenAndServe starts the HTTP server on the given address. // -// ListenAndServe installs a SIGINT/SIGTERM handler that triggers a graceful -// shutdown: new connections are refused and in-flight requests are given up -// to shutdownTimeout to complete. The background load-average sampler is -// stopped via Close before returning. Callers who need their own signal -// handling or shutdown semantics should use Handler() and run their own -// http.Server instead. -// -// If the consumer's flag.Parse() set the SDK-registered -healthcheck flag, -// ListenAndServe never starts the server: it probes /health on addr and calls -// os.Exit(0) on success or os.Exit(1) on failure. This is what lets a -// scratch-based Docker image use the binary itself as its HEALTHCHECK probe. +// ListenAndServe does not stop the background load-average sampler on return; +// call Close to stop it. This is not required for process-scoped usage but is +// recommended for tests and embedded lifecycles. func (s *Server) ListenAndServe(addr string) error { - if *healthcheckMode { - if err := runHealthcheck(addr); err != nil { - fmt.Fprintln(os.Stderr, "healthcheck failed:", err) - os.Exit(1) - } - os.Exit(0) - } - - srv := &http.Server{Addr: addr, Handler: requestLogger(s.mux)} - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - defer signal.Stop(sigCh) - - shutdownErr := make(chan error, 1) - go func() { - sig, ok := <-sigCh - if !ok { - shutdownErr <- nil - return - } - log.Printf("checker received %s, shutting down (timeout %s)", sig, shutdownTimeout) - ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) - defer cancel() - shutdownErr <- srv.Shutdown(ctx) - }() - log.Printf("checker listening on %s", addr) - err := srv.ListenAndServe() - signal.Stop(sigCh) - close(sigCh) - - if err == http.ErrServerClosed { - if sErr := <-shutdownErr; sErr != nil { - err = sErr - } else { - err = nil - } - } - - if cErr := s.Close(); cErr != nil && err == nil { - err = cErr - } - return err + return http.ListenAndServe(addr, requestLogger(s.mux)) } // Close stops the background load-average sampler goroutine. It is safe to // call multiple times; subsequent calls are no-ops. Close does not shut down -// any underlying http.Server, callers own that lifecycle. +// any underlying http.Server — callers own that lifecycle. func (s *Server) Close() error { s.closeOnce.Do(func() { s.cancelSampler() @@ -244,9 +158,10 @@ func (s *Server) Close() error { return nil } -// TrackWork wraps a handler with in-flight and total-request accounting, -// opting custom routes into the load signal reported on /health. -func (s *Server) TrackWork(next http.Handler) http.Handler { +// trackWork wraps a handler with in-flight and total-request accounting. +// It is applied only to "work" endpoints (/collect, /evaluate, /report) so +// that /health polling traffic does not pollute the load signal. +func (s *Server) trackWork(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.inFlight.Add(1) s.totalRequests.Add(1) @@ -302,7 +217,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { for i := range load { load[i] = math.Float64frombits(s.loadBits[i].Load()) } - writeJSON(w, http.StatusOK, checker.HealthResponse{ + writeJSON(w, http.StatusOK, HealthResponse{ Status: "ok", Uptime: time.Since(s.startTime).Seconds(), NumCPU: runtime.NumCPU(), @@ -317,9 +232,9 @@ func (s *Server) handleDefinition(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) { - var req checker.ExternalCollectRequest + var req ExternalCollectRequest if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, checker.ExternalCollectResponse{ + writeJSON(w, http.StatusBadRequest, ExternalCollectResponse{ Error: fmt.Sprintf("invalid request body: %v", err), }) return @@ -327,7 +242,7 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) { data, err := s.provider.Collect(r.Context(), req.Options) if err != nil { - writeJSON(w, http.StatusInternalServerError, checker.ExternalCollectResponse{ + writeJSON(w, http.StatusInternalServerError, ExternalCollectResponse{ Error: err.Error(), }) return @@ -335,18 +250,18 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) { raw, err := json.Marshal(data) if err != nil { - writeJSON(w, http.StatusInternalServerError, checker.ExternalCollectResponse{ + writeJSON(w, http.StatusInternalServerError, ExternalCollectResponse{ Error: fmt.Sprintf("failed to marshal result: %v", err), }) return } - resp := checker.ExternalCollectResponse{Data: json.RawMessage(raw)} + resp := ExternalCollectResponse{Data: json.RawMessage(raw)} // Harvest discovery entries from the native Go value, before it goes // out of scope. No re-parse; DiscoverEntries operates on the same // object that was just marshaled above. - if dp, ok := s.provider.(checker.DiscoveryPublisher); ok { + if dp, ok := s.provider.(DiscoveryPublisher); ok { entries, derr := dp.DiscoverEntries(data) if derr != nil { log.Printf("DiscoverEntries failed: %v", derr) @@ -358,47 +273,36 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } -// evaluateRules runs all definition rules against obs/opts, skipping any rule -// whose name maps to false in enabledRules (nil means run all). -func (s *Server) evaluateRules(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions, enabledRules map[string]bool) []checker.CheckState { - var states []checker.CheckState - for _, rule := range s.definition.Rules { - if len(enabledRules) > 0 { - if enabled, ok := enabledRules[rule.Name()]; ok && !enabled { - continue - } - } - ruleStates := rule.Evaluate(ctx, obs, opts) - if len(ruleStates) == 0 { - ruleStates = []checker.CheckState{{ - Status: checker.StatusUnknown, - Message: fmt.Sprintf("rule %q returned no state", rule.Name()), - }} - } - for _, state := range ruleStates { - state.RuleName = rule.Name() - states = append(states, state) - } - } - return states -} - func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) { - var req checker.ExternalEvaluateRequest + var req ExternalEvaluateRequest if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, checker.ExternalEvaluateResponse{ + writeJSON(w, http.StatusBadRequest, ExternalEvaluateResponse{ Error: fmt.Sprintf("invalid request body: %v", err), }) return } obs := &mapObservationGetter{data: req.Observations} - states := s.evaluateRules(r.Context(), obs, req.Options, req.EnabledRules) - writeJSON(w, http.StatusOK, checker.ExternalEvaluateResponse{States: states}) + + var states []CheckState + for _, rule := range s.definition.Rules { + if len(req.EnabledRules) > 0 { + if enabled, ok := req.EnabledRules[rule.Name()]; ok && !enabled { + continue + } + } + state := rule.Evaluate(r.Context(), obs, req.Options) + if state.Code == "" { + state.Code = rule.Name() + } + states = append(states, state) + } + + writeJSON(w, http.StatusOK, ExternalEvaluateResponse{States: states}) } func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { - var req checker.ExternalReportRequest + var req ExternalReportRequest if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": fmt.Sprintf("invalid request body: %v", err), @@ -409,13 +313,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { accept := r.Header.Get("Accept") if strings.Contains(accept, "text/html") { - reporter, ok := s.provider.(checker.CheckerHTMLReporter) + reporter, ok := s.provider.(CheckerHTMLReporter) if !ok { http.Error(w, "this checker does not support HTML reports", http.StatusNotImplemented) return } - html, err := reporter.GetHTMLReport(checker.NewReportContext(req.Data, req.Related, req.States)) + html, err := reporter.GetHTMLReport(NewReportContext(req.Data, req.Related)) if err != nil { http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError) return @@ -427,13 +331,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { } // Default: JSON metrics. - reporter, ok := s.provider.(checker.CheckerMetricsReporter) + reporter, ok := s.provider.(CheckerMetricsReporter) if !ok { http.Error(w, "this checker does not support metrics reports", http.StatusNotImplemented) return } - metrics, err := reporter.ExtractMetrics(checker.NewReportContext(req.Data, req.Related, req.States), time.Now()) + metrics, err := reporter.ExtractMetrics(NewReportContext(req.Data, req.Related), time.Now()) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": fmt.Sprintf("failed to extract metrics: %v", err), @@ -444,16 +348,12 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, metrics) } -// mapObservationGetter implements checker.ObservationGetter backed by static maps. -// Both fields are optional: Get reads from data, GetRelated reads from -// related. Leaving related nil preserves the pre-existing "no lineage" -// behavior used by the remote /evaluate path. +// mapObservationGetter implements ObservationGetter backed by a static map. type mapObservationGetter struct { - data map[checker.ObservationKey]json.RawMessage - related map[checker.ObservationKey][]checker.RelatedObservation + data map[ObservationKey]json.RawMessage } -func (g *mapObservationGetter) Get(ctx context.Context, key checker.ObservationKey, dest any) error { +func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest any) error { raw, ok := g.data[key] if !ok { return fmt.Errorf("observation %q not available", key) @@ -461,13 +361,13 @@ func (g *mapObservationGetter) Get(ctx context.Context, key checker.ObservationK return json.Unmarshal(raw, dest) } -// GetRelated returns the pre-resolved related observations for key, or nil -// when none were seeded. The remote /evaluate path leaves related nil -// because ExternalEvaluateRequest does not currently carry cross-checker -// lineage; the interactive /check path can seed it from sibling providers -// declared via Siblings. -func (g *mapObservationGetter) GetRelated(ctx context.Context, key checker.ObservationKey) ([]checker.RelatedObservation, error) { - return g.related[key], nil +// GetRelated always returns nil in the remote /evaluate path: the host that +// invokes /evaluate does not (currently) carry cross-checker related data in +// ExternalEvaluateRequest. Consumers that need related observations must run +// evaluation locally with a host-side ObservationContext that resolves +// lineage. +func (g *mapObservationGetter) GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error) { + return nil, nil } func writeJSON(w http.ResponseWriter, status int, v any) { diff --git a/checker/server/healthcheck.go b/checker/server/healthcheck.go deleted file mode 100644 index bb79d22..0000000 --- a/checker/server/healthcheck.go +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright 2020-2026 The happyDomain Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package server - -import ( - "context" - "flag" - "fmt" - "net" - "net/http" - "strings" - "time" -) - -// healthcheckMode is registered on the default flag set so any consumer that -// calls flag.Parse() before ListenAndServe (the standard pattern in our -// checker mains) gets the behaviour for free. When set, ListenAndServe -// performs a short-lived HTTP probe against /health on the configured listen -// address and exits 0/1 instead of starting the server. This lets the same -// binary act as its own Docker HEALTHCHECK probe for scratch images, where -// no shell, curl or wget is available. -var healthcheckMode = flag.Bool( - "healthcheck", - false, - "probe /health on the server's listen address and exit 0 if healthy, 1 "+ - "otherwise (intended as a Docker HEALTHCHECK for scratch-based images)", -) - -// runHealthcheck performs a GET against http:///health with a short -// timeout. Returns nil on a 2xx response, an error otherwise. A bind address -// like ":8080" or "0.0.0.0:8080" is rewritten to dial the loopback interface -// so the probe targets the local process. -func runHealthcheck(addr string) error { - host, port, err := net.SplitHostPort(normalizeHealthcheckAddr(addr)) - if err != nil { - return fmt.Errorf("invalid listen addr %q: %w", addr, err) - } - if host == "" || host == "0.0.0.0" || host == "::" { - host = "127.0.0.1" - } - url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, port)) - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return err - } - client := &http.Client{Timeout: 2 * time.Second} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - return fmt.Errorf("unhealthy: HTTP %d", resp.StatusCode) - } - return nil -} - -func normalizeHealthcheckAddr(a string) string { - if strings.HasPrefix(a, ":") { - return "127.0.0.1" + a - } - if strings.HasPrefix(a, "[::]:") { - return "[::1]:" + strings.TrimPrefix(a, "[::]:") - } - return a -} diff --git a/checker/server/healthcheck_test.go b/checker/server/healthcheck_test.go deleted file mode 100644 index daa4bc5..0000000 --- a/checker/server/healthcheck_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2020-2026 The happyDomain Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package server - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestRunHealthcheck_OK(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - addr := strings.TrimPrefix(srv.URL, "http://") - if err := runHealthcheck(addr); err != nil { - t.Fatalf("runHealthcheck(%s) returned error: %v", addr, err) - } -} - -func TestRunHealthcheck_NonOK(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - addr := strings.TrimPrefix(srv.URL, "http://") - if err := runHealthcheck(addr); err == nil { - t.Fatalf("runHealthcheck against 503 returned nil; want error") - } -} - -func TestRunHealthcheck_Unreachable(t *testing.T) { - // Reserved-for-documentation port on loopback that nothing should bind. - if err := runHealthcheck("127.0.0.1:1"); err == nil { - t.Fatalf("runHealthcheck against unreachable port returned nil; want error") - } -} - -func TestNormalizeHealthcheckAddr(t *testing.T) { - cases := map[string]string{ - ":8080": "127.0.0.1:8080", - "127.0.0.1:8080": "127.0.0.1:8080", - "0.0.0.0:8080": "0.0.0.0:8080", - "[::1]:8080": "[::1]:8080", - "[::]:8080": "[::1]:8080", - } - for in, want := range cases { - if got := normalizeHealthcheckAddr(in); got != want { - t.Errorf("normalizeHealthcheckAddr(%q) = %q, want %q", in, got, want) - } - } -} diff --git a/checker/server/interactive.go b/checker/server/interactive.go deleted file mode 100644 index 27cc260..0000000 --- a/checker/server/interactive.go +++ /dev/null @@ -1,453 +0,0 @@ -// Copyright 2020-2026 The happyDomain Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package server - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "html/template" - "log" - "maps" - "net/http" - "time" - - "git.happydns.org/checker-sdk-go/checker" -) - -// Interactive is an optional interface that observation providers -// can implement to expose a human-facing web form usable standalone, -// outside of a happyDomain host. Detect support with a type assertion: -// _, ok := provider.(server.Interactive). -// -// When the provider implements it, Server binds GET and POST on /check. -// GET renders an HTML form built from RenderForm(). POST calls ParseForm -// to obtain the checker.CheckerOptions, then runs the standard pipeline -// (Collect, Evaluate, GetHTMLReport, ExtractMetrics) and renders a -// consolidated result page. -// -// Unlike /evaluate, which relies on happyDomain to fill AutoFill-backed -// options from execution context, an Interactive implementation is -// responsible for resolving whatever it needs from the human inputs -// (typically via direct DNS queries) before Collect runs. -type Interactive interface { - // RenderForm returns the fields the human must fill in to bootstrap - // a check. Typically a minimal set (domain name, nameserver to - // query, …) that ParseForm expands into the full CheckerOptions - // that Collect expects. - RenderForm() []checker.CheckerOptionField - - // ParseForm reads the submitted form and returns the CheckerOptions - // ready to feed Collect. It is the checker's responsibility to do - // whatever lookups or resolutions are needed to populate fields - // that would normally be auto-filled by happyDomain. Returning an - // error causes the SDK to re-render the form with the error - // displayed. - ParseForm(r *http.Request) (checker.CheckerOptions, error) -} - -// Siblings is an optional interface an interactive ObservationProvider -// can co-implement to declare sibling providers whose Collect the SDK -// runs in-process during /check. Their results are exposed as -// RelatedObservations on ObservationGetter and ReportContext, mirroring -// the cross-checker lineage a happyDomain host resolves. -// -// For each sibling the SDK seeds options from the primary and, when the -// primary implements DiscoveryPublisher, writes its entries into any -// sibling option tagged AutoFill == checker.AutoFillDiscoveryEntries. -// Sibling errors are logged and skipped so the primary result still -// reaches the user. -type Siblings interface { - RelatedProviders() []checker.ObservationProvider -} - -// checkResult holds everything the result page needs to render. -type checkResult struct { - Title string - States []checker.CheckState - Metrics []checker.CheckMetric - ReportHTML string - CollectErr string - ReportErr string - MetricsErr string -} - -type checkFormPage struct { - Title string - Fields []checker.CheckerOptionField - Error string -} - -func (s *Server) handleCheckForm(w http.ResponseWriter, r *http.Request) { - s.renderCheckForm(w, s.interactive.RenderForm(), "") -} - -func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) { - fields := s.interactive.RenderForm() - if err := r.ParseForm(); err != nil { - s.renderCheckForm(w, fields, fmt.Sprintf("invalid form: %v", err)) - return - } - - opts, err := s.interactive.ParseForm(r) - if err != nil { - s.renderCheckForm(w, fields, err.Error()) - return - } - - result := &checkResult{Title: s.checkPageTitle()} - - data, err := s.provider.Collect(r.Context(), opts) - if err != nil { - result.CollectErr = err.Error() - s.renderCheckResult(w, result) - return - } - - raw, err := json.Marshal(data) - if err != nil { - result.CollectErr = fmt.Sprintf("failed to marshal collected data: %v", err) - s.renderCheckResult(w, result) - return - } - - related := s.collectRelatedObservations(r.Context(), opts, data) - - if s.definition != nil { - obs := &mapObservationGetter{ - data: map[checker.ObservationKey]json.RawMessage{ - s.provider.Key(): raw, - }, - related: related, - } - result.States = s.evaluateRules(r.Context(), obs, opts, nil) - } - - ctx := checker.NewReportContext(raw, related, result.States) - - if reporter, ok := s.provider.(checker.CheckerHTMLReporter); ok { - html, rerr := reporter.GetHTMLReport(ctx) - if rerr != nil { - result.ReportErr = rerr.Error() - } else { - result.ReportHTML = html - } - } - - if reporter, ok := s.provider.(checker.CheckerMetricsReporter); ok { - metrics, merr := reporter.ExtractMetrics(ctx, time.Now()) - if merr != nil { - result.MetricsErr = merr.Error() - } else { - result.Metrics = metrics - } - } - - s.renderCheckResult(w, result) -} - -// collectRelatedObservations runs sibling providers declared via Siblings -// and returns their results keyed by the sibling's observation key. -// Sibling errors are logged and skipped. -func (s *Server) collectRelatedObservations(ctx context.Context, opts checker.CheckerOptions, data any) map[checker.ObservationKey][]checker.RelatedObservation { - irp, ok := s.provider.(Siblings) - if !ok { - return nil - } - siblings := irp.RelatedProviders() - if len(siblings) == 0 { - return nil - } - - var entries []checker.DiscoveryEntry - if dp, ok := s.provider.(checker.DiscoveryPublisher); ok { - e, err := dp.DiscoverEntries(data) - if err != nil { - log.Printf("interactive: DiscoverEntries failed: %v", err) - } else { - entries = e - } - } - - related := make(map[checker.ObservationKey][]checker.RelatedObservation, len(siblings)) - for _, sp := range siblings { - sOpts := cloneOptions(opts) - siblingID := "" - if dp, ok := sp.(checker.CheckerDefinitionProvider); ok { - if def := dp.Definition(); def != nil { - siblingID = def.ID - if len(entries) > 0 { - fillDiscoveryEntryOption(sOpts, def, entries) - } - } - } - sData, err := sp.Collect(ctx, sOpts) - if err != nil { - log.Printf("interactive: sibling %q Collect failed: %v", sp.Key(), err) - continue - } - raw, err := json.Marshal(sData) - if err != nil { - log.Printf("interactive: sibling %q marshal failed: %v", sp.Key(), err) - continue - } - related[sp.Key()] = append(related[sp.Key()], checker.RelatedObservation{ - CheckerID: siblingID, - Key: sp.Key(), - Data: raw, - CollectedAt: time.Now(), - }) - } - return related -} - -func cloneOptions(opts checker.CheckerOptions) checker.CheckerOptions { - out := make(checker.CheckerOptions, len(opts)) - maps.Copy(out, opts) - return out -} - -// fillDiscoveryEntryOption mirrors the host's AutoFill wiring: it writes -// entries into every option in def tagged AutoFill == checker.AutoFillDiscoveryEntries. -func fillDiscoveryEntryOption(opts checker.CheckerOptions, def *checker.CheckerDefinition, entries []checker.DiscoveryEntry) { - scopes := [][]checker.CheckerOptionDocumentation{ - def.Options.AdminOpts, - def.Options.UserOpts, - def.Options.DomainOpts, - def.Options.ServiceOpts, - def.Options.RunOpts, - } - for _, scope := range scopes { - for _, f := range scope { - if f.AutoFill == checker.AutoFillDiscoveryEntries { - opts[f.Id] = entries - } - } - } -} - -func (s *Server) checkPageTitle() string { - if s.definition != nil && s.definition.Name != "" { - return s.definition.Name - } - return "Checker" -} - -func renderHTML(w http.ResponseWriter, status int, tpl *template.Template, data any) { - var buf bytes.Buffer - if err := tpl.Execute(&buf, data); err != nil { - log.Printf("render %s: %v", tpl.Name(), err) - http.Error(w, "failed to render page", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(status) - w.Write(buf.Bytes()) -} - -func (s *Server) renderCheckForm(w http.ResponseWriter, fields []checker.CheckerOptionField, errMsg string) { - status := http.StatusOK - if errMsg != "" { - status = http.StatusBadRequest - } - renderHTML(w, status, checkFormTemplate, checkFormPage{ - Title: s.checkPageTitle(), - Fields: fields, - Error: errMsg, - }) -} - -func (s *Server) renderCheckResult(w http.ResponseWriter, result *checkResult) { - renderHTML(w, http.StatusOK, checkResultTemplate, result) -} - -func statusClass(s checker.Status) string { - switch s { - case checker.StatusOK: - return "ok" - case checker.StatusInfo: - return "info" - case checker.StatusWarn: - return "warn" - case checker.StatusCrit: - return "crit" - case checker.StatusError: - return "error" - default: - return "unknown" - } -} - -// defaultString avoids printing the literal "" for unset defaults. -func defaultString(v any) string { - if v == nil { - return "" - } - switch t := v.(type) { - case string: - return t - case bool: - if t { - return "true" - } - return "" - default: - return fmt.Sprintf("%v", v) - } -} - -func defaultBool(v any) bool { - b, _ := v.(bool) - return b -} - -var templateFuncs = template.FuncMap{ - "statusClass": statusClass, - "statusString": checker.Status.String, - "defaultString": defaultString, - "defaultBool": defaultBool, -} - -const baseCSS = ` -body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 960px; margin: 2rem auto; padding: 0 1rem; color: #222; } -h1, h2 { border-bottom: 1px solid #eee; padding-bottom: 0.3rem; } -form { display: grid; gap: 1rem; } -label { display: block; font-weight: 600; margin-bottom: 0.25rem; } -.required::after { content: " *"; color: #c00; } -.desc { font-weight: normal; color: #666; font-size: 0.9rem; display: block; margin-top: 0.1rem; } -input[type=text], input[type=password], input[type=number], select, textarea { - width: 100%; padding: 0.5rem; border: 1px solid #bbb; border-radius: 4px; box-sizing: border-box; font: inherit; -} -textarea { min-height: 6rem; } -button { padding: 0.6rem 1.2rem; background: #0b63c5; color: #fff; border: 0; border-radius: 4px; font: inherit; cursor: pointer; } -button:hover { background: #084c98; } -.err { background: #fee; border: 1px solid #fbb; color: #900; padding: 0.6rem 0.8rem; border-radius: 4px; margin: 1rem 0; } -table { border-collapse: collapse; width: 100%; margin: 0.5rem 0 1.5rem; } -th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #eee; vertical-align: top; } -th { background: #f7f7f7; } -.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-size: 0.8rem; font-weight: 600; color: #fff; } -.badge.ok { background: #2a9d3c; } -.badge.info { background: #3277cc; } -.badge.warn { background: #d08a00; } -.badge.crit { background: #c0392b; } -.badge.error { background: #7a1f1f; } -.badge.unknown { background: #777; } -iframe.report { width: 100%; min-height: 480px; border: 1px solid #ccc; border-radius: 4px; } -.actions { margin-top: 1.5rem; } -.actions a { color: #0b63c5; text-decoration: none; } -.actions a:hover { text-decoration: underline; } -` - -var checkFormTemplate = template.Must(template.New("form").Funcs(templateFuncs).Parse(` - - - -{{.Title}} – Check - - - -

{{.Title}}

-{{if .Error}}
{{.Error}}
{{end}} -
-{{range .Fields}}{{if not .Hide}} -
- - {{if .Choices}} - - {{else if eq .Type "bool"}} - - {{else if .Textarea}} - - {{else if eq .Type "number"}} - - {{else if eq .Type "uint"}} - - {{else if .Secret}} - - {{else}} - - {{end}} -
-{{end}}{{end}} -
-
- -`)) - -var checkResultTemplate = template.Must(template.New("result").Funcs(templateFuncs).Parse(` - - - -{{.Title}} – Result - - - -

{{.Title}}

- -{{if .CollectErr}}
Collect failed: {{.CollectErr}}
{{end}} - -{{if .States}} -

Check states

- - - - {{range .States}} - - - - - - - - {{end}} - -
StatusRuleCodeSubjectMessage
{{statusString .Status}}{{.RuleName}}{{.Code}}{{.Subject}}{{.Message}}
-{{end}} - -{{if .Metrics}} -

Metrics

- - - - {{range .Metrics}} - - - - - - - {{end}} - -
NameValueUnitLabels
{{.Name}}{{.Value}}{{.Unit}}{{range $k, $v := .Labels}}{{$k}}={{$v}} {{end}}
-{{end}} - -{{if .MetricsErr}}
Metrics error: {{.MetricsErr}}
{{end}} - -{{if .ReportHTML}} -

Report

- -{{end}} - -{{if .ReportErr}}
Report error: {{.ReportErr}}
{{end}} - - - -`)) diff --git a/checker/server/interactive_test.go b/checker/server/interactive_test.go deleted file mode 100644 index 4ce7ade..0000000 --- a/checker/server/interactive_test.go +++ /dev/null @@ -1,439 +0,0 @@ -// Copyright 2020-2026 The happyDomain Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package server - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "git.happydns.org/checker-sdk-go/checker" -) - -// interactiveProvider embeds testProvider and adds Interactive. -type interactiveProvider struct { - *testProvider - fields []checker.CheckerOptionField - parseFn func(r *http.Request) (checker.CheckerOptions, error) - parseErr error -} - -func (p *interactiveProvider) RenderForm() []checker.CheckerOptionField { - return p.fields -} - -func (p *interactiveProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) { - if p.parseErr != nil { - return nil, p.parseErr - } - if p.parseFn != nil { - return p.parseFn(r) - } - return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil -} - -func postForm(handler http.Handler, path string, values url.Values) *httptest.ResponseRecorder { - req := httptest.NewRequest("POST", path, strings.NewReader(values.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - return rec -} - -// minimalProvider implements only ObservationProvider. -type minimalProvider struct{ key checker.ObservationKey } - -func (m *minimalProvider) Key() checker.ObservationKey { return m.key } -func (m *minimalProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { - return nil, nil -} - -func TestCheck_NotRegistered_WhenProviderLacksInterface(t *testing.T) { - p := &minimalProvider{key: "test"} - srv := New(p) - defer srv.Close() - - rec := doRequest(srv.Handler(), "GET", "/check", nil, nil) - if rec.Code != http.StatusNotFound { - t.Fatalf("GET /check without Interactive = %d, want 404", rec.Code) - } -} - -func TestCheck_Form_Renders(t *testing.T) { - p := &interactiveProvider{ - testProvider: &testProvider{key: "test"}, - fields: []checker.CheckerOptionField{ - {Id: "domain", Type: "string", Label: "Domain name", Required: true, Placeholder: "example.com"}, - {Id: "verbose", Type: "bool", Label: "Verbose", Default: true}, - {Id: "flavor", Type: "string", Choices: []string{"a", "b"}, Default: "b"}, - {Id: "hidden", Type: "string", Hide: true}, - }, - } - srv := New(p) - defer srv.Close() - - rec := doRequest(srv.Handler(), "GET", "/check", nil, nil) - if rec.Code != http.StatusOK { - t.Fatalf("GET /check = %d, want 200", rec.Code) - } - body := rec.Body.String() - for _, want := range []string{ - `name="domain"`, - `placeholder="example.com"`, - `Domain name`, - `type="checkbox"`, - `name="verbose"`, - ` checked`, - `