// 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}}
← Run another check
`))