Compare commits

...

4 commits

Author SHA1 Message Date
d5372e0a21 checkers: add ICMP ping checker with RTT and packet loss metrics
Some checks failed
continuous-integration/drone/push Build is failing
Implements a new ping observation provider and check rule that tests
ICMP reachability against abstract.Server services. Collects RTT and
packet loss metrics with configurable warning/critical thresholds.
2026-04-05 17:09:54 +07:00
a39418b4f3 checkers: add HTTP transport layer and extract ping to external repo
Introduce a transport abstraction so observation providers can run either
locally or be delegated to a remote HTTP endpoint. When an admin sets the
"endpoint" option, the engine substitutes the local provider with an
HTTPObservationProvider that POSTs to {endpoint}/collect.

Key changes:
- Add ObservationKeys field to CheckerDefinition for explicit key declaration
- Add ExternalCollectRequest/Response protocol types to model
- Add HTTPObservationProvider in internal/checker for remote collection
- Add provider override mechanism to ObservationContext (per-execution)
- Add RegisterExternalizableChecker helper that appends endpoint AdminOpt
- Create sdk/checker package exposing registration and option helpers for
  external checker plugins without requiring internal/ imports
- Add sdk/checker/server.go with generic HTTP server for checker binaries
- Remove target parameter from ObservationProvider.Collect interface
- Extract ping checker logic to external checker-ping repo, keeping only
  the thin registration wrapper with local-only AdminOpts in happydomain
2026-04-05 17:09:54 +07:00
88663ed4cc checkers: add incremental scheduler updates on domain/zone changes
Instead of rebuilding the entire scheduler queue, incrementally add or
remove jobs when domains are created/deleted or zones are
imported/published. A wake channel interrupts the run loop so new jobs
are picked up immediately. A jobKeys index prevents duplicate entries.

Hook points: domain creation, domain deletion, zone import, and zone
publish (correction apply) all notify the scheduler via the narrow
SchedulerDomainNotifier interface, wired through setter methods to
avoid initialization ordering issues.
2026-04-05 17:09:54 +07:00
50badc5811 checkers: store observations as json.RawMessage with cross-checker reuse
Refactor observation data pipeline to serialize once after collection and
keep json.RawMessage throughout storage and API responses. This eliminates
double-serialization and makes DB round-trips lossless.

Key changes:
- ObservationGetter.Get() adopts json.Unmarshal semantics (dest any)
- ObservationSnapshot.Data uses map[ObservationKey]json.RawMessage
- Add freshness-based observation cache (ObservationCacheStorage) that
  stores lightweight snapshot pointers, enabling cross-checker reuse of
  recent observations without re-collecting
2026-04-05 17:09:54 +07:00
24 changed files with 842 additions and 88 deletions

32
checkers/ping.go Normal file
View file

@ -0,0 +1,32 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
ping "git.happydns.org/happyDomain/checker-ping/checker"
"git.happydns.org/happyDomain/internal/checker"
)
func init() {
checker.RegisterObservationProvider(ping.Provider())
checker.RegisterExternalizableChecker(ping.Definition())
}

2
go.mod
View file

@ -5,6 +5,7 @@ go 1.25.0
toolchain go1.26.1
require (
git.happydns.org/happyDomain/checker-ping v0.0.0-00010101000000-000000000000
github.com/StackExchange/dnscontrol/v4 v4.34.0
github.com/altcha-org/altcha-lib-go v1.0.0
github.com/coreos/go-oidc/v3 v3.17.0
@ -179,6 +180,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus-community/pro-bing v0.8.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect

2
go.sum
View file

@ -471,6 +471,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=

View file

@ -22,7 +22,6 @@
package controller
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
@ -235,7 +234,7 @@ func (cc *CheckerController) GetExecutionObservation(c *gin.Context) {
return
}
c.JSON(http.StatusOK, val)
c.Data(http.StatusOK, "application/json; charset=utf-8", val)
}
// GetExecutionResults returns the evaluation (per-rule states) for an execution.
@ -380,13 +379,7 @@ func (cc *CheckerController) GetExecutionHTMLReport(c *gin.Context) {
return
}
raw, err := json.Marshal(val)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, json.RawMessage(raw))
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, val)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return

View file

@ -263,9 +263,14 @@ func (app *App) initUsecases() {
app.store,
app.store,
app.store,
app.store,
)
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store, app.store)
app.usecases.checkerStatusUC.SetPlannedJobProvider(app.usecases.checkerScheduler)
// Wire scheduler notifications for incremental queue updates.
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
}
func (app *App) setupRouter() {

View file

@ -51,37 +51,70 @@ func GetObservationProviders() map[happydns.ObservationKey]happydns.ObservationP
return providerRegistry
}
// ObservationCacheLookup resolves a cached observation for a target+key.
// Returns the raw data and collection time, or an error if not cached.
type ObservationCacheLookup func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error)
// ObservationContext provides lazy-loading, cached, thread-safe access to observation data.
// Collected data is serialized to json.RawMessage immediately after collection.
type ObservationContext struct {
target happydns.CheckTarget
opts happydns.CheckerOptions
cache map[happydns.ObservationKey]any
errors map[happydns.ObservationKey]error
mu sync.RWMutex
target happydns.CheckTarget
opts happydns.CheckerOptions
cache map[happydns.ObservationKey]json.RawMessage
errors map[happydns.ObservationKey]error
mu sync.RWMutex
cacheLookup ObservationCacheLookup // nil = no DB cache
freshness time.Duration // 0 = always collect
providerOverride map[happydns.ObservationKey]happydns.ObservationProvider
}
// NewObservationContext creates a new ObservationContext for the given target and options.
func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions) *ObservationContext {
// cacheLookup and freshness enable cross-checker observation reuse from stored snapshots.
// Pass nil and 0 to disable DB-based caching.
func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions, cacheLookup ObservationCacheLookup, freshness time.Duration) *ObservationContext {
return &ObservationContext{
target: target,
opts: opts,
cache: make(map[happydns.ObservationKey]any),
errors: make(map[happydns.ObservationKey]error),
target: target,
opts: opts,
cache: make(map[happydns.ObservationKey]json.RawMessage),
errors: make(map[happydns.ObservationKey]error),
cacheLookup: cacheLookup,
freshness: freshness,
}
}
// Get returns the observation data for the given key, collecting it lazily if needed.
// SetProviderOverride registers a per-context provider that takes precedence
// over the global registry for the given observation key. This is used to
// substitute local providers with HTTP-backed ones when an endpoint is configured.
func (oc *ObservationContext) SetProviderOverride(key happydns.ObservationKey, p happydns.ObservationProvider) {
if oc.providerOverride == nil {
oc.providerOverride = make(map[happydns.ObservationKey]happydns.ObservationProvider)
}
oc.providerOverride[key] = p
}
// getProvider returns the observation provider for the given key, checking
// per-context overrides first, then falling back to the global registry.
func (oc *ObservationContext) getProvider(key happydns.ObservationKey) happydns.ObservationProvider {
if oc.providerOverride != nil {
if p, ok := oc.providerOverride[key]; ok {
return p
}
}
return GetObservationProvider(key)
}
// Get collects observation data for the given key (lazily) and unmarshals it into dest.
// Thread-safe: concurrent calls for the same key will only trigger one collection.
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey) (any, error) {
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
// Fast path: check cache under read lock.
oc.mu.RLock()
if val, ok := oc.cache[key]; ok {
if raw, ok := oc.cache[key]; ok {
oc.mu.RUnlock()
return val, nil
return json.Unmarshal(raw, dest)
}
if err, ok := oc.errors[key]; ok {
oc.mu.RUnlock()
return nil, err
return err
}
oc.mu.RUnlock()
@ -90,36 +123,53 @@ func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationK
defer oc.mu.Unlock()
// Double-check after acquiring write lock.
if val, ok := oc.cache[key]; ok {
return val, nil
if raw, ok := oc.cache[key]; ok {
return json.Unmarshal(raw, dest)
}
if err, ok := oc.errors[key]; ok {
return nil, err
return err
}
provider := GetObservationProvider(key)
// Try DB cache before collecting fresh data.
if oc.cacheLookup != nil && oc.freshness > 0 {
if raw, collectedAt, err := oc.cacheLookup(oc.target, key); err == nil {
if time.Since(collectedAt) < oc.freshness {
oc.cache[key] = raw
return json.Unmarshal(raw, dest)
}
}
}
provider := oc.getProvider(key)
if provider == nil {
err := fmt.Errorf("no observation provider registered for key %q", key)
oc.errors[key] = err
return nil, err
return err
}
val, err := provider.Collect(ctx, oc.target, oc.opts)
val, err := provider.Collect(ctx, oc.opts)
if err != nil {
oc.errors[key] = err
return nil, err
return err
}
oc.cache[key] = val
return val, nil
raw, err := json.Marshal(val)
if err != nil {
err = fmt.Errorf("observation %q: marshal failed: %w", key, err)
oc.errors[key] = err
return err
}
oc.cache[key] = json.RawMessage(raw)
return json.Unmarshal(raw, dest)
}
// Data returns all cached observation data.
func (oc *ObservationContext) Data() map[happydns.ObservationKey]any {
// Data returns all cached observation data as pre-serialized JSON.
func (oc *ObservationContext) Data() map[happydns.ObservationKey]json.RawMessage {
oc.mu.RLock()
defer oc.mu.RUnlock()
data := make(map[happydns.ObservationKey]any, len(oc.cache))
data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache))
for k, v := range oc.cache {
data[k] = v
}
@ -181,12 +231,8 @@ func GetMetrics(key happydns.ObservationKey, raw json.RawMessage, collectedAt ti
// GetAllMetrics extracts metrics from all observation keys in a snapshot.
func GetAllMetrics(snap *happydns.ObservationSnapshot) ([]happydns.CheckMetric, error) {
var allMetrics []happydns.CheckMetric
for key, val := range snap.Data {
raw, err := json.Marshal(val)
if err != nil {
continue
}
metrics, supported, err := GetMetrics(key, json.RawMessage(raw), snap.CollectedAt)
for key, raw := range snap.Data {
metrics, supported, err := GetMetrics(key, raw, snap.CollectedAt)
if err != nil || !supported {
continue
}

View file

@ -27,6 +27,36 @@ import (
"git.happydns.org/happyDomain/model"
)
// GetOption extracts a typed value from checker options, handling both
// native Go types (in-process providers) and map[string]any values
// (from JSON round-tripping through HTTP providers). Returns the zero
// value and false if the key is missing or the value cannot be converted.
func GetOption[T any](options happydns.CheckerOptions, key string) (T, bool) {
v, ok := options[key]
if !ok {
var zero T
return zero, false
}
// Direct type assertion (in-process path).
if t, ok := v.(T); ok {
return t, true
}
// JSON round-trip for values deserialized as map[string]any over HTTP.
raw, err := json.Marshal(v)
if err != nil {
var zero T
return zero, false
}
var t T
if err := json.Unmarshal(raw, &t); err != nil {
var zero T
return zero, false
}
return t, true
}
// GetFloatOption extracts a float64 from checker options, handling both
// native float64 values and json.Number. Returns defaultVal if the key
// is missing or the value cannot be converted.

View file

@ -0,0 +1,105 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"git.happydns.org/happyDomain/model"
)
// HTTPObservationProvider is an ObservationProvider that delegates data
// collection to a remote HTTP endpoint via POST /collect.
type HTTPObservationProvider struct {
observationKey happydns.ObservationKey
endpoint string // base URL without trailing slash
}
// NewHTTPObservationProvider creates a new HTTP-backed observation provider.
// endpoint is the base URL of the remote checker (e.g. "http://checker-ping:8080").
func NewHTTPObservationProvider(key happydns.ObservationKey, endpoint string) *HTTPObservationProvider {
return &HTTPObservationProvider{
observationKey: key,
endpoint: strings.TrimSuffix(endpoint, "/"),
}
}
// Key returns the observation key this provider handles.
func (p *HTTPObservationProvider) Key() happydns.ObservationKey {
return p.observationKey
}
// Collect sends the observation request to the remote endpoint and returns
// the raw JSON data. The returned value is a json.RawMessage which
// ObservationContext.Get() will marshal without double-encoding.
func (p *HTTPObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
reqBody := happydns.ExternalCollectRequest{
Key: p.observationKey,
Options: opts,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("HTTP provider %s: failed to marshal request: %w", p.observationKey, err)
}
url := p.endpoint + "/collect"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("HTTP provider %s: failed to create request: %w", p.observationKey, err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("HTTP provider %s: request failed: %w", p.observationKey, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP provider %s: endpoint returned status %d: %s", p.observationKey, resp.StatusCode, string(respBody))
}
var result happydns.ExternalCollectResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("HTTP provider %s: failed to decode response: %w", p.observationKey, err)
}
if result.Error != "" {
return nil, fmt.Errorf("HTTP provider %s: remote error: %s", p.observationKey, result.Error)
}
if result.Data == nil {
return nil, fmt.Errorf("HTTP provider %s: remote returned empty data", p.observationKey)
}
// Return json.RawMessage directly — it implements json.Marshaler,
// so ObservationContext.Get() won't double-encode it.
return result.Data, nil
}

View file

@ -39,6 +39,24 @@ func RegisterChecker(c *happydns.CheckerDefinition) {
checkerRegistry[c.ID] = c
}
// RegisterExternalizableChecker registers a checker that supports being
// delegated to a remote HTTP endpoint. It appends an "endpoint" AdminOpt
// so the administrator can optionally configure a remote URL.
// When the endpoint is left empty, the checker runs locally as usual.
func RegisterExternalizableChecker(c *happydns.CheckerDefinition) {
c.Options.AdminOpts = append(c.Options.AdminOpts,
happydns.CheckerOptionDocumentation{
Id: "endpoint",
Type: "string",
Label: "Remote checker endpoint URL",
Description: "If set, delegate observation collection to this HTTP endpoint instead of running locally.",
Placeholder: "http://checker-" + c.ID + ":8080",
NoOverride: true,
},
)
RegisterChecker(c)
}
// GetCheckers returns all registered checker definitions.
func GetCheckers() map[string]*happydns.CheckerDefinition {
return checkerRegistry

View file

@ -579,6 +579,32 @@ func (s *InMemoryStorage) ClearSnapshots() error {
return nil
}
// --- ObservationCacheStorage ---
func obsCacheKey(target happydns.CheckTarget, key happydns.ObservationKey) string {
return fmt.Sprintf("obscache-%s-%s", target.String(), key)
}
func (s *InMemoryStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error) {
s.mu.Lock()
defer s.mu.Unlock()
data, ok := s.data[obsCacheKey(target, key)]
if !ok {
return nil, happydns.ErrNotFound
}
entry := &happydns.ObservationCacheEntry{}
if err := s.DecodeData(data, entry); err != nil {
return nil, err
}
return entry, nil
}
func (s *InMemoryStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error {
return s.Put(obsCacheKey(target, key), entry)
}
// --- SchedulerStateStorage ---
func (s *InMemoryStorage) GetLastSchedulerRun() (time.Time, error) {

View file

@ -45,6 +45,7 @@ type Storage interface {
checker.CheckerOptionsStorage
checker.CheckEvaluationStorage
checker.ExecutionStorage
checker.ObservationCacheStorage
checker.ObservationSnapshotStorage
checker.SchedulerStateStorage
domain.DomainStorage

View file

@ -0,0 +1,45 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"fmt"
"git.happydns.org/happyDomain/model"
)
func obsCacheKey(target happydns.CheckTarget, key happydns.ObservationKey) string {
return fmt.Sprintf("obscache-%s-%s", target.String(), key)
}
func (s *KVStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error) {
entry := &happydns.ObservationCacheEntry{}
err := s.db.Get(obsCacheKey(target, key), entry)
if err != nil {
return nil, err
}
return entry, nil
}
func (s *KVStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error {
return s.db.Put(obsCacheKey(target, key), entry)
}

View file

@ -361,10 +361,10 @@ func (u *CheckStatusUsecase) GetSnapshotByExecution(execID happydns.Identifier,
return nil, err
}
val, ok := snap.Data[obsKey]
raw, ok := snap.Data[obsKey]
if !ok {
return nil, happydns.ErrSnapshotNotFound
}
return json.Marshal(val)
return raw, nil
}

View file

@ -23,6 +23,7 @@ package checker
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
@ -37,6 +38,7 @@ type checkerEngine struct {
evalStore CheckEvaluationStorage
execStore ExecutionStorage
snapStore ObservationSnapshotStorage
cacheStore ObservationCacheStorage
}
// NewCheckerEngine creates a new CheckerEngine implementation.
@ -45,12 +47,14 @@ func NewCheckerEngine(
evalStore CheckEvaluationStorage,
execStore ExecutionStorage,
snapStore ObservationSnapshotStorage,
cacheStore ObservationCacheStorage,
) happydns.CheckerEngine {
return &checkerEngine{
optionsUC: optionsUC,
evalStore: evalStore,
execStore: execStore,
snapStore: snapStore,
optionsUC: optionsUC,
evalStore: evalStore,
execStore: execStore,
snapStore: snapStore,
cacheStore: cacheStore,
}
}
@ -141,8 +145,42 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
return happydns.CheckState{}, nil, fmt.Errorf("resolving options: %w", err)
}
// Build observation cache lookup for cross-checker reuse.
var cacheLookup checkerPkg.ObservationCacheLookup
if e.cacheStore != nil {
cacheLookup = func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error) {
entry, err := e.cacheStore.GetCachedObservation(target, key)
if err != nil {
return nil, time.Time{}, err
}
snap, err := e.snapStore.GetSnapshot(entry.SnapshotID)
if err != nil {
return nil, time.Time{}, err
}
raw, ok := snap.Data[key]
if !ok {
return nil, time.Time{}, fmt.Errorf("observation %q not in snapshot", key)
}
return raw, entry.CollectedAt, nil
}
}
var freshness time.Duration
if plan != nil && plan.Interval != nil {
freshness = *plan.Interval
} else if plan != nil && def.Interval != nil {
freshness = def.Interval.Default
}
// Create observation context for lazy data collection.
obsCtx := checkerPkg.NewObservationContext(target, mergedOpts)
obsCtx := checkerPkg.NewObservationContext(target, mergedOpts, cacheLookup, freshness)
// If an endpoint is configured, override observation providers with HTTP transport.
if endpoint, ok := mergedOpts["endpoint"].(string); ok && endpoint != "" {
for _, key := range def.ObservationKeys {
obsCtx.SetProviderOverride(key, checkerPkg.NewHTTPObservationProvider(key, endpoint))
}
}
// Evaluate all rules, skipping disabled ones.
states := make([]happydns.CheckState, 0, len(def.Rules))
@ -174,6 +212,16 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
return happydns.CheckState{}, nil, fmt.Errorf("creating snapshot: %w", err)
}
// Update observation cache pointers for cross-checker reuse.
if e.cacheStore != nil {
for key := range snap.Data {
_ = e.cacheStore.PutCachedObservation(target, key, &happydns.ObservationCacheEntry{
SnapshotID: snap.Id,
CollectedAt: snap.CollectedAt,
})
}
}
// Persist evaluation.
eval := &happydns.CheckEvaluation{
PlanID: planID,

View file

@ -38,7 +38,7 @@ func (p *testObservationProvider) Key() happydns.ObservationKey {
return "test_obs"
}
func (p *testObservationProvider) Collect(ctx context.Context, target happydns.CheckTarget, opts happydns.CheckerOptions) (any, error) {
func (p *testObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
return map[string]any{"value": 42}, nil
}
@ -52,8 +52,8 @@ func (r *testCheckRule) Name() string { return r.name }
func (r *testCheckRule) Description() string { return "test rule: " + r.name }
func (r *testCheckRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
_, err := obs.Get(ctx, "test_obs")
if err != nil {
var data map[string]any
if err := obs.Get(ctx, "test_obs", &data); err != nil {
return happydns.CheckState{Status: happydns.StatusError, Message: err.Error()}
}
return happydns.CheckState{Status: r.status, Message: r.name + " passed", Code: r.name}
@ -79,7 +79,7 @@ func TestCheckerEngine_RunOK(t *testing.T) {
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
@ -143,7 +143,7 @@ func TestCheckerEngine_RunWarn(t *testing.T) {
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
@ -188,7 +188,7 @@ func TestCheckerEngine_RunPerRuleDisable(t *testing.T) {
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
@ -278,7 +278,7 @@ func TestCheckerEngine_RunNotFound(t *testing.T) {
t.Fatalf("NewInMemoryStorage() returned error: %v", err)
}
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: &uid}

View file

@ -96,12 +96,14 @@ type SchedulerStatus struct {
// Scheduler manages periodic execution of checkers.
type Scheduler struct {
queue SchedulerQueue
jobKeys map[string]bool
engine happydns.CheckerEngine
planStore CheckPlanStorage
domainStore DomainLister
zoneStore ZoneGetter
stateStore SchedulerStateStorage
cancel context.CancelFunc
wake chan struct{}
mu sync.RWMutex
running bool
ctx context.Context
@ -119,6 +121,8 @@ func NewScheduler(engine happydns.CheckerEngine, maxConcurrency int, planStore C
domainStore: domainStore,
zoneStore: zoneStore,
stateStore: stateStore,
jobKeys: make(map[string]bool),
wake: make(chan struct{}, 1),
maxConcurrency: maxConcurrency,
}
}
@ -206,6 +210,8 @@ func (s *Scheduler) run(ctx context.Context) {
select {
case <-ctx.Done():
return
case <-s.wake:
continue
case <-time.After(1 * time.Minute):
s.mu.Lock()
s.buildQueue()
@ -228,6 +234,9 @@ func (s *Scheduler) run(ctx context.Context) {
case <-ctx.Done():
timer.Stop()
return
case <-s.wake:
timer.Stop()
continue
case <-timer.C:
}
}
@ -287,14 +296,17 @@ func (s *Scheduler) run(ctx context.Context) {
}
// Add jitter for next cycle.
job.NextRun = job.NextRun.Add(computeJitter(job.CheckerID, job.Target.String(), job.NextRun, job.Interval))
key := job.CheckerID + "|" + job.Target.String()
s.mu.Lock()
heap.Push(&s.queue, job)
s.jobKeys[key] = true
s.mu.Unlock()
}
}
func (s *Scheduler) buildQueue() {
s.queue = s.queue[:0]
s.jobKeys = make(map[string]bool)
var lastRun time.Time
if s.stateStore != nil {
@ -370,6 +382,7 @@ func (s *Scheduler) buildQueue() {
job.PlanID = &plan.Id
}
heap.Push(&s.queue, job)
s.jobKeys[key] = true
}
// Service-level discovery: load the latest zone and match services.
@ -403,12 +416,137 @@ func (s *Scheduler) buildQueue() {
job.PlanID = &plan.Id
}
heap.Push(&s.queue, job)
s.jobKeys[key] = true
}
}
}
}
}
// NotifyDomainChange incrementally adds scheduler jobs for a domain
// without rebuilding the entire queue. Call this after a domain is
// created or its zone is imported/published.
func (s *Scheduler) NotifyDomainChange(domain *happydns.Domain) {
checkers := checkerPkg.GetCheckers()
// Load plans relevant to this domain.
uid := domain.Owner
did := domain.Id
domainTarget := happydns.CheckTarget{UserId: &uid, DomainId: &did}
plans, err := s.planStore.ListCheckPlansByTarget(domainTarget)
if err != nil {
log.Printf("Scheduler: NotifyDomainChange: failed to load plans: %v", err)
}
disabledSet := make(map[string]bool)
planMap := make(map[string]*happydns.CheckPlan)
for _, p := range plans {
key := p.CheckerID + "|" + p.Target.String()
planMap[key] = p
if p.IsFullyDisabled() {
disabledSet[key] = true
}
}
var added int
s.mu.Lock()
for checkerID, def := range checkers {
if def.Availability.ApplyToDomain {
key := checkerID + "|" + domainTarget.String()
if s.jobKeys[key] || disabledSet[key] {
continue
}
plan := planMap[key]
interval := s.effectiveInterval(def, plan)
job := &SchedulerJob{
CheckerID: checkerID,
Target: domainTarget,
Interval: interval,
NextRun: time.Now().Add(computeJitter(checkerID, domainTarget.String(), time.Now(), interval)),
}
if plan != nil {
job.PlanID = &plan.Id
}
heap.Push(&s.queue, job)
s.jobKeys[key] = true
added++
}
if def.Availability.ApplyToService {
services := s.loadDomainServices(domain)
for _, svc := range services {
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
continue
}
sid := svc.Id
svcTarget := happydns.CheckTarget{UserId: &uid, DomainId: &did, ServiceId: &sid, ServiceType: svc.Type}
key := checkerID + "|" + svcTarget.String()
if s.jobKeys[key] || disabledSet[key] {
continue
}
plan := planMap[key]
interval := s.effectiveInterval(def, plan)
job := &SchedulerJob{
CheckerID: checkerID,
Target: svcTarget,
Interval: interval,
NextRun: time.Now().Add(computeJitter(checkerID, svcTarget.String(), time.Now(), interval)),
}
if plan != nil {
job.PlanID = &plan.Id
}
heap.Push(&s.queue, job)
s.jobKeys[key] = true
added++
}
}
}
s.mu.Unlock()
if added > 0 {
log.Printf("Scheduler: NotifyDomainChange(%s): added %d jobs", domain.DomainName, added)
// Wake the run loop so it re-evaluates the queue head.
select {
case s.wake <- struct{}{}:
default:
}
}
}
// NotifyDomainRemoved removes all scheduler jobs for the given domain.
func (s *Scheduler) NotifyDomainRemoved(domainID happydns.Identifier) {
s.mu.Lock()
n := 0
for i := 0; i < len(s.queue); {
job := s.queue[i]
if job.Target.DomainId != nil && job.Target.DomainId.Equals(domainID) {
key := job.CheckerID + "|" + job.Target.String()
delete(s.jobKeys, key)
// Swap with last and shrink.
s.queue[i] = s.queue[len(s.queue)-1]
s.queue[len(s.queue)-1] = nil
s.queue = s.queue[:len(s.queue)-1]
n++
} else {
i++
}
}
if n > 0 {
heap.Init(&s.queue)
// Re-index after Init.
for i, job := range s.queue {
job.index = i
}
}
s.mu.Unlock()
if n > 0 {
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID, n)
}
}
func (s *Scheduler) loadAllPlans() ([]*happydns.CheckPlan, error) {
iter, err := s.planStore.ListAllCheckPlans()
if err != nil {

View file

@ -43,6 +43,13 @@ type ZoneGetter interface {
GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error)
}
// SchedulerDomainNotifier is a narrow interface for notifying the scheduler
// about domain changes so it can incrementally update its job queue.
type SchedulerDomainNotifier interface {
NotifyDomainChange(domain *happydns.Domain)
NotifyDomainRemoved(domainID happydns.Identifier)
}
// CheckAutoFillStorage provides access to domain, zone and user data
// needed to resolve auto-fill field values at execution time.
type CheckAutoFillStorage interface {
@ -112,3 +119,10 @@ type ObservationSnapshotStorage interface {
DeleteSnapshot(snapID happydns.Identifier) error
ClearSnapshots() error
}
// ObservationCacheStorage provides a lightweight cache mapping (target, observation key)
// to the snapshot that holds the most recent data.
type ObservationCacheStorage interface {
GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error)
PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error
}

View file

@ -41,12 +41,20 @@ type DomainExistenceTester interface {
TestDomainExistence(ctx context.Context, provider *happydns.Provider, name string) error
}
// SchedulerDomainNotifier is an optional callback to notify the scheduler
// about domain changes so it can incrementally update its job queue.
type SchedulerDomainNotifier interface {
NotifyDomainChange(domain *happydns.Domain)
NotifyDomainRemoved(domainID happydns.Identifier)
}
type Service struct {
store DomainStorage
providerService ProviderGetter
getZone *zoneUC.GetZoneUsecase
domainExistence DomainExistenceTester
domainLogAppender domainLogUC.DomainLogAppender
store DomainStorage
providerService ProviderGetter
getZone *zoneUC.GetZoneUsecase
domainExistence DomainExistenceTester
domainLogAppender domainLogUC.DomainLogAppender
schedulerNotifier SchedulerDomainNotifier
}
func NewService(
@ -65,6 +73,12 @@ func NewService(
}
}
// SetSchedulerNotifier sets the optional scheduler notifier for incremental
// queue updates on domain creation/deletion.
func (s *Service) SetSchedulerNotifier(notifier SchedulerDomainNotifier) {
s.schedulerNotifier = notifier
}
// CreateDomain creates a new domain for the given user.
func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, uz *happydns.Domain) error {
uz, err := happydns.NewDomain(user, uz.DomainName, uz.ProviderId)
@ -93,6 +107,10 @@ func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, uz *hap
s.domainLogAppender.AppendDomainLog(uz, happydns.NewDomainLog(user, happydns.LOG_INFO, fmt.Sprintf("Domain name %s added.", uz.DomainName)))
}
if s.schedulerNotifier != nil {
s.schedulerNotifier.NotifyDomainChange(uz)
}
return nil
}
@ -194,5 +212,9 @@ func (s *Service) DeleteDomain(domainID happydns.Identifier) error {
}
}
if s.schedulerNotifier != nil {
s.schedulerNotifier.NotifyDomainRemoved(domainID)
}
return nil
}

View file

@ -54,6 +54,12 @@ type ZoneCorrector interface {
ListZoneCorrections(ctx context.Context, provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error)
}
// SchedulerDomainNotifier is an optional callback to notify the scheduler
// about domain changes so it can incrementally update its job queue.
type SchedulerDomainNotifier interface {
NotifyDomainChange(domain *happydns.Domain)
}
// Orchestrator aggregates the use-cases that together implement the DNS zone
// lifecycle: importing zones from a provider, listing required corrections, and
// applying those corrections back to the provider.
@ -91,3 +97,10 @@ func NewOrchestrator(
ZoneImporter: zoneImporter,
}
}
// SetSchedulerNotifier sets the optional scheduler notifier on the
// sub-usecases that create or publish zones.
func (o *Orchestrator) SetSchedulerNotifier(notifier SchedulerDomainNotifier) {
o.RemoteZoneImporter.schedulerNotifier = notifier
o.ZoneCorrectionApplier.schedulerNotifier = notifier
}

View file

@ -34,10 +34,11 @@ import (
// from the provider and delegates to ZoneImporterUsecase to persist them. It
// also appends a domain log entry on success.
type RemoteZoneImporterUsecase struct {
appendDomainLog domainlogUC.DomainLogAppender
providerService ProviderGetter
zoneImporter happydns.ZoneImporterUsecase
zoneRetriever ZoneRetriever
appendDomainLog domainlogUC.DomainLogAppender
providerService ProviderGetter
zoneImporter happydns.ZoneImporterUsecase
zoneRetriever ZoneRetriever
schedulerNotifier SchedulerDomainNotifier
}
// NewRemoteZoneImporterUsecase creates a RemoteZoneImporterUsecase wired to
@ -79,5 +80,9 @@ func (uc *RemoteZoneImporterUsecase) Import(ctx context.Context, user *happydns.
log.Printf("unable to append domain log for %s: %s", domain.DomainName, err.Error())
}
if uc.schedulerNotifier != nil {
uc.schedulerNotifier.NotifyDomainChange(domain)
}
return myZone, nil
}

View file

@ -41,13 +41,14 @@ import (
// in the domain history. The WIP zone at ZoneHistory[0] is never modified.
type ZoneCorrectionApplierUsecase struct {
*ZoneCorrectionListerUsecase
appendDomainLog domainlogUC.DomainLogAppender
domainUpdater DomainUpdater
zoneCreator *zoneUC.CreateZoneUsecase
zoneGetter *zoneUC.GetZoneUsecase
zoneRetriever ZoneRetriever
zoneUpdater *zoneUC.UpdateZoneUsecase
clock func() time.Time
appendDomainLog domainlogUC.DomainLogAppender
domainUpdater DomainUpdater
zoneCreator *zoneUC.CreateZoneUsecase
zoneGetter *zoneUC.GetZoneUsecase
zoneRetriever ZoneRetriever
zoneUpdater *zoneUC.UpdateZoneUsecase
schedulerNotifier SchedulerDomainNotifier
clock func() time.Time
}
// NewZoneCorrectionApplierUsecase creates a ZoneCorrectionApplierUsecase with
@ -288,6 +289,10 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
log.Printf("%s: unable to update WIP zone propagation times: %s", domain.DomainName, updateErr)
}
if uc.schedulerNotifier != nil {
uc.schedulerNotifier.NotifyDomainChange(domain)
}
return snapshot, nil
}

View file

@ -238,10 +238,16 @@ type CheckEvaluation struct {
// ObservationSnapshot holds data collected during an execution.
type ObservationSnapshot struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
CollectedAt time.Time `json:"collectedAt" binding:"required" readonly:"true" format:"date-time"`
Data map[ObservationKey]any `json:"data" binding:"required" readonly:"true"`
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
CollectedAt time.Time `json:"collectedAt" binding:"required" readonly:"true" format:"date-time"`
Data map[ObservationKey]json.RawMessage `json:"data" binding:"required" readonly:"true"`
}
// ObservationCacheEntry is a lightweight pointer to cached observation data in a snapshot.
type ObservationCacheEntry struct {
SnapshotID Identifier `json:"snapshotId"`
CollectedAt time.Time `json:"collectedAt"`
}
// ExecutionStatus represents the lifecycle state of an execution.
@ -286,7 +292,7 @@ type Execution struct {
// ObservationProvider collects a specific type of data for a target.
type ObservationProvider interface {
Key() ObservationKey
Collect(ctx context.Context, target CheckTarget, opts CheckerOptions) (any, error)
Collect(ctx context.Context, opts CheckerOptions) (any, error)
}
// CheckRuleInfo is the JSON-serializable description of a rule, for API/UI listing.
@ -311,8 +317,9 @@ type CheckRuleWithOptions interface {
}
// ObservationGetter provides access to observation data (used by CheckRule).
// Get unmarshals observation data into dest (like json.Unmarshal).
type ObservationGetter interface {
Get(ctx context.Context, key ObservationKey) (any, error)
Get(ctx context.Context, key ObservationKey, dest any) error
}
// CheckAggregator combines multiple CheckStates into a single result.
@ -338,16 +345,17 @@ type CheckerMetricsReporter interface {
// CheckerDefinition is the complete definition of a checker, registered via init().
type CheckerDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
Availability CheckerAvailability `json:"availability"`
Options CheckerOptionsDocumentation `json:"options"`
RulesInfo []CheckRuleInfo `json:"rules"`
Rules []CheckRule `json:"-"`
Aggregator CheckAggregator `json:"-"`
Interval *CheckIntervalSpec `json:"interval,omitempty"`
HasHTMLReport bool `json:"has_html_report,omitempty"`
HasMetrics bool `json:"has_metrics,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Availability CheckerAvailability `json:"availability"`
Options CheckerOptionsDocumentation `json:"options"`
RulesInfo []CheckRuleInfo `json:"rules"`
Rules []CheckRule `json:"-"`
Aggregator CheckAggregator `json:"-"`
Interval *CheckIntervalSpec `json:"interval,omitempty"`
HasHTMLReport bool `json:"has_html_report,omitempty"`
HasMetrics bool `json:"has_metrics,omitempty"`
ObservationKeys []ObservationKey `json:"observationKeys,omitempty"`
}
// BuildRulesInfo populates RulesInfo from the Rules slice.
@ -422,3 +430,16 @@ func ParseCheckerOptionsKey(key string) (checkerName string, userId *Identifier,
}
return
}
// ExternalCollectRequest is sent to POST /collect on a remote checker endpoint.
type ExternalCollectRequest struct {
Key ObservationKey `json:"key"`
Target CheckTarget `json:"target"`
Options CheckerOptions `json:"options"`
}
// ExternalCollectResponse is returned by POST /collect on a remote checker endpoint.
type ExternalCollectResponse struct {
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}

62
sdk/checker/sdk.go Normal file
View file

@ -0,0 +1,62 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package checker provides the public API for checker plugins.
// External checker plugins should import this package for registration
// and option helpers instead of the internal checker package.
package checker
import (
internal "git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// RegisterChecker registers a checker definition globally.
func RegisterChecker(c *happydns.CheckerDefinition) {
internal.RegisterChecker(c)
}
// RegisterObservationProvider registers an observation provider globally.
func RegisterObservationProvider(p happydns.ObservationProvider) {
internal.RegisterObservationProvider(p)
}
// GetFloatOption extracts a float64 option with a default value.
func GetFloatOption(options happydns.CheckerOptions, key string, defaultVal float64) float64 {
return internal.GetFloatOption(options, key, defaultVal)
}
// GetIntOption extracts an int option with a default value.
func GetIntOption(options happydns.CheckerOptions, key string, defaultVal int) int {
return internal.GetIntOption(options, key, defaultVal)
}
// GetBoolOption extracts a bool option with a default value.
func GetBoolOption(options happydns.CheckerOptions, key string, defaultVal bool) bool {
return internal.GetBoolOption(options, key, defaultVal)
}
// GetOption extracts a typed value from checker options, handling both
// native Go types (in-process providers) and map[string]any values
// (from JSON round-tripping through HTTP providers).
func GetOption[T any](options happydns.CheckerOptions, key string) (T, bool) {
return internal.GetOption[T](options, key)
}

121
sdk/checker/server.go Normal file
View file

@ -0,0 +1,121 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.happydns.org/happyDomain/model"
)
// Server is a generic HTTP server for external checkers.
// It handles the /health and /collect endpoints, taking care of
// JSON serialization and error formatting.
type Server struct {
provider happydns.ObservationProvider
mux *http.ServeMux
}
// NewServer creates a new checker HTTP server backed by the given observation provider.
func NewServer(provider happydns.ObservationProvider) *Server {
s := &Server{provider: provider}
s.mux = http.NewServeMux()
s.mux.HandleFunc("GET /health", s.handleHealth)
s.mux.HandleFunc("POST /collect", s.handleCollect)
return s
}
// Handler returns the http.Handler for this server, allowing callers
// to embed it in a custom server or add middleware.
func (s *Server) Handler() http.Handler {
return requestLogger(s.mux)
}
// ListenAndServe starts the HTTP server on the given address.
func (s *Server) ListenAndServe(addr string) error {
log.Printf("checker listening on %s", addr)
return http.ListenAndServe(addr, requestLogger(s.mux))
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.status, time.Since(start))
})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
var req happydns.ExternalCollectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, happydns.ExternalCollectResponse{
Error: fmt.Sprintf("invalid request body: %v", err),
})
return
}
data, err := s.provider.Collect(r.Context(), req.Options)
if err != nil {
writeJSON(w, http.StatusOK, happydns.ExternalCollectResponse{
Error: err.Error(),
})
return
}
raw, err := json.Marshal(data)
if err != nil {
writeJSON(w, http.StatusOK, happydns.ExternalCollectResponse{
Error: fmt.Sprintf("failed to marshal result: %v", err),
})
return
}
writeJSON(w, http.StatusOK, happydns.ExternalCollectResponse{
Data: json.RawMessage(raw),
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}