diff --git a/README.md b/README.md index e224fdf..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/interactive.go b/checker/interactive.go deleted file mode 100644 index bb7158e..0000000 --- a/checker/interactive.go +++ /dev/null @@ -1,348 +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 ( - "bytes" - "encoding/json" - "fmt" - "html/template" - "log" - "net/http" - "time" -) - -// CheckerInteractive 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.(CheckerInteractive). -// -// 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 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, a CheckerInteractive implementation is -// responsible for resolving whatever it needs from the human inputs -// (typically via direct DNS queries) before Collect runs. -type CheckerInteractive 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() []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) (CheckerOptions, error) -} - -// checkResult holds everything the result page needs to render. -type checkResult struct { - Title string - States []CheckState - Metrics []CheckMetric - ReportHTML string - CollectErr string - ReportErr string - MetricsErr string -} - -type checkFormPage struct { - Title string - Fields []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) { - if err := r.ParseForm(); err != nil { - s.renderCheckForm(w, s.interactive.RenderForm(), fmt.Sprintf("invalid form: %v", err)) - return - } - - opts, err := s.interactive.ParseForm(r) - if err != nil { - s.renderCheckForm(w, s.interactive.RenderForm(), 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 - } - - if s.definition != nil { - obs := &mapObservationGetter{data: map[ObservationKey]json.RawMessage{ - s.provider.Key(): raw, - }} - result.States = s.evaluateRules(r.Context(), obs, opts, nil) - } - - ctx := NewReportContext(raw, nil) - - if reporter, ok := s.provider.(CheckerHTMLReporter); ok { - html, rerr := reporter.GetHTMLReport(ctx) - if rerr != nil { - result.ReportErr = rerr.Error() - } else { - result.ReportHTML = html - } - } - - if reporter, ok := s.provider.(CheckerMetricsReporter); ok { - metrics, merr := reporter.ExtractMetrics(ctx, time.Now()) - if merr != nil { - result.MetricsErr = merr.Error() - } else { - result.Metrics = metrics - } - } - - s.renderCheckResult(w, result) -} - -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 []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 Status) string { - switch s { - case StatusOK: - return "ok" - case StatusInfo: - return "info" - case StatusWarn: - return "warn" - case StatusCrit: - return "crit" - case 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": 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}} - -
← Run another check
- -`)) diff --git a/checker/interactive_test.go b/checker/interactive_test.go deleted file mode 100644 index 0a48512..0000000 --- a/checker/interactive_test.go +++ /dev/null @@ -1,245 +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 ( - "context" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" -) - -// interactiveProvider embeds testProvider and adds CheckerInteractive. -type interactiveProvider struct { - *testProvider - fields []CheckerOptionField - parseFn func(r *http.Request) (CheckerOptions, error) - parseErr error -} - -func (p *interactiveProvider) RenderForm() []CheckerOptionField { - return p.fields -} - -func (p *interactiveProvider) ParseForm(r *http.Request) (CheckerOptions, error) { - if p.parseErr != nil { - return nil, p.parseErr - } - if p.parseFn != nil { - return p.parseFn(r) - } - return 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 ObservationKey } - -func (m *minimalProvider) Key() ObservationKey { return m.key } -func (m *minimalProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { - return nil, nil -} - -func TestCheck_NotRegistered_WhenProviderLacksInterface(t *testing.T) { - p := &minimalProvider{key: "test"} - srv := NewServer(p) - defer srv.Close() - - rec := doRequest(srv.Handler(), "GET", "/check", nil, nil) - if rec.Code != http.StatusNotFound { - t.Fatalf("GET /check without CheckerInteractive = %d, want 404", rec.Code) - } -} - -func TestCheck_Form_Renders(t *testing.T) { - p := &interactiveProvider{ - testProvider: &testProvider{key: "test"}, - fields: []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 := NewServer(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`, - `