Compare commits

..

50 commits

Author SHA1 Message Date
b1d1353b93 web: Add optional breadcrumb to PageTitle
Some checks failed
continuous-integration/drone/push Build is failing
2026-04-16 17:09:39 +07:00
4f20a2ff06 Add Prometheus export documentation
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2026-04-16 17:08:05 +07:00
57de739f80 web: add Prometheus metrics URL link to checker config page 2026-04-16 17:08:05 +07:00
e857b1fb99 web-admin: wire dashboard to /metrics with collapsible details
Replaces the three REST count calls with a single Prometheus scrape that
auto-refreshes every 15s, surfaces queue/worker/in-flight/RSS/version/uptime
as featured cards, and tucks counters and Go runtime stats under a
"Show more metrics" Collapse.
2026-04-16 17:08:05 +07:00
504660367e checkers: add filter predicate to ListExecutionsBy* storage methods
Metrics endpoints now skip incomplete/planned executions by passing a
`doneExecution` filter so only fully-evaluated runs contribute to the
Prometheus output.
2026-04-16 17:08:01 +07:00
35d4d84004 checkers: add Prometheus text format for metrics export
The metrics endpoints now negotiate response format via the Accept
header: application/json returns the JSON array, anything else returns
the Prometheus text exposition format.
2026-04-16 17:02:31 +07:00
1ca806852e Instrument check scheduler with Prometheus metrics
Track queue depth on enqueue and pop, active worker count, check execution
duration per checker, and check result status counters.
2026-04-16 17:02:31 +07:00
7899b2f0e8 Instrument DNS provider adapter with Prometheus metrics
Add providerName field to DNSControlAdapterNSProvider and wrap GetZoneRecords,
GetZoneCorrections, CreateDomain, and ListZones with timing and call counters
using happydomain_provider_api_calls_total and happydomain_provider_api_duration_seconds.
2026-04-16 17:02:31 +07:00
5660003311 Add storage stats Prometheus collector for business entity counts
Expose four live gauges queried at each scrape via a custom Collector:
- happydomain_registered_users_total
- happydomain_domains_total
- happydomain_zones_total
- happydomain_providers_total
2026-04-16 17:02:31 +07:00
7ac13175c6 Wire metrics into app: HTTP middleware, storage instrumentation, build info
- Add HTTP metrics middleware to public router in setupRouter()
- Wrap storage with InstrumentedStorage after initialization
- Set build info metric from main() with actual version string
- Promote prometheus/client_golang to direct dependency
2026-04-16 17:02:31 +07:00
b4c6492936 Expose /metrics endpoint on admin socket via promhttp 2026-04-16 17:02:31 +07:00
b7beefed3f Add Prometheus metrics package with HTTP middleware and storage instrumentation
- internal/metrics/metrics.go: defines all metric variables (http, scheduler,
  provider, storage, build info) using promauto for zero-config registration
- internal/metrics/http.go: Gin middleware recording request count, duration,
  and in-flight gauge using c.FullPath() to avoid high-cardinality labels
- internal/app/instrumented_storage.go: InstrumentedStorage wrapper implementing
  storage.Storage, recording operation counts and durations for all entities
2026-04-16 17:02:31 +07:00
5de221411e New checker: domain lock status
Some checks failed
continuous-integration/drone/push Build is failing
2026-04-16 15:22:52 +07:00
6555df1f4d New checker: domain contact consistency
Add ContactInfo struct to DomainInfo and extract contact data (registrant,
admin, tech) from both RDAP and WHOIS responses. Introduce a new
domain_contact checker that compares actual contact fields against
user-specified expected values, with redaction detection for
privacy-protected domains. The WHOIS observation provider now also exposes
contact data so the new rule can reuse the same lookup as domain_expiry.
2026-04-16 15:22:51 +07:00
26bbdfdcc4 New checker: NS security restrictions
Registers the external checker-ns-restrictions plugin, which probes each
authoritative nameserver for AXFR/IXFR acceptance, recursion availability,
RFC 8482 ANY handling and authoritative status.
2026-04-16 15:22:51 +07:00
313434f883 New checker: monitor domain expiration date 2026-04-16 15:22:51 +07:00
a531a6ef66 domaininfo: add per-IP rate limiting on public endpoint
The /api/domaininfo/:domain route is unauthenticated and proxies
outbound RDAP/WHOIS queries, making it an abuse vector. Add a
per-IP rate limiter (10 req/min) using gin-rate-limit to mitigate
enumeration and proxy abuse while keeping the endpoint public.
2026-04-16 15:22:51 +07:00
8a546a8b41 domaininfo: add RDAP/WHOIS lookup feature
Introduces a domaininfo package with RDAP and WHOIS getters, exposed
through a new DomainInfoUsecase and /api/domaininfo/:domain route (also
mounted under domain scope). Adds a /whois frontend page and a zone
sidebar modal to display registrar, dates, nameservers and status.
2026-04-16 15:22:51 +07:00
e0dc19614f checker: enforce MaxChecksPerDay quota with interval-aware throttling
Some checks failed
continuous-integration/drone/push Build is failing
Wire UserQuota.MaxChecksPerDay field into the scheduler via the
UserGater: an in-memory daily counter per user
(reset at UTC midnight): gates scheduled executions, with a two-tier
heuristic that skips short-interval jobs first once the budget is 80%
consumed so rare/important checks are not starved by frequent
pings. Planned executions returned by ListPlannedExecutions are marked
with a new ExecutionRateLimited status when the user is over
budget. Manual API triggers bypass the quota.
2026-04-16 15:18:43 +07:00
561d885c6f checker: also consider WIP zone for tidy, scheduler, and auto-fill
Tidy and scheduler now check both the WIP zone ([0]) and the latest
published zone ([1]) so that services being drafted are not cleaned up
or ignored by the scheduler.  Auto-fill searches WIP first for the
best user experience when configuring new services.
2026-04-16 15:18:43 +07:00
f8e1633a29 fix: use latest published zone instead of oldest in checker subsystem
ZoneHistory is ordered [WIP, newest-published, ..., oldest-published].
The tidy, scheduler, and auto-fill code was using ZoneHistory[len-1]
(the oldest zone) instead of ZoneHistory[1] (the latest published).

This caused the scheduler to enumerate services from the oldest zone
snapshot, tidy to check service existence against outdated data, and
auto-fill to resolve from the wrong zone.
2026-04-16 15:18:43 +07:00
166c7e864c checker: pause scheduling for paused or inactive users
Add a job-level gate to the scheduler. When set, the gate is consulted
on every popped job; if it returns false, the job is skipped and
re-enqueued for its next interval without invoking the engine.

A new UserGater builds such a gate from a user resolver and an
inactivity threshold:

  - users with UserQuota.SchedulingPaused are always blocked (admin
    kill switch);
  - users whose LastSeen is older than their effective inactivity
    horizon (UserQuota.InactivityPauseDays, falling back to a system
    default) are blocked until they log in again;
  - lookups are cached for 5 minutes so the scheduler hot path stays
    cheap, with an Invalidate hook for use on user updates.

This addresses the "free trial then forgotten" failure mode described
in the design notes.
2026-04-16 15:18:43 +07:00
97d9e2872f checker: add Janitor goroutine to enforce retention policy
The Janitor periodically walks every CheckPlan, loads its executions,
and deletes the ones that the tiered RetentionPolicy says to drop.

Per-user overrides are honoured: if a user's UserQuota.RetentionDays
is set, that horizon replaces the system default for the user's plans.
User lookups are cached per sweep to avoid repeated storage hits.

The janitor is the long-tail counterpart of the (still TODO) cheap
hard cap that will be applied at execution-creation time. It runs
immediately on Start() and then every configured interval (default 6h).
2026-04-16 15:18:43 +07:00
030ff6dc61 checker: keep 1 report per hour after the first day
Insert an hourly tier between the full-detail window and the daily
bucket so users still get sub-day resolution for the first week:

  0..1 day  -> all
  1..7 days -> 1 per hour
  7..30     -> 2 per day
  ...
2026-04-16 15:18:43 +07:00
6aa25c74c0 checker: add tiered RetentionPolicy
Introduce a pure RetentionPolicy.Decide function that partitions check
executions into keep/drop sets according to a tiered policy:

  - 0..7 days   -> every execution
  - 7..30 days  -> 2 per day per (checker, target)
  - 30..D/2     -> 1 per week per (checker, target)
  - D/2..D days -> 1 per month per (checker, target)
  - > D days    -> dropped

The function is intentionally storage-agnostic so the upcoming janitor
goroutine can call it on any execution slice and so it can be unit
tested directly. All thresholds are configurable to allow per-user
overrides via UserQuota.
2026-04-16 15:18:43 +07:00
8876b3972a model: add UserQuota struct for admin-controlled per-user limits
Introduce a UserQuota field on the User model to hold admin-controlled
limits and flags that the user cannot modify. Only checker-related
fields are defined for now (max checks per day, retention days,
inactivity pause days, scheduling kill switch); future paid-plan
attributes will be added here later.

The user-facing API only exposes settings updates and account deletion,
so Quota cannot be written through it. Updates go through the existing
admin user PUT endpoint, with a new editor card in the admin UI under
/users/[uid].
2026-04-16 15:18:43 +07:00
9bd11fff4c New checker: Matrix federation 2026-04-16 15:18:43 +07:00
ec1ae64e4d New checker: zonemaster 2026-04-16 15:18:43 +07:00
ce925af579 New checker: ICMP ping checker with RTT and packet loss metrics 2026-04-16 15:18:43 +07:00
2402c61455 checkers: add HTTP transport layer
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.
2026-04-16 15:18:43 +07:00
4ce6728a87 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-16 15:18:43 +07:00
524e83d056 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.
2026-04-16 15:18:43 +07:00
98642bf7e7 checkers: add NoOverride field support for checker options
Prevent more specific scopes from overriding option values locked at a
higher scope (e.g. admin). Includes defense-in-depth stripping on
Set/Add operations, merge-time preservation, and frontend filtering.
2026-04-16 15:18:43 +07:00
33ef6b60e7 web: add checks status summary badge to zone viewer PageHeader
Add a ChecksSummaryBadge component that fetches domain-level checker statuses
and displays the worst status as a colored badge linking to the checks page.
2026-04-16 15:18:43 +07:00
439fe2a2e9 checkers: show worst check status badge on domain list
Add DomainWithCheckStatus model and GetWorstDomainStatuses usecase to
compute the most critical checker status per domain. The GET /domains
endpoint now returns status alongside each domain. The frontend domain
store, list components, and table row display dynamic status badges
with color and icon instead of a hardcoded "OK".

ZoneList is made generic (T extends HappydnsDomain) so the badges
snippet preserves the caller's concrete type without unsafe casts.
2026-04-16 15:18:43 +07:00
fb02331f41 checkers: show children checkers on domain page and hide scheduling for non-domain checkers
Add a separate section on the domain checks page to display zone and
service-level checkers that can be configured but won't produce results
at the domain scope. Hide the scheduling and rules cards when configuring
a non-domain checker from the domain context.
2026-04-16 15:18:43 +07:00
4e034b0135 checkers: integrate rules as a view tab in execution detail page
Remove separate /rules pages and display rules as a tab alongside
metrics, HTML, and JSON views. Rules become the default view when
no metrics or HTML report is available. Status is now shown as a
colored badge in the rules table.
2026-04-16 15:18:43 +07:00
322ade2881 checkers: add frontend metrics chart on execution pages
Add Chart.js-based line chart for checker metrics. The chart appears
on the executions list page (aggregated) and on individual execution
detail pages. Metrics view mode is selectable via the sidebar alongside
HTML report and raw JSON views.
2026-04-16 15:18:43 +07:00
6ed3b3f6ed checkers: add metrics export in JSON format
Observation providers can now implement CheckerMetricsReporter to
extract time-series metrics from their stored data. The controller
returns the metrics as a JSON array.

Routes: user-level (/api/checkers/metrics), domain-level, per-checker,
and per-execution.
2026-04-16 15:18:43 +07:00
b37ed1d349 checkers: add HTML report rendering for observation providers
Introduce CheckerHTMLReporter interface that observation providers can
implement to render rich HTML documents from their data. The Zonemaster
provider implements it with collapsible accordions and severity badges.

Adds API endpoint GET .../observations/:obsKey/report, frontend stores
for view mode switching (HTML/JSON), and wires the sidebar toggle buttons.
2026-04-16 15:18:43 +07:00
7a4de13ac6 checkers: add frontend UI components and routes
Add all checker UI pages and components:
- Checker list, config, schedule, and rules pages
- Execution list, detail, results, and rules pages
- Sidebar components for domain/service checker status
- Run check modal with option overrides and rule selection
- Domain-scoped and service-scoped check routes
- Admin pages for checker configuration and scheduler management
- Header navigation link for checkers section
2026-04-16 15:18:43 +07:00
ee560a699d checkers: add frontend API client, stores, and utilities
Add the frontend infrastructure for the checker UI:
- API client with scoped helpers for domain/service-level operations
- Svelte stores for checker state (currentExecution, currentCheckInfo)
- Utility functions for status colors, icons, i18n keys, date formatting
- Shared helpers: withInheritedPlaceholders, downloadBlob, collectAllOptionDocs
- English translations for all checker UI strings
- Zone model and form types extended for checker support
2026-04-16 15:18:43 +07:00
ee8e7b322d checkers: add API controllers, routes, and app wiring
Wire up the checker system to the HTTP layer:
- API controllers for checker operations, options, plans, and results
- Scoped routes at domain and service level
- Admin controllers for checker config and scheduler management
- App initialization: create usecases, start/stop scheduler
- Zone controller updated to include per-service check status
2026-04-16 15:18:43 +07:00
4bee15e684 checkers: add usecases, engine, and scheduler
Implement the checker business logic:
- CheckerOptionsUsecase: scope-based option resolution, validation,
  auto-fill from execution context (domain, zone, service)
- CheckPlanUsecase: CRUD for user scheduling configurations
- CheckStatusUsecase: aggregated status queries, execution history
- CheckerEngine: full execution pipeline (observe, evaluate, aggregate)
- Scheduler: background job executor with auto-discovery, min-heap
  queue, worker pool, and jitter-based scheduling
2026-04-16 15:18:43 +07:00
b18f3d737e checkers: add storage interfaces, implementations, and tidy
Add the persistence layer for the checker system:
- Storage interfaces (CheckPlanStorage, CheckerOptionsStorage,
  CheckEvaluationStorage, ExecutionStorage, ObservationSnapshotStorage,
  SchedulerStateStorage) in the usecase/checker package
- KV-based implementations for LevelDB/Oracle NoSQL/InMemory backends
- Integrate checker storage into the main Storage interface
- Add tidy methods for checker entities (plans, configurations,
  evaluations, executions, snapshots, observation cache) and
  secondary index cleanup
2026-04-16 13:45:14 +07:00
dc8143628a checkers: add map-based option validation for checker fields
Add ValidateMapValues() to the forms package for validating
checker option maps against field documentation (required fields,
allowed choices, type checking).
2026-04-16 13:45:14 +07:00
76af934782 checkers: load external checker plugins from .so files
Some checks are pending
continuous-integration/drone/push Build is running
Scan -plugins-directory paths at startup, open each .so via plugin.Open,
look up the NewCheckerPlugin symbol from checker-sdk-go, and register the
returned definition and observation provider in the global checker
registries. A pluginLoader indirection keeps the door open for future
plugin kinds.
2026-04-16 13:45:14 +07:00
71b0c7fdaf checkers: introduce checker subsystem foundation
Add the checker-sdk-go dependency and build the core checker
infrastructure:
- Domain model types: CheckTarget, CheckPlan, Execution,
  CheckEvaluation, CheckerDefinition, CheckerOptions,
  ObservationSnapshot, and associated interfaces
- Observation collection engine with concurrent per-key gathering
- Checker and observation provider registries (wrapping checker-sdk-go)
- WorstStatusAggregator for combining rule evaluation results
2026-04-16 13:45:13 +07:00
8a826b1e8f test(adapters): add tests for DNSControlAdapterNSProvider metrics instrumentation
Some checks failed
continuous-integration/drone/push Build is failing
Cover success, error, and panic paths for GetZoneRecords, GetZoneCorrections,
CreateDomain, and ListZones to ensure provider API call counters are recorded
with correct status labels.
2026-04-16 13:45:10 +07:00
07d4c244d1 middleware: skip JWT parsing for session ID tokens to suppress spurious log
When a Bearer token is a valid session ID (base32, 103 chars), the JWT
middleware now silently hands off to the session store instead of logging
a misleading "bad JWT claims" error.

Also exports IsValidSessionID from the session store package and derives
the session ID length from a constant tied to the key size in the usecase
package, removing the hardcoded 103.
2026-04-16 13:41:47 +07:00
228 changed files with 31013 additions and 418 deletions

256
checkers/domain_contact.go Normal file
View file

@ -0,0 +1,256 @@
// 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 (
"context"
"fmt"
"strings"
"time"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// redactedPatterns is the list of substrings that, when found in a contact
// field, indicate the data is privacy-protected rather than meaningful.
var redactedPatterns = []string{
"redacted",
"privacy",
"not disclosed",
"whoisguard",
"withheld",
"data protected",
"contact privacy",
}
// domainContactRule compares the registered domain contacts (registrant,
// admin, tech) against user-supplied expected values, with redaction
// detection for privacy-protected domains.
type domainContactRule struct{}
func (r *domainContactRule) Name() string {
return "domain_contact_check"
}
func (r *domainContactRule) Description() string {
return "Verifies that domain contacts (name/organization/email) match expected values"
}
// validRoles enumerates the contact roles supported by the WHOIS observation.
var validRoles = map[string]bool{
"registrant": true,
"admin": true,
"tech": true,
}
func (r *domainContactRule) ValidateOptions(opts happydns.CheckerOptions) error {
for _, key := range []string{"expectedName", "expectedOrganization", "expectedEmail", "checkRoles"} {
if v, ok := opts[key]; ok {
if _, ok := v.(string); !ok {
return fmt.Errorf("%s must be a string", key)
}
}
}
if v, ok := opts["checkRoles"].(string); ok && v != "" {
hasOne := false
for _, p := range strings.Split(v, ",") {
role := strings.TrimSpace(p)
if role == "" {
continue
}
if !validRoles[role] {
return fmt.Errorf("checkRoles: unknown role %q (allowed: registrant, admin, tech)", role)
}
hasOne = true
}
if !hasOne {
return fmt.Errorf("checkRoles must contain at least one role")
}
}
return nil
}
func (r *domainContactRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
var whois WHOISData
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
return happydns.CheckState{
Status: happydns.StatusError,
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
Code: "contact_error",
}
}
expectedName, _ := opts["expectedName"].(string)
expectedOrg, _ := opts["expectedOrganization"].(string)
expectedEmail, _ := opts["expectedEmail"].(string)
if expectedName == "" && expectedOrg == "" && expectedEmail == "" {
return happydns.CheckState{
Status: happydns.StatusUnknown,
Message: "No expected contact values configured",
Code: "contact_skipped",
}
}
checkRolesStr := "registrant"
if v, ok := opts["checkRoles"].(string); ok && v != "" {
checkRolesStr = v
}
var roles []string
for s := range strings.SplitSeq(checkRolesStr, ",") {
s = strings.TrimSpace(s)
if s != "" {
roles = append(roles, s)
}
}
if len(roles) == 0 {
return happydns.CheckState{
Status: happydns.StatusUnknown,
Message: "No contact roles to check",
Code: "contact_skipped",
}
}
worst := happydns.StatusOK
var lines []string
for _, role := range roles {
contact, found := whois.Contacts[role]
if !found || contact == nil {
lines = append(lines, fmt.Sprintf("%s: contact not found", role))
worst = worseStatus(worst, happydns.StatusWarn)
continue
}
if isRedacted(contact) {
lines = append(lines, fmt.Sprintf("%s: contact info is redacted/private", role))
worst = worseStatus(worst, happydns.StatusInfo)
continue
}
var mismatches []string
if expectedName != "" && !strings.EqualFold(expectedName, contact.Name) {
mismatches = append(mismatches, fmt.Sprintf("name: got %q, expected %q", contact.Name, expectedName))
}
if expectedOrg != "" && !strings.EqualFold(expectedOrg, contact.Organization) {
mismatches = append(mismatches, fmt.Sprintf("organization: got %q, expected %q", contact.Organization, expectedOrg))
}
if expectedEmail != "" && !strings.EqualFold(expectedEmail, contact.Email) {
mismatches = append(mismatches, fmt.Sprintf("email: got %q, expected %q", contact.Email, expectedEmail))
}
if len(mismatches) > 0 {
lines = append(lines, fmt.Sprintf("%s: %s", role, strings.Join(mismatches, ", ")))
worst = worseStatus(worst, happydns.StatusWarn)
} else {
lines = append(lines, fmt.Sprintf("%s: contact info matches", role))
}
}
return happydns.CheckState{
Status: worst,
Message: strings.Join(lines, "; "),
Code: "contact_result",
}
}
// isRedacted reports whether a contact's fields look privacy-protected.
func isRedacted(c *happydns.ContactInfo) bool {
for _, field := range []string{c.Name, c.Organization, c.Email} {
lower := strings.ToLower(field)
for _, pattern := range redactedPatterns {
if strings.Contains(lower, pattern) {
return true
}
}
}
return false
}
// worseStatus returns the more severe of two statuses. The SDK orders
// statuses as Unknown < OK < Info < Warn < Crit < Error, so the higher
// numeric value is the more severe one.
func worseStatus(a, b happydns.Status) happydns.Status {
if b > a {
return b
}
return a
}
func init() {
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "domain_contact",
Name: "Domain Contact Consistency",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
Options: happydns.CheckerOptionsDocumentation{
DomainOpts: []happydns.CheckerOptionDocumentation{
{
Id: "domainName",
Type: "string",
AutoFill: happydns.AutoFillDomainName,
Hide: true,
},
{
Id: "expectedName",
Type: "string",
Label: "Expected registrant name",
Description: "If set, the configured roles must report this exact name (case-insensitive).",
},
{
Id: "expectedOrganization",
Type: "string",
Label: "Expected organization",
Description: "If set, the configured roles must report this exact organization (case-insensitive).",
},
{
Id: "expectedEmail",
Type: "string",
Label: "Expected email",
Description: "If set, the configured roles must report this exact email (case-insensitive).",
},
{
Id: "checkRoles",
Type: "string",
Label: "Contact roles to check",
Description: "Comma-separated list of roles among: registrant, admin, tech.",
Default: "registrant",
Placeholder: "registrant",
},
},
},
Rules: []happydns.CheckRule{
&domainContactRule{},
},
Interval: &happydns.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
})
}

View file

@ -0,0 +1,211 @@
// 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 (
"context"
"strings"
"testing"
"git.happydns.org/happyDomain/model"
)
func contactsFixture() map[string]*happydns.ContactInfo {
return map[string]*happydns.ContactInfo{
"registrant": {
Name: "Alice Example",
Organization: "Example Inc",
Email: "alice@example.com",
},
"admin": {
Name: "REDACTED FOR PRIVACY",
Organization: "REDACTED FOR PRIVACY",
Email: "redacted@example.com",
},
"tech": {
Name: "Bob Tech",
Organization: "Example Inc",
Email: "bob@example.com",
},
}
}
func TestDomainContactRule_Evaluate(t *testing.T) {
rule := &domainContactRule{}
obs := newWhoisObs(&WHOISData{Contacts: contactsFixture()})
cases := []struct {
name string
opts happydns.CheckerOptions
want happydns.Status
code string
}{
{
name: "no expectations",
opts: nil,
want: happydns.StatusUnknown,
code: "contact_skipped",
},
{
name: "registrant matches",
opts: happydns.CheckerOptions{
"expectedName": "Alice Example",
"expectedEmail": "alice@example.com",
},
want: happydns.StatusOK,
code: "contact_result",
},
{
name: "registrant name mismatch",
opts: happydns.CheckerOptions{
"expectedName": "Carol Other",
},
want: happydns.StatusWarn,
code: "contact_result",
},
{
name: "admin role is redacted",
opts: happydns.CheckerOptions{
"checkRoles": "admin",
"expectedName": "Alice Example",
},
want: happydns.StatusInfo,
code: "contact_result",
},
{
name: "missing role",
opts: happydns.CheckerOptions{
"checkRoles": "billing",
"expectedName": "Alice Example",
},
want: happydns.StatusWarn,
code: "contact_result",
},
{
name: "multi-role mixed (worst wins)",
opts: happydns.CheckerOptions{
"checkRoles": "registrant,admin",
"expectedName": "Alice Example",
},
// admin is redacted (Info) — Info is worse than OK from registrant.
want: happydns.StatusInfo,
code: "contact_result",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
st := rule.Evaluate(context.Background(), obs, tc.opts)
if st.Status != tc.want {
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
}
if st.Code != tc.code {
t.Errorf("code = %q, want %q", st.Code, tc.code)
}
})
}
}
func TestDomainContactRule_EvaluateObservationError(t *testing.T) {
rule := &domainContactRule{}
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("nope")}
st := rule.Evaluate(context.Background(), obs, happydns.CheckerOptions{"expectedName": "x"})
if st.Status != happydns.StatusError || st.Code != "contact_error" {
t.Errorf("got %v / %q", st.Status, st.Code)
}
}
func TestDomainContactRule_ValidateOptions(t *testing.T) {
rule := &domainContactRule{}
cases := []struct {
name string
opts happydns.CheckerOptions
wantErr bool
}{
{"empty", nil, false},
{"all valid", happydns.CheckerOptions{
"expectedName": "x",
"checkRoles": "registrant,tech",
}, false},
{"unknown role", happydns.CheckerOptions{"checkRoles": "billing"}, true},
{"empty roles after split", happydns.CheckerOptions{"checkRoles": " , , "}, true},
{"wrong type", happydns.CheckerOptions{"expectedEmail": 42}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := rule.ValidateOptions(tc.opts)
if (err != nil) != tc.wantErr {
t.Errorf("err=%v wantErr=%v", err, tc.wantErr)
}
})
}
}
func TestIsRedacted(t *testing.T) {
cases := []struct {
c *happydns.ContactInfo
want bool
}{
{&happydns.ContactInfo{Name: "REDACTED FOR PRIVACY"}, true},
{&happydns.ContactInfo{Organization: "Contact Privacy Inc"}, true},
{&happydns.ContactInfo{Email: "withheld@example.com"}, true},
{&happydns.ContactInfo{Name: "Alice", Email: "alice@example.com"}, false},
}
for _, tc := range cases {
if got := isRedacted(tc.c); got != tc.want {
t.Errorf("isRedacted(%+v) = %v, want %v", tc.c, got, tc.want)
}
}
}
func TestWorseStatus(t *testing.T) {
cases := []struct {
a, b, want happydns.Status
}{
{happydns.StatusOK, happydns.StatusInfo, happydns.StatusInfo},
{happydns.StatusInfo, happydns.StatusWarn, happydns.StatusWarn},
{happydns.StatusCrit, happydns.StatusWarn, happydns.StatusCrit},
{happydns.StatusOK, happydns.StatusUnknown, happydns.StatusOK},
{happydns.StatusError, happydns.StatusCrit, happydns.StatusError},
}
for _, tc := range cases {
if got := worseStatus(tc.a, tc.b); got != tc.want {
t.Errorf("worseStatus(%v,%v) = %v, want %v", tc.a, tc.b, got, tc.want)
}
}
}
// Sanity: WHOIS data with no contacts must not panic when a role is requested.
func TestDomainContactRule_NilContacts(t *testing.T) {
rule := &domainContactRule{}
obs := newWhoisObs(&WHOISData{})
st := rule.Evaluate(context.Background(), obs, happydns.CheckerOptions{
"expectedName": "Alice",
})
if st.Status != happydns.StatusWarn {
t.Errorf("status = %v, want Warn", st.Status)
}
if !strings.Contains(st.Message, "not found") {
t.Errorf("message = %q", st.Message)
}
}

237
checkers/domain_expiry.go Normal file
View file

@ -0,0 +1,237 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/pkg/domaininfo"
)
const (
// ObservationKeyWhois is the observation key for WHOIS / domain expiry data.
ObservationKeyWhois happydns.ObservationKey = "whois"
defaultWarningDays = 30
defaultCriticalDays = 7
)
// WHOISData represents WHOIS observation data.
type WHOISData struct {
ExpiryDate time.Time `json:"expiryDate"`
Registrar string `json:"registrar"`
Contacts map[string]*happydns.ContactInfo `json:"contacts,omitempty"`
Status []string `json:"status,omitempty"`
}
// whoisProvider is a placeholder WHOIS observation provider.
type whoisProvider struct{}
func (p *whoisProvider) Key() happydns.ObservationKey {
return ObservationKeyWhois
}
func (p *whoisProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
domainName, _ := opts["domainName"].(string)
if domainName == "" {
return nil, fmt.Errorf("domainName is required")
}
info, err := domaininfo.GetDomainInfo(ctx, happydns.Origin(domainName))
if err != nil {
return nil, fmt.Errorf("failed to retrieve domain info: %w", err)
}
if info.ExpirationDate == nil {
return nil, fmt.Errorf("expiration date not available for %s", domainName)
}
registrar := info.Registrar
if registrar == "" {
registrar = "Unknown"
}
return &WHOISData{
ExpiryDate: *info.ExpirationDate,
Registrar: registrar,
Contacts: info.Contacts,
Status: info.Status,
}, nil
}
// ExtractMetrics implements happydns.CheckerMetricsReporter.
func (p *whoisProvider) ExtractMetrics(raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, error) {
var data WHOISData
if err := json.Unmarshal(raw, &data); err != nil {
return nil, err
}
daysRemaining := data.ExpiryDate.Sub(collectedAt).Hours() / 24
return []happydns.CheckMetric{{
Name: "domain_expiry_days_remaining",
Value: daysRemaining,
Unit: "days",
Labels: map[string]string{"registrar": data.Registrar},
Timestamp: collectedAt,
}}, nil
}
// domainExpiryRule checks whether a domain is nearing expiration.
type domainExpiryRule struct{}
func (r *domainExpiryRule) Name() string {
return "domain_expiry_check"
}
func (r *domainExpiryRule) Description() string {
return "Checks whether a domain name is nearing its expiration date"
}
func (r *domainExpiryRule) ValidateOptions(opts happydns.CheckerOptions) error {
warningDays := float64(defaultWarningDays)
criticalDays := float64(defaultCriticalDays)
if v, ok := opts["warning_days"]; ok {
d, ok := v.(float64)
if !ok {
return fmt.Errorf("warning_days must be a number")
}
if d <= 0 {
return fmt.Errorf("warning_days must be positive")
}
warningDays = d
}
if v, ok := opts["critical_days"]; ok {
d, ok := v.(float64)
if !ok {
return fmt.Errorf("critical_days must be a number")
}
if d <= 0 {
return fmt.Errorf("critical_days must be positive")
}
criticalDays = d
}
if criticalDays >= warningDays {
return fmt.Errorf("critical_days (%v) must be less than warning_days (%v)", criticalDays, warningDays)
}
return nil
}
func (r *domainExpiryRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
var whois WHOISData
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
return happydns.CheckState{
Status: happydns.StatusError,
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
Code: "whois_error",
}
}
// Read thresholds from options with defaults.
warningDays := sdk.GetIntOption(opts, "warning_days", defaultWarningDays)
criticalDays := sdk.GetIntOption(opts, "critical_days", defaultCriticalDays)
daysRemaining := int(time.Until(whois.ExpiryDate).Hours() / 24)
meta := map[string]any{"days_remaining": daysRemaining}
if daysRemaining <= criticalDays {
return happydns.CheckState{
Status: happydns.StatusCrit,
Message: fmt.Sprintf("Domain expires in %d days", daysRemaining),
Code: "expiry_critical",
Meta: meta,
}
}
if daysRemaining <= warningDays {
return happydns.CheckState{
Status: happydns.StatusWarn,
Message: fmt.Sprintf("Domain expires in %d days", daysRemaining),
Code: "expiry_warning",
Meta: meta,
}
}
return happydns.CheckState{
Status: happydns.StatusOK,
Message: fmt.Sprintf("Domain expires in %d days", daysRemaining),
Code: "expiry_ok",
Meta: meta,
}
}
func init() {
checker.RegisterObservationProvider(&whoisProvider{})
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "domain_expiry",
Name: "Domain Expiry",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
Options: happydns.CheckerOptionsDocumentation{
DomainOpts: []happydns.CheckerOptionDocumentation{
{
Id: "domainName",
Type: "string",
AutoFill: happydns.AutoFillDomainName,
Hide: true,
},
{
Id: "warning_days",
Type: "uint",
Label: "Warning threshold (days)",
Description: "Number of days before expiration to trigger a warning.",
Default: defaultWarningDays,
Placeholder: strconv.Itoa(defaultWarningDays),
},
{
Id: "critical_days",
Type: "uint",
Label: "Critical threshold (days)",
Description: "Number of days before expiration to trigger a critical alert.",
Default: defaultCriticalDays,
Placeholder: strconv.Itoa(defaultCriticalDays),
},
},
},
Rules: []happydns.CheckRule{
&domainExpiryRule{},
},
Interval: &happydns.CheckIntervalSpec{
Min: 12 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
HasMetrics: true,
})
}

View file

@ -0,0 +1,136 @@
// 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 (
"context"
"encoding/json"
"testing"
"time"
"git.happydns.org/happyDomain/model"
)
func TestDomainExpiryRule_Evaluate(t *testing.T) {
rule := &domainExpiryRule{}
now := time.Now()
cases := []struct {
name string
expiresIn time.Duration
opts happydns.CheckerOptions
want happydns.Status
code string
}{
{"already expired", -5 * 24 * time.Hour, nil, happydns.StatusCrit, "expiry_critical"},
{"critical default", 3 * 24 * time.Hour, nil, happydns.StatusCrit, "expiry_critical"},
{"warning default", 15 * 24 * time.Hour, nil, happydns.StatusWarn, "expiry_warning"},
{"ok default", 90 * 24 * time.Hour, nil, happydns.StatusOK, "expiry_ok"},
{"ok with custom thresholds", 10 * 24 * time.Hour, happydns.CheckerOptions{
"warning_days": float64(5),
"critical_days": float64(2),
}, happydns.StatusOK, "expiry_ok"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
obs := newWhoisObs(&WHOISData{
ExpiryDate: now.Add(tc.expiresIn),
Registrar: "Test Registrar",
})
st := rule.Evaluate(context.Background(), obs, tc.opts)
if st.Status != tc.want {
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
}
if st.Code != tc.code {
t.Errorf("code = %q, want %q", st.Code, tc.code)
}
})
}
}
func TestDomainExpiryRule_EvaluateObservationError(t *testing.T) {
rule := &domainExpiryRule{}
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("boom")}
st := rule.Evaluate(context.Background(), obs, nil)
if st.Status != happydns.StatusError {
t.Fatalf("expected StatusError, got %v", st.Status)
}
if st.Code != "whois_error" {
t.Errorf("code = %q, want whois_error", st.Code)
}
}
func TestDomainExpiryRule_ValidateOptions(t *testing.T) {
rule := &domainExpiryRule{}
cases := []struct {
name string
opts happydns.CheckerOptions
wantErr bool
}{
{"defaults", nil, false},
{"valid custom", happydns.CheckerOptions{"warning_days": 30.0, "critical_days": 7.0}, false},
{"crit >= warn", happydns.CheckerOptions{"warning_days": 5.0, "critical_days": 5.0}, true},
{"warn wrong type", happydns.CheckerOptions{"warning_days": "thirty"}, true},
{"warn negative", happydns.CheckerOptions{"warning_days": -1.0}, true},
{"crit negative", happydns.CheckerOptions{"critical_days": -3.0}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := rule.ValidateOptions(tc.opts)
if (err != nil) != tc.wantErr {
t.Errorf("ValidateOptions err=%v, wantErr=%v", err, tc.wantErr)
}
})
}
}
func TestWhoisProvider_ExtractMetrics(t *testing.T) {
p := &whoisProvider{}
collected := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
data := WHOISData{
ExpiryDate: collected.Add(10 * 24 * time.Hour),
Registrar: "Acme",
}
raw, err := json.Marshal(data)
if err != nil {
t.Fatal(err)
}
metrics, err := p.ExtractMetrics(raw, collected)
if err != nil {
t.Fatal(err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 metric, got %d", len(metrics))
}
m := metrics[0]
if m.Name != "domain_expiry_days_remaining" {
t.Errorf("name = %q", m.Name)
}
if m.Value < 9.99 || m.Value > 10.01 {
t.Errorf("value = %v, want ~10", m.Value)
}
if m.Labels["registrar"] != "Acme" {
t.Errorf("registrar label = %q", m.Labels["registrar"])
}
}

165
checkers/domain_lock.go Normal file
View file

@ -0,0 +1,165 @@
// 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 (
"context"
"fmt"
"strings"
"time"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
const defaultRequiredLockStatuses = "clientTransferProhibited"
// domainLockRule verifies that a domain carries the expected EPP lock
// statuses (e.g. clientTransferProhibited) as reported by RDAP/WHOIS.
type domainLockRule struct{}
func (r *domainLockRule) Name() string {
return "domain_lock_check"
}
func (r *domainLockRule) Description() string {
return "Verifies that a domain carries the expected EPP lock statuses (e.g. clientTransferProhibited)"
}
func (r *domainLockRule) ValidateOptions(opts happydns.CheckerOptions) error {
v, ok := opts["requiredStatuses"]
if !ok {
return nil
}
s, ok := v.(string)
if !ok {
return fmt.Errorf("requiredStatuses must be a string")
}
for _, p := range strings.Split(s, ",") {
if strings.TrimSpace(p) != "" {
return nil
}
}
return fmt.Errorf("requiredStatuses must contain at least one EPP status code")
}
func (r *domainLockRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
var whois WHOISData
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
return happydns.CheckState{
Status: happydns.StatusError,
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
Code: "lock_error",
}
}
requiredStr := defaultRequiredLockStatuses
if v, ok := opts["requiredStatuses"].(string); ok && v != "" {
requiredStr = v
}
var required []string
for _, s := range strings.Split(requiredStr, ",") {
s = strings.TrimSpace(s)
if s != "" {
required = append(required, s)
}
}
if len(required) == 0 {
return happydns.CheckState{
Status: happydns.StatusUnknown,
Message: "No required lock statuses configured",
Code: "lock_skipped",
}
}
present := make(map[string]bool, len(whois.Status))
for _, s := range whois.Status {
present[strings.ToLower(s)] = true
}
var missing []string
for _, req := range required {
if !present[strings.ToLower(req)] {
missing = append(missing, req)
}
}
if len(missing) > 0 {
return happydns.CheckState{
Status: happydns.StatusCrit,
Message: fmt.Sprintf("Missing lock status: %s", strings.Join(missing, ", ")),
Code: "lock_missing",
Meta: map[string]any{
"missing": missing,
"present": whois.Status,
},
}
}
return happydns.CheckState{
Status: happydns.StatusOK,
Message: fmt.Sprintf("All required statuses present: %s", strings.Join(required, ", ")),
Code: "lock_ok",
Meta: map[string]any{
"required": required,
},
}
}
func init() {
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "domain_lock",
Name: "Domain Lock Status",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
Options: happydns.CheckerOptionsDocumentation{
DomainOpts: []happydns.CheckerOptionDocumentation{
{
Id: "domainName",
Type: "string",
AutoFill: happydns.AutoFillDomainName,
Hide: true,
},
{
Id: "requiredStatuses",
Type: "string",
Label: "Required lock statuses",
Description: "Comma-separated list of EPP status codes that must be present on the domain (e.g. clientTransferProhibited, clientUpdateProhibited, clientDeleteProhibited).",
Default: defaultRequiredLockStatuses,
Placeholder: defaultRequiredLockStatuses,
},
},
},
Rules: []happydns.CheckRule{
&domainLockRule{},
},
Interval: &happydns.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
})
}

View file

@ -0,0 +1,150 @@
// 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 (
"context"
"testing"
"git.happydns.org/happyDomain/model"
)
func TestDomainLockRule_Evaluate(t *testing.T) {
rule := &domainLockRule{}
cases := []struct {
name string
status []string
opts happydns.CheckerOptions
want happydns.Status
code string
}{
{
name: "default required present",
status: []string{"clientTransferProhibited", "ok"},
opts: nil,
want: happydns.StatusOK,
code: "lock_ok",
},
{
name: "default required missing",
status: []string{"ok"},
opts: nil,
want: happydns.StatusCrit,
code: "lock_missing",
},
{
name: "multiple required all present",
status: []string{"clientTransferProhibited", "clientUpdateProhibited", "clientDeleteProhibited"},
opts: happydns.CheckerOptions{
"requiredStatuses": "clientTransferProhibited,clientUpdateProhibited,clientDeleteProhibited",
},
want: happydns.StatusOK,
code: "lock_ok",
},
{
name: "multiple required some missing",
status: []string{"clientTransferProhibited"},
opts: happydns.CheckerOptions{
"requiredStatuses": "clientTransferProhibited,clientUpdateProhibited",
},
want: happydns.StatusCrit,
code: "lock_missing",
},
{
name: "case insensitive match",
status: []string{"clienttransferprohibited"},
opts: nil,
want: happydns.StatusOK,
code: "lock_ok",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
obs := newWhoisObs(&WHOISData{Status: tc.status})
st := rule.Evaluate(context.Background(), obs, tc.opts)
if st.Status != tc.want {
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
}
if st.Code != tc.code {
t.Errorf("code = %q, want %q", st.Code, tc.code)
}
})
}
}
// Sanity: WHOIS data with nil/empty status must report missing locks, not panic.
func TestDomainLockRule_NilStatus(t *testing.T) {
rule := &domainLockRule{}
cases := []struct {
name string
status []string
}{
{"nil status", nil},
{"empty status", []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
obs := newWhoisObs(&WHOISData{Status: tc.status})
st := rule.Evaluate(context.Background(), obs, nil)
if st.Status != happydns.StatusCrit {
t.Errorf("status = %v, want Crit", st.Status)
}
if st.Code != "lock_missing" {
t.Errorf("code = %q, want lock_missing", st.Code)
}
})
}
}
func TestDomainLockRule_EvaluateObservationError(t *testing.T) {
rule := &domainLockRule{}
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("nope")}
st := rule.Evaluate(context.Background(), obs, nil)
if st.Status != happydns.StatusError || st.Code != "lock_error" {
t.Errorf("got %v / %q", st.Status, st.Code)
}
}
func TestDomainLockRule_ValidateOptions(t *testing.T) {
rule := &domainLockRule{}
cases := []struct {
name string
opts happydns.CheckerOptions
wantErr bool
}{
{"default", nil, false},
{"valid", happydns.CheckerOptions{"requiredStatuses": "clientTransferProhibited"}, false},
{"empty after split", happydns.CheckerOptions{"requiredStatuses": " , , "}, true},
{"wrong type", happydns.CheckerOptions{"requiredStatuses": 123}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := rule.ValidateOptions(tc.opts)
if (err != nil) != tc.wantErr {
t.Errorf("err=%v wantErr=%v", err, tc.wantErr)
}
})
}
}

61
checkers/helpers_test.go Normal file
View file

@ -0,0 +1,61 @@
// 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 (
"context"
"encoding/json"
"git.happydns.org/happyDomain/model"
)
// stubObservationGetter is a test helper that serves a single pre-built
// observation, mimicking the SDK's mapObservationGetter (JSON round-trip).
type stubObservationGetter struct {
key happydns.ObservationKey
data any
err error
}
func (g *stubObservationGetter) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
if g.err != nil {
return g.err
}
if key != g.key {
return errNotFound
}
raw, err := json.Marshal(g.data)
if err != nil {
return err
}
return json.Unmarshal(raw, dest)
}
type errString string
func (e errString) Error() string { return string(e) }
const errNotFound = errString("observation not available")
func newWhoisObs(d *WHOISData) *stubObservationGetter {
return &stubObservationGetter{key: ObservationKeyWhois, data: d}
}

View file

@ -0,0 +1,33 @@
// 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 (
matrix "git.happydns.org/checker-matrix/checker"
"git.happydns.org/happyDomain/internal/checker"
)
func init() {
checker.RegisterObservationProvider(matrix.Provider())
// Not Externalizable checker as it already calls a HTTP API
checker.RegisterChecker(matrix.Definition())
}

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 (
nsr "git.happydns.org/checker-ns-restrictions/checker"
"git.happydns.org/happyDomain/internal/checker"
)
func init() {
checker.RegisterObservationProvider(nsr.Provider())
checker.RegisterExternalizableChecker(nsr.Definition())
}

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/checker-ping/checker"
"git.happydns.org/happyDomain/internal/checker"
)
func init() {
checker.RegisterObservationProvider(ping.Provider())
checker.RegisterExternalizableChecker(ping.Definition())
}

33
checkers/zonemaster.go Normal file
View file

@ -0,0 +1,33 @@
// 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 (
zonemaster "git.happydns.org/checker-zonemaster/checker"
"git.happydns.org/happyDomain/internal/checker"
)
func init() {
checker.RegisterObservationProvider(zonemaster.Provider())
// Not Externalizable checker as it already calls a HTTP API
checker.RegisterChecker(zonemaster.Definition())
}

View file

@ -26,13 +26,16 @@ import (
"os"
"os/signal"
"syscall"
"time"
"github.com/earthboundkid/versioninfo/v2"
"github.com/fatih/color"
_ "git.happydns.org/happyDomain/checkers"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/app"
"git.happydns.org/happyDomain/internal/config"
"git.happydns.org/happyDomain/internal/metrics"
_ "git.happydns.org/happyDomain/internal/storage/inmemory"
_ "git.happydns.org/happyDomain/internal/storage/leveldb"
_ "git.happydns.org/happyDomain/internal/storage/oracle-nosql"
@ -54,11 +57,19 @@ func main() {
LastCommit: versioninfo.Revision,
DirtyBuild: versioninfo.DirtyBuild,
}
v := Version
if Version == "custom-build" {
controller.HDVersion.Version = versioninfo.Short()
v = versioninfo.Short()
controller.HDVersion.Version = v
} else {
versioninfo.Version = Version
}
metrics.SetBuildInfo(
v,
versioninfo.Revision,
versioninfo.LastCommit.UTC().Format(time.RFC3339),
versioninfo.DirtyBuild,
)
log.Println("This is happyDomain", versioninfo.Short())

187
docs/checker-quotas.md Normal file
View file

@ -0,0 +1,187 @@
# Checker quotas and scheduling policy
happyDomain's checker subsystem runs scheduled DNS health checks on behalf of
each user. To keep resource usage predictable on shared instances, the
scheduler consults a per-user policy before every job and can throttle or
skip executions based on the user's activity, explicit pause, or daily
budget.
This document describes:
1. The three gates a scheduled check must pass through.
2. How per-day budgets are counted and reset.
3. How an administrator configures system-wide defaults.
4. How per-user overrides work.
5. Operational caveats (restart behaviour, manual triggers, UI indications).
> **Scope:** this document only covers the scheduler's user-level gate. It
> does not cover retention (`--checker-retention-days`, see the janitor) nor
> the per-checker `MinInterval` throttling.
---
## The three gates
Before each scheduled execution, the scheduler evaluates the job against a
**user-level gate**. A job is dropped (and rescheduled for the next tick) if
any of the following apply:
| Gate | Blocks when… | Source of truth |
| --- | --- | --- |
| Scheduling paused | `UserQuota.SchedulingPaused == true` | per-user only |
| Inactivity pause | user has not logged in for N days | per-user, falls back to system default |
| Daily budget | user has executed `MaxChecksPerDay` scheduled checks today | per-user, falls back to system default |
The first two gates ("policy layer") are cached for 5 minutes after a lookup
so the scheduler hot path does not hit storage on every job pop. The cache
is invalidated automatically whenever a user's quota or `LastSeen` changes
(login, admin edit).
The daily budget gate is **not** cached: the counter changes on every
successful execution and must be accurate.
## Daily budget
### How it is counted
- The counter is incremented **once** per successful call to
`CreateExecution` by the scheduler. It is _not_ decremented if
`RunExecution` later fails — a check that got as far as being recorded
counts against the budget. This prevents users from burning capacity via
engines that repeatedly error.
- **Manual API triggers are counted by default**, and refused with
`HTTP 429 Too Many Requests` once the user is over budget. This applies
to "Run now" in the UI and to `POST
/api/domains/.../checkers/.../executions`. The behaviour is controlled
by `--checker-count-manual-triggers` (default `true`); set it to
`false` to restore the legacy bypass — in that mode, manual triggers
are neither checked nor incremented.
### When it resets
- The counter resets at **00:00 UTC** every day. This is intentionally
independent of the user's local timezone so the behaviour is consistent
across deployments. A user in UTC+8 will see their counter flip
mid-afternoon local time.
- The counter is kept **in memory only**. A process restart resets it to
zero for every user. In a rolling-restart environment, this effectively
grants a partial top-up; operators should size their defaults with this
in mind.
### Interval-aware throttling
When the budget is at least **80 % consumed**, the scheduler starts skipping
jobs whose configured interval is shorter than **4 hours**. Jobs with a
longer interval continue to run until the hard limit is reached.
The goal is to prevent rare-but-important checks (for example, a daily
DNSSEC sanity check) from being starved by frequent low-value pings (for
example, a 1-minute probe). Put bluntly: if you are going to run out of
budget anyway, spend the last 20 % on the checks you would most miss.
Constants (not currently configurable at runtime):
- `throttleFillRatio` = 0.8
- `throttleShortIntervalCutoff` = 4 h
Both live in `internal/usecase/checker/user_gate.go`.
### UI signalling
Planned (not-yet-run) executions returned by the "upcoming checks" API are
marked with status `ExecutionRateLimited` whenever the target user is over
budget. This lets the frontend show the user that scheduled work is on hold
until tomorrow, distinct from a merely-pending job.
Only _synthetic_ planned entries carry this status; it is never persisted
on a real execution record.
## System-wide configuration
| CLI flag | Default | Meaning |
| --- | --- | --- |
| `--checker-inactivity-pause-days` | `90` | Stop scheduling for users inactive for this many days. `0` disables the inactivity gate. |
| `--checker-max-checks-per-day` | `0` | Cap on scheduled executions per user per day. `0` means unlimited. Counter resets at 00:00 UTC. |
| `--checker-count-manual-triggers` | `true` | When `true`, manual triggers count against `MaxChecksPerDay` and are refused with HTTP 429 once exhausted. When `false`, manual triggers bypass the quota entirely. No effect when `MaxChecksPerDay` is `0` (unlimited). |
Example:
```sh
happyDomain \
--checker-max-checks-per-day=2000 \
--checker-inactivity-pause-days=60 \
--checker-count-manual-triggers=true
```
Values set via the CLI are read at startup. Changing them requires a
restart of the happyDomain process.
## Per-user overrides
Administrators can override the system defaults for individual users via
the admin API (`UserQuota`):
```json
{
"max_checks_per_day": 500,
"inactivity_pause_days": 14,
"scheduling_paused": false
}
```
Semantics:
- `max_checks_per_day`: `0` means "use the system default", any positive
value is an override, and a **negative** value disables the daily cap
for that user (explicit unlimited, independent of the system default).
Changes take effect immediately — on the next scheduler tick, the
user's budget cache is recomputed against the new limit while the
accumulated usage counter for the current day is preserved.
- `inactivity_pause_days`: `0` means "use the system default", any
positive value is an override, and a **negative** value disables
inactivity pausing for that user.
- `scheduling_paused`: hard per-user override. Takes effect on the next
scheduler tick (within the cache TTL of 5 minutes; an admin edit
invalidates the cache immediately).
After editing a user's quota via the admin API, the gate cache is
invalidated automatically. You should not need to restart the scheduler.
## Operational notes
- **Failed executions still count.** If the checker engine is misbehaving
for an unrelated reason, users will see their budget drained by failed
runs. Watch the scheduler logs (`Scheduler: checker ... failed: ...`)
and the `ExecutionFailed` status counts in the admin metrics.
- **Process restarts clear the counters.** For deployments with a hard
budget, avoid frequent restarts during peak hours.
- **Manual triggers count by default.** With
`--checker-count-manual-triggers=true` (the default), pressing
"Run now" consumes one unit of the daily budget and returns HTTP 429
once the budget is exhausted. Set the flag to `false` to restore the
legacy bypass; that option is useful for self-hosted instances where
the quota is only meant to protect against runaway scheduled load.
- **HTTP 429 response body.** Rejected manual triggers return
`{"errmsg": "daily check quota exhausted; try again after 00:00 UTC"}`
alongside the 429 status. Clients should surface this to the user —
the `ExecutionRateLimited` status (value `4`) used for planned
executions in `?include_planned=1` can be reused for consistent
iconography.
- **Invalidate is cooperative.** The gate cache is invalidated on user
update and on admin quota edit. If you modify user data through a
backdoor (direct database write, migration, …) the cache will stay
stale until its 5-minute TTL expires.
## Diagnostics
There is currently no dedicated endpoint to introspect per-user usage.
Pragmatic options, in order of effort:
1. Read the scheduler logs: every gated job emits a line at debug level
(commented out by default in `scheduler.go`; enable locally when
investigating).
2. Query planned executions via the user-facing API with
`?include_planned=1` and look for `ExecutionRateLimited` entries —
this confirms a user is currently over budget.
3. Restart the process to force a clean slate (destructive to all
counters; use only as a last resort).

253
docs/checker-scheduler.md Normal file
View file

@ -0,0 +1,253 @@
# Checker scheduling and execution
happyDomain's checker subsystem runs small health checks against DNS zones,
domains, and services. Each check can be triggered in two ways (on a
recurring schedule, or manually from the UI/API), and both paths share the
same execution pipeline (observation → rule evaluation → aggregated status).
This document describes:
1. The two ways a check can be triggered (scheduled vs manual).
2. How the execution pipeline turns an observation into a status.
3. How options are composed across scopes and how auto-fill variables work.
4. Where to find the per-user throttling rules.
> **See also:** [checker-quotas.md](./checker-quotas.md) for the per-user
> scheduling gate (pause, inactivity, daily budget) that sits in front of
> every scheduled and manual trigger.
---
## Key concepts
A few terms recur throughout this document and the checker APIs:
- **Checker.** The unit of monitoring logic: a named program (built-in,
plugin, or external HTTP service) that declares a set of observations
it can collect and a set of rules that evaluate them. A checker is
identified by a stable `checkerId` and described by a
`CheckerDefinition` (options, intervals, availability, rules).
- **Observation.** The raw data produced by a single collect step: for
example, the RTT samples of a ping probe or the response of a DNS
query. Each observation is typed, identified by an
`ObservationKey`, serialised to JSON, and stored in an
`ObservationSnapshot`. A checker may expose several observation
keys; rules request the ones they need.
- **Rule.** A named predicate that turns observations into a
`CheckState` (`StatusOK`, `StatusWarn`, `StatusCrit`, ...). A single
checker typically ships several rules that look at the same
observation from different angles (e.g. "packet loss" and "latency"
on the same ping data). Users can enable or disable rules
individually per target.
- **Target.** What a check runs against: a user, a domain, a
(zone, subdomain) pair, or a service inside a zone. The
`CheckerAvailability` of a checker decides which target levels it
may attach to.
- **CheckPlan.** The per-target configuration that ties a checker to a
specific target. It carries the user's (or admin's) overrides:
interval, and a per-rule `enabled` map. A plan is optional: if none
exists, the checker runs with its declared defaults.
- **Execution.** A single run of the pipeline for a given
(checker, target). It is created either by the scheduler (when a
plan or default schedule is due) or by a manual trigger, and
progresses through the lifecycle statuses `Pending → Running →
Done | Failed`.
- **CheckEvaluation.** The result attached to a finished execution: a
list of rule states plus a reference to the `ObservationSnapshot`
they were computed from. This is what the UI renders as green / amber
/ red badges.
In short: a **CheckPlan** pairs a checker with a target; each run
produces one **Execution**, which collects one or more **Observations**
into a snapshot and feeds them to the enabled **Rules**; the rules'
states are bundled into a **CheckEvaluation**, which becomes the
execution's final result.
## How a check runs
### 1. Scheduled runs (automatic)
The scheduler maintains a priority queue of upcoming jobs and, on every
tick, pops the jobs whose `NextRun` is due. A job that passes the
[user-level gate](./checker-quotas.md#the-three-gates) is handed to the
engine, executed, and re-enqueued for its next run.
**Interval resolution.** The effective interval for a given (checker,
target) pair is chosen in this order:
1. `CheckPlan.Interval`: the per-target override stored in DB, if the
user (or admin) set one.
2. `CheckerDefinition.Interval.Default`: the checker's own default,
declared at registration time.
3. `24h`: the hardcoded fallback used when the checker did not declare
an interval spec.
The result is then **clamped** to `[Interval.Min, Interval.Max]` if the
checker declared bounds, so a user cannot set `Interval = 10s` on a
checker whose minimum is `1m`.
**Spread and jitter.** To avoid thundering herds when many checks share
the same interval, the scheduler adds:
- a **deterministic offset** per (checkerID, target) hashed into the
interval window, and
- a ~5 % **deterministic jitter** per cycle.
Two users who configure the same 5-minute probe on the same day will
therefore run it on different sub-minute offsets.
**Applicability.** A checker is scheduled for a target only if its
`CheckerAvailability` matches: `ApplyToDomain` enrolls every domain,
`ApplyToZone` every published zone, and `ApplyToService` every service
of a type listed in `LimitToServices` (or all of them, if the list is
empty).
### 2. Manual runs ("Run now")
Users (and admins) can trigger an execution on demand:
```
POST /api/domains/{domain}/checkers/{checkerId}/executions
POST /api/domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions
```
The request body is optional:
```json
{
"options": { "...": "..." },
"enabledRules": { "ruleName": true, "otherRule": false }
}
```
- `options` are **run-time overrides** that take effect for this single
execution. They are validated against the checker's `RunOpts`.
- `enabledRules` temporarily selects a subset of the rules for this run;
omit it to evaluate every rule configured for the target.
Behaviour:
- By default, the endpoint returns **HTTP 202** with the newly created
`Execution` and runs the pipeline asynchronously. Poll
`GET .../executions/{id}` (or its aliases) for completion.
- With `?sync=true`, it blocks and returns **HTTP 200** with the
resulting `CheckEvaluation`.
- Manual triggers are subject to the per-user daily budget and may be
refused with **HTTP 429**. See
[checker-quotas.md](./checker-quotas.md#daily-budget).
The handler is `TriggerCheck` in the checker API controller.
## The execution pipeline
Whether the trigger is scheduled or manual, a single execution goes
through the same steps:
1. **Resolve options.** Merge admin → user → domain → service → run-time
overrides into a single `CheckerOptions`, then apply
[auto-fill](#auto-fill-variables).
2. **Collect observations.** Each `ObservationProvider` referenced by
the enabled rules is invoked (with caching: two rules sharing the
same observation key only collect once). Providers return a typed
struct that is JSON-serialised into an `ObservationSnapshot`.
3. **Evaluate rules.** Each enabled `CheckRule` receives the snapshot
(via an `ObservationGetter`) plus the resolved options, and returns a
`CheckState` with one of the statuses below.
4. **Aggregate** the individual rule states into the execution's final
result (worst-status wins, by default) and persist both the snapshot
and the evaluation.
5. **Update the `Execution`** with its terminal `ExecutionStatus` and a
link to the evaluation.
For the anatomy of a checker (data types, providers, rules, metrics),
see the companion project
[checker-dummy](https://git.happydns.org/checker-dummy), which is the
reference walkthrough.
### Check statuses
Returned by each rule and aggregated at the execution level:
| Constant | Meaning |
| ---------------- | ----------------------------------------------- |
| `StatusOK` | Healthy. |
| `StatusInfo` | Informational, not a problem. |
| `StatusWarn` | Soft threshold crossed. |
| `StatusCrit` | Hard threshold crossed. |
| `StatusError` | The rule itself could not evaluate (e.g. bad data). |
| `StatusUnknown` | Not enough information to decide. |
### Execution lifecycle statuses
Attached to the `Execution` record (not to individual rule states):
| Status | Value | Meaning |
| ---------------------- | ----- | ----------------------------------------------------- |
| `ExecutionPending` | `0` | Created, not yet running. |
| `ExecutionRunning` | `1` | Pipeline is executing. |
| `ExecutionDone` | `2` | Pipeline completed; see the linked `CheckEvaluation`. |
| `ExecutionFailed` | `3` | Pipeline errored before producing an evaluation. |
| `ExecutionRateLimited` | `4` | Synthetic, only on planned (not-yet-run) entries returned by `?include_planned=1`; never persisted. See [checker-quotas.md](./checker-quotas.md#ui-signalling). |
## Configuring a check
Every checker exposes a set of typed options grouped by **scope**. The
scope determines who sets the option and for how many targets it
applies at once:
| Scope | Who sets it | Applies to |
| -------------- | ------------------ | -------------------------- |
| `AdminOpts` | instance admin | every user on the instance |
| `UserOpts` | end user | all their own targets |
| `DomainOpts` | end user / auto | a single domain |
| `ServiceOpts` | end user / auto | a single service |
| `RunOpts` | trigger caller | a single execution |
At execution time the scopes are merged in order of increasing
specificity (`admin → user → domain → service → run`), so a per-service
value wins over a per-domain one, which wins over a per-user default,
and so on. Admin-provided values can be locked with the `NoOverride`
attribute to prevent lower scopes from changing them.
The interval itself is **not** an option; it lives on the `CheckPlan`
record for that (checker, target) pair, as described above.
### Auto-fill variables
Some options don't need to be typed in manually: they can be resolved
from the surrounding context of the execution. Each such option is
declared with an `AutoFill` attribute, and the engine populates it just
before the collect step. Supported variables:
| Constant | Resolves to |
| --------------------- | ---------------------------------------------------- |
| `AutoFillDomainName` | The target domain's FQDN. |
| `AutoFillSubdomain` | The subdomain under which the target service lives. |
| `AutoFillZone` | The published zone the check is running against. |
| `AutoFillServiceType` | The service's type identifier. |
| `AutoFillService` | The full service payload. |
Auto-fill runs **last** and overrides any value that may have been set
at a lower scope: the goal is to keep these fields authoritative. Rule
code can therefore rely on them being present and correct at
`Evaluate()` time without re-deriving them from the observation.
## Operational notes
- **Same pipeline for both triggers.** A manual run and a scheduled run
produce an `Execution` of the same shape; the only distinction is the
`TriggerInfo.Type` (`TriggerManual` vs `TriggerSchedule`) stored on
the execution.
- **User gate first.** The per-user pause / inactivity / daily-budget
gate is evaluated before the pipeline starts; a gated scheduled job
is dropped and the counter is not incremented. The rules are in
[checker-quotas.md](./checker-quotas.md).
- **Rules can be disabled per target.** `CheckPlan.Enabled` is a rule
name → boolean map. A missing entry means "enabled", an empty map
means "all enabled". A plan where every rule is explicitly `false`
disables the checker entirely for that target
(`CheckPlan.IsFullyDisabled`).
- **Options validation.** Options are validated both when a plan is
stored and when a manual trigger arrives (with `RunOpts` included for
the latter). Invalid options yield HTTP 400 at trigger time;
`TriggerCheck` does not silently drop bad input.

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

60
docs/metrics.md Normal file
View file

@ -0,0 +1,60 @@
# happyDomain Metrics
happyDomain exposes Prometheus metrics at `GET /metrics` on the **admin
socket only** (Unix socket or loopback). The admin socket is not
authenticated; do not expose it to untrusted networks. The public HTTP API
does **not** serve `/metrics`.
All metric names are prefixed with `happydomain_`.
## Exported metrics
| Metric | Type | Labels | Cardinality bound | Description |
|---|---|---|---|---|
| `happydomain_http_requests_total` | counter | `method`, `path`, `status` | HTTP methods × Gin route templates × HTTP status codes (low hundreds) | Total HTTP requests served. `path` is the Gin route template (e.g. `/api/domains/:domain`), never the raw URL, to keep cardinality bounded. |
| `happydomain_http_request_duration_seconds` | histogram | `method`, `path` | same as above | HTTP request latency, default Prometheus buckets. |
| `happydomain_http_requests_in_flight` | gauge | | 1 | HTTP requests currently being served. |
| `happydomain_scheduler_queue_depth` | gauge (func) | | 1 | Sampled at scrape time via `RegisterSchedulerQueueDepth`. Reports 0 when no scheduler is registered. |
| `happydomain_scheduler_active_workers` | gauge | | 1 | Workers currently executing a check. |
| `happydomain_scheduler_checks_total` | counter | `checker`, `status` | #checker types × {`success`, `error`} | Total scheduler check executions. Checker IDs are system-defined, never user input. |
| `happydomain_scheduler_check_duration_seconds` | histogram | `checker` | #checker types | Check execution latency. |
| `happydomain_provider_api_calls_total` | counter | `provider`, `operation`, `status` | #providers × #ops × {`success`, `error`} | DNS provider API calls. `provider` is the dnscontrol provider name (bounded set). |
| `happydomain_provider_api_duration_seconds` | histogram | `provider`, `operation` | same | DNS provider API latency. |
| `happydomain_storage_operations_total` | counter | `operation`, `entity`, `status` | ~6 ops × ~5 entities × {`success`, `error`} | Storage operations. |
| `happydomain_storage_operation_duration_seconds` | histogram | `operation`, `entity` | same | Storage operation latency. |
| `happydomain_storage_stats_errors_total` | counter | `entity` | #entities | Errors encountered while collecting storage stats during a scrape. Alert on a non-zero rate — silent storage failures otherwise produce gaps in the gauges below. |
| `happydomain_registered_users` | gauge | | 1 | Registered user accounts (sampled live at scrape time). |
| `happydomain_domains` | gauge | | 1 | Domains managed across all users. |
| `happydomain_zones` | gauge | | 1 | Zone snapshots stored. |
| `happydomain_providers` | gauge | | 1 | Provider configurations across all users. |
| `happydomain_build_info` | gauge | `version`, `revision`, `dirty`, `build_date` | 1 per build | Always 1; metadata is in the labels. |
## Cardinality rules
- **Never** add a label whose value comes from user input: domain name, user
ID, zone ID, provider URL, raw HTTP path, etc.
- New labels MUST have a documented finite bound in the table above before
the metric is merged.
- Histograms inherit the cardinality of their labels — be especially careful.
## Security
`/metrics` exposes business intelligence (entity counts, provider mix,
latency profiles) and operational shape (queue depth, worker counts). It is
intentionally only mounted on the admin socket (`internal/app/admin.go`).
Bind that socket to a Unix path or `127.0.0.1` only — exposing it on a
network interface will leak this information to anyone who can reach it.
## Implementation notes
- The HTTP middleware uses `c.FullPath()` (Gin route template) to populate
the `path` label. See `internal/metrics/http.go`.
- The scheduler queue depth gauge is a `GaugeFunc` that calls back into the
scheduler at scrape time, installed via
`metrics.RegisterSchedulerQueueDepth`. The scheduler unregisters its
accessor in `Stop()` so stopped schedulers do not leak their queue.
- The storage stats collector runs each `Count*` query in its own goroutine
with a `recover()` guard, so a panicking backend cannot crash the scrape.
Failures increment `happydomain_storage_stats_errors_total{entity=…}`.
- `happydomain_build_info` is set once at startup from `cmd/happyDomain/main.go`
using `versioninfo.Revision`, `LastCommit`, and `DirtyBuild`.

View file

@ -0,0 +1,149 @@
# Building a happyDomain Checker Plugin
This page documents how to ship a **checker** as an in-process Go plugin
that happyDomain loads at startup. Checker plugins extend happyDomain with
automated diagnostics on zones, domains, services or users.
If you've never built a happyDomain plugin before, read
[`checker-dummy`](https://git.happydns.org/checker-dummy) first; it is the
reference implementation that this page mirrors.
> ⚠️ **Security note.** A `.so` plugin is loaded into the happyDomain process
> and runs with the same privileges. happyDomain refuses to load plugins from
> a directory that is group- or world-writable; keep your plugin directory
> owned and writable only by the happyDomain user.
---
## What a checker plugin must export
happyDomain's loader looks for a single exported symbol named
`NewCheckerPlugin` with this exact signature:
```go
func NewCheckerPlugin() (
*checker.CheckerDefinition,
checker.ObservationProvider,
error,
)
```
where `checker` is `git.happydns.org/checker-sdk-go/checker` (see
[Licensing](#licensing) below for why the SDK lives in a separate module).
- `*CheckerDefinition` describes the checker: ID, name, version, options
documentation, rules, optional aggregator, scheduling interval, and
whether the checker exposes HTML reports or metrics. The `ID` field is
the persistent key: pick something stable and namespaced
(`com.example.dnssec-freshness`, not `dnssec`).
- `ObservationProvider` is the data-collection half of the checker. It
exposes a `Key()` (the observation key the rules will look up) and a
`Collect(ctx, opts)` method that returns the raw observation payload.
happyDomain serialises the result to JSON and caches it per
`ObservationContext`.
- Return a non-nil `error` if your plugin cannot initialise (missing
environment variable, broken cgo dependency, …); the host will log it and
skip the file rather than aborting startup.
### Registration and collisions
The loader calls `RegisterExternalizableChecker` and
`RegisterObservationProvider` from the SDK registry. Pick globally unique
identifiers: if your checker ID or observation key collides with a built-in
or another plugin, the duplicate is ignored.
The same `.so` may export both `NewCheckerPlugin` and (e.g.)
`NewProviderPlugin`. The loader runs every known plugin loader against
every file, so a single binary can ship a checker, a provider and a service
at once.
---
## Minimal example
```go
// Command plugin is the happyDomain plugin entrypoint for the dummy checker.
//
// Build with:
// go build -buildmode=plugin -o checker-dummy.so ./plugin
package main
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type dummyProvider struct{}
func (dummyProvider) Key() sdk.ObservationKey { return "dummy.observation" }
func (dummyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
return map[string]string{"hello": "world"}, nil
}
// NewCheckerPlugin is the symbol resolved by happyDomain at startup.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
def := &sdk.CheckerDefinition{
ID: "com.example.dummy",
Name: "Dummy checker",
Version: "0.1.0",
ObservationKeys: []sdk.ObservationKey{"dummy.observation"},
// Add Rules / Aggregator / Options here in a real plugin.
}
return def, dummyProvider{}, nil
}
```
Build and deploy:
```bash
go build -buildmode=plugin -o checker-dummy.so ./plugin
sudo install -m 0644 -o happydomain checker-dummy.so /var/lib/happydomain/plugins/
sudo systemctl restart happydomain
```
happyDomain will log:
```
Plugin com.example.dummy (.../checker-dummy.so) loaded
```
---
## Build constraints and platform support
Go's `plugin` package is unforgiving:
- The plugin **must be built with the same Go version** as happyDomain
itself, including the same toolchain patch level.
- It **must use the same versions of every shared dependency**. Vendor the
exact module versions happyDomain ships, or pin them in your `go.mod`
with `replace` directives.
- `CGO_ENABLED=1` is required.
- `GOOS`/`GOARCH` must match the host binary.
If any of these don't match, `plugin.Open` will fail with a (sometimes
cryptic) error like *"plugin was built with a different version of package
…"*. The host will log it and skip the file.
Go's `plugin` package only works on **linux**, **darwin** and **freebsd**.
On other platforms (Windows, plan9, …) happyDomain is built without plugin
support and `--plugins-directory` is silently ignored apart from a warning
log line at startup.
---
## Licensing
Checker plugins import only `git.happydns.org/checker-sdk-go/checker`,
which is licensed under **Apache-2.0**. This is intentional: the
checker SDK is a small, stable public API for third-party checkers,
deliberately split out of the AGPL-3.0 happyDomain core so that
permissively-licensed checker plugins are possible.
You may therefore distribute your checker `.so` under any license compatible
with Apache-2.0. Note that this only covers checker plugins; provider and
service plugins still link against AGPL code and remain subject to the
AGPL-3.0 reciprocity rules described in their respective documentation
([provider](provider-plugin.md), [service](service-plugin.md)).

275
docs/prometheus.md Normal file
View file

@ -0,0 +1,275 @@
# Scraping happyDomain checker metrics with Prometheus
happyDomain exposes check results as time-series metrics that Prometheus can
scrape directly. This lets you alert on DNS health checks, track trends over
time, and correlate domain health with the rest of your infrastructure.
> **Scope of this document:** user-facing metrics from the checker subsystem.
> The admin-socket `/metrics` endpoint (happyDomain internal instrumentation)
> is covered separately in [metrics.md](metrics.md).
---
## Getting started
### 1. Create an API token
The metrics endpoints require authentication. You need a long-lived API token
(not your login session).
1. Log in to happyDomain.
2. Go to the **Account** page (top-right user menu).
3. Scroll down to the **Security & Access** section.
4. Click **Create API key**.
5. Give the key a descriptive name (e.g. `prometheus-scraper`).
6. Click **Create API key** in the modal.
7. **Copy the secret immediately** (it is shown only once).
![Create API key modal showing the secret field](user-create-api-key.png)
The secret is used as a Bearer token in every request:
```
Authorization: Bearer <your-secret-here>
```
### 2. Verify manually
Before configuring Prometheus, confirm that the endpoint is reachable and
returns Prometheus-formatted data:
```bash
curl -s \
-H "Authorization: Bearer <your-secret>" \
-H "Accept: text/plain" \
https://happydomain.example.com/api/checkers/metrics
```
You should see lines like:
```
# HELP dns_rtt_seconds unit: s
# TYPE dns_rtt_seconds untyped
dns_rtt_seconds{...} 0.042 1744800000000
```
> **Format selection:** the API returns JSON by default. Add
> `Accept: text/plain` (or `Accept: application/openmetrics-text`) to receive
> Prometheus text exposition format 0.0.4. Prometheus itself sends the right
> header automatically when you use the `params` and `headers` scrape options
> shown below.
### 3. Minimal Prometheus scrape config
```yaml
scrape_configs:
- job_name: happydomain_checks
metrics_path: /api/checkers/metrics
scheme: https
authorization:
type: Bearer
credentials: <your-secret>
params:
# Not needed; Prometheus sends Accept: application/openmetrics-text
# by default and the API honours it.
static_configs:
- targets:
- happydomain.example.com
```
That's it. Prometheus will now scrape all check metrics for your account on
every evaluation interval.
---
## Available endpoints
All endpoints are under `/api` and require `Authorization: Bearer <token>`.
### All user metrics
```
GET /api/checkers/metrics
```
Returns metrics from recent executions of **every checker across all your
domains**. This is the broadest scrape target, useful when you want a single
job covering everything.
| Query parameter | Default | Description |
|---|---|---|
| `limit` | `100` | Maximum number of recent executions to include. |
```bash
curl -s \
-H "Authorization: Bearer <token>" \
-H "Accept: text/plain" \
"https://happydomain.example.com/api/checkers/metrics?limit=200"
```
---
### Domain metrics
```
GET /api/domains/{domain}/checkers/metrics
```
Returns metrics for **all checkers on a single domain**, including checkers
running on its services. `{domain}` is your domain FQDN (e.g.
`example.com`) or its internal identifier; both are accepted.
| Query parameter | Default | Description |
|---|---|---|
| `limit` | `100` | Maximum number of recent executions to include. |
```bash
curl -s \
-H "Authorization: Bearer <token>" \
-H "Accept: text/plain" \
"https://happydomain.example.com/api/domains/example.com/checkers/metrics"
```
Prometheus config for per-domain scraping:
```yaml
scrape_configs:
- job_name: happydomain_example_com
metrics_path: /api/domains/example.com/checkers/metrics
scheme: https
authorization:
type: Bearer
credentials: <your-secret>
static_configs:
- targets:
- happydomain.example.com
```
![Checker configuration page with the Prometheus Metrics button highlighted](domain-prometheus-url.png)
---
### Per-checker metrics (domain level)
```
GET /api/domains/{domain}/checkers/{checkerId}/metrics
```
Returns metrics for **one specific checker on a domain**. Use this when you
want fine-grained scrape intervals or separate Prometheus jobs per checker
type.
`{checkerId}` is the checker identifier (e.g. `dnssec`, `mx_reachability`).
The easiest way to obtain the exact URL is the **Prometheus Metrics** button
on the checker configuration page (visible when the checker produces metrics).
| Query parameter | Default | Description |
|---|---|---|
| `limit` | `100` | Maximum number of recent executions to include. |
```bash
curl -s \
-H "Authorization: Bearer <token>" \
-H "Accept: text/plain" \
"https://happydomain.example.com/api/domains/example.com/checkers/dnssec/metrics"
```
---
### Per-checker metrics (service level)
```
GET /api/domains/{domain}/zone/{zoneId}/{subdomain}/services/{serviceId}/checkers/{checkerId}/metrics
```
Returns metrics for **one specific checker on a DNS service** (a structured
record group, e.g. an MX configuration or an SPF record).
The URL for a service-level checker can be copied from the **Prometheus
Metrics** button on the service's checker configuration page.
| Path segment | Description |
|---|---|
| `{domain}` | Domain FQDN or identifier |
| `{zoneId}` | Zone snapshot identifier (opaque string) |
| `{subdomain}` | Relative owner name within the zone (e.g. `@`, `mail`) |
| `{serviceId}` | Service identifier (opaque string) |
| `{checkerId}` | Checker identifier |
```bash
curl -s \
-H "Authorization: Bearer <token>" \
-H "Accept: text/plain" \
"https://happydomain.example.com/api/domains/example.com/zone/<zoneId>/@/services/<serviceId>/checkers/spf_policy/metrics"
```
---
### Single-execution metrics
```
GET /api/domains/{domain}/checkers/{checkerId}/executions/{executionId}/metrics
GET /api/domains/{domain}/zone/{zoneId}/{subdomain}/services/{serviceId}/checkers/{checkerId}/executions/{executionId}/metrics
```
Returns metrics from **one specific execution run**. This is not typically
used for Prometheus scraping (the data is historical and does not change after
the execution completes); it is more useful for debugging or one-off
inspections.
```bash
curl -s \
-H "Authorization: Bearer <token>" \
-H "Accept: text/plain" \
"https://happydomain.example.com/api/domains/example.com/checkers/dnssec/executions/<executionId>/metrics"
```
---
## Multi-domain Prometheus configuration
To scrape several domains with a single Prometheus job, use
`relabel_configs` to build the path dynamically from a label:
```yaml
scrape_configs:
- job_name: happydomain_domains
scheme: https
authorization:
type: Bearer
credentials: <your-secret>
metrics_path: /api/checkers/metrics # fallback; overridden per target
relabel_configs:
- source_labels: [__address__]
regex: (.+)@(.+)
target_label: __metrics_path__
replacement: /api/domains/$1/checkers/metrics
- source_labels: [__address__]
regex: .+@(.+)
target_label: __address__
replacement: $1
- source_labels: [domain]
target_label: domain
static_configs:
- targets:
- example.com@happydomain.example.com
- otherdomain.net@happydomain.example.com
labels:
instance: happydomain.example.com
```
Each target encodes `domain@host`; the relabel rules split them into the
correct `__metrics_path__` and `__address__`.
---
## Security considerations
- **Treat the API token like a password.** It grants read access to all check
metrics associated with your account.
- Use a dedicated token for each scraper. You can revoke individual tokens from
the **Security & Access** page without affecting your login session.
- Prefer HTTPS so the `Authorization` header is not transmitted in the clear.
- The metrics endpoints return only aggregated numeric values; they do not
expose DNS zone content, provider credentials, or other sensitive
configuration.

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View file

@ -21,10 +21,11 @@
package main
//go:generate go run tools/gen_instrumented_storage.go
//go:generate go run tools/gen_icon.go providers providers
//go:generate go run tools/gen_icon.go services svcs
//go:generate go run tools/gen_rr_typescript.go web/src/lib/dns_rr.ts
//go:generate go run tools/gen_service_specs.go -o web/src/lib/services_specs.ts
//go:generate go run tools/gen_dns_type_mapping.go -o internal/usecase/service_specs_dns_types.go
//go:generate swag init --exclude internal/api-admin/ --generalInfo internal/api/route/route.go
//go:generate swag init --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go
//go:generate swag init --parseDependency --exclude internal/api-admin/ --generalInfo internal/api/route/route.go
//go:generate swag init --parseDependency --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go

24
go.mod
View file

@ -5,6 +5,12 @@ go 1.25.0
toolchain go1.26.2
require (
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489
git.happydns.org/checker-ns-restrictions v0.0.0-20260415205411-f1e3096f606d
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc
git.happydns.org/checker-sdk-go v0.5.0
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc
github.com/JGLTechnologies/gin-rate-limit v1.5.8
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.18.0
@ -33,6 +39,16 @@ require (
golang.org/x/oauth2 v0.36.0
)
require (
github.com/alecthomas/kingpin/v2 v2.4.0 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/likexian/gokit v0.25.16 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
)
require (
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
@ -157,6 +173,8 @@ require (
github.com/labstack/echo/v4 v4.15.1 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/likexian/whois v1.15.7
github.com/likexian/whois-parser v1.24.21
github.com/luadns/luadns-go v0.3.0 // indirect
github.com/mailgun/raymond/v2 v2.0.48 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
@ -170,6 +188,7 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
github.com/nrdcg/goinwx v0.12.0 // indirect
github.com/openrdap/rdap v0.9.1
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/oracle/oci-go-sdk/v65 v65.111.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
@ -179,9 +198,10 @@ 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/client_golang v1.23.2 // indirect
github.com/prometheus-community/pro-bing v0.8.0 // indirect
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/common v0.67.5
github.com/prometheus/procfs v0.20.1 // indirect
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 // indirect
github.com/quic-go/qpack v0.6.0 // indirect

200
go.sum
View file

@ -1,25 +1,29 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
codeberg.org/miekg/dns v0.6.67 h1:vsVNsqAOE9uYscJHIHNtoCxiEySQn/B9BEvAUYI5Zmc=
codeberg.org/miekg/dns v0.6.67/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
codeberg.org/miekg/dns v0.6.73 h1:4aRD1k1THw49vpe1d+W3KO16adAGN8Raxdi0WGvvbrY=
codeberg.org/miekg/dns v0.6.73/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489 h1:pTGfGq88Dj4Y60LJLSW4FvpUubeYpNlwuxKt/2IFzdo=
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489/go.mod h1:fQjY1yWYFucu+Ebn5uYM7ZWTJNQIgjMENI/8tqlaR98=
git.happydns.org/checker-ns-restrictions v0.0.0-20260415205411-f1e3096f606d h1:WqNi0vYSu7ZtPC4zNTwrZM64WcceaOistkF9qMxFQvE=
git.happydns.org/checker-ns-restrictions v0.0.0-20260415205411-f1e3096f606d/go.mod h1:Sw8SqlrTAi3ZSVQQ+5kKE8reekxvLRm7oYlUYfJD+Z4=
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc h1:jKEOx2NDbHHxjCy1fUkcn1RgpzOKbE+bGRsF+ITNigI=
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc/go.mod h1:wphWmslFhKcpWfJTrHdChv8DkhUP9jwis7V2jy7vOX0=
git.happydns.org/checker-sdk-go v0.5.0 h1:wpFIK/vxanrAYf1OlewSnSCYc7KOJKdu88uUWB7HIQI=
git.happydns.org/checker-sdk-go v0.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc h1:y5xjoqLA/WztFWhEUifOwnJ6POjl+Udw6bWjzQ2afOw=
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc/go.mod h1:B1P23OMm82GfAtYw8vCbspc7qULsFA0u/tqR+SGAaNw=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c=
@ -36,8 +40,6 @@ github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+W
github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1 h1:edShSHV3DV90+kt+CMaEXEzR9QF7wFrPJxVGz2blMIU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@ -47,21 +49,19 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
github.com/CloudyKit/fastprinter v0.0.0-20251202014920-1725d2651bd4 h1:DQ1+lDdBve+u+aovjh4wV6sYnvZKH0Hx8GaQOi4vYl8=
github.com/CloudyKit/fastprinter v0.0.0-20251202014920-1725d2651bd4/go.mod h1:eauGmjfZG874MOAEPVeqg21mZCbTOLW+tFe8F7NpfnY=
github.com/CloudyKit/jet/v6 v6.3.1 h1:6IAo5Cx21xrHVaR8zzXN5gJatKV/wO7Nf6bfCnCSbUw=
github.com/CloudyKit/jet/v6 v6.3.1/go.mod h1:lf8ksdNsxZt7/yH/3n4vJQWA9RUq4wpaHtArHhGVMOw=
github.com/CloudyKit/jet/v6 v6.3.2 h1:BPaX0lnXTZ9TniICiiK/0iJqzeGJ2ibvB4DjAqLMBSM=
github.com/CloudyKit/jet/v6 v6.3.2/go.mod h1:lf8ksdNsxZt7/yH/3n4vJQWA9RUq4wpaHtArHhGVMOw=
github.com/G-Core/gcore-dns-sdk-go v0.3.3 h1:McILJSbJ5nOcT0MI0aBYhEuufCF329YbqKwFIN0RjCI=
github.com/G-Core/gcore-dns-sdk-go v0.3.3/go.mod h1:35t795gOfzfVanhzkFyUXEzaBuMXwETmJldPpP28MN4=
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0=
github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g=
github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc=
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk=
github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
@ -72,12 +72,14 @@ github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0 h1:iGVPe/gPqzpXggbbmVLWR0TyJ9UoPoqKL+kspjseZzE=
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0/go.mod h1:76JtkiCKMwTdTOlKe9goT4Md+oWjfMouGBQgy+u1bgc=
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
github.com/altcha-org/altcha-lib-go v1.0.0 h1:7oPti0aUS+YCep8nwt5b9g4jYfCU55ZruWESL8G9K5M=
github.com/altcha-org/altcha-lib-go v1.0.0/go.mod h1:I8ESLVWR9C58uvGufB/AJDPhaSU4+4Oh3DLpVtgwDAk=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
@ -87,68 +89,36 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3 h1:JRPXnIr0WwFsSHBmuCvT/uh0Vgys+crvwkOghbJEqi8=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3/go.mod h1:DHddp7OO4bY467WVCqWBzk5+aEWn7vqYkap7UigJzGk=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 h1:Z+/OLsb85Kpq7TVLCspskqePaf68Tdv6GfmJP4kH6i0=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5/go.mod h1:TmxGowuBYwjmHFOsEDxaZdsQE62JJzOmtiWafTi/czg=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17 h1:Fw2SIR63jhfLpFZr6955zU3g9V8ouHC/pRpmmiHmIFM=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17/go.mod h1:x9PRRtbCQ/gv1ziQPXFB7nQwQgVLQ+FSvPIkVAhRcYY=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.19 h1:I1uSW0oydwLZWp4IDjGqAJY+EoNFylgNxxcXeOSioVk=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.19/go.mod h1:1qCxun61Kq+S1e790tY+MpOKQ29DoOt2Fdx8Efgmo2g=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@ -165,20 +135,15 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18 h1:RvyTDU0VmnUBd3Qm2i6irEXtCR2KRIxnRlD8l+5z/DY=
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18/go.mod h1:a6n4wXFHbMW0iJFxHIJR4PkgG5krP52nOVCBU0m+Obw=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
@ -196,12 +161,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deepmap/oapi-codegen v1.16.3 h1:GT9G86SbQtT1r8ZB+4Cybi9VGdu1P5ieNvNdEoCSbrA=
github.com/deepmap/oapi-codegen v1.16.3/go.mod h1:JD6ErqeX0nYnhdciLc61Konj3NBASREMlkHOgHn8WAM=
github.com/digitalocean/godo v1.176.0 h1:P379vPO5TUre+bUHPEsdSAbl5vIrRRhP91tMIEPoWYU=
github.com/digitalocean/godo v1.176.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/digitalocean/godo v1.184.0 h1:2B2CQhxftlf3xa24Nrzn5CBQlaQjyaWqi3XbbnJlG3w=
github.com/digitalocean/godo v1.184.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
github.com/dnsimple/dnsimple-go/v8 v8.1.0 h1:U4ENaNCe5aUFHLiF7lj2NNpLPzFY3YIriu/UzrdfUbg=
github.com/dnsimple/dnsimple-go/v8 v8.1.0/go.mod h1:61MdYHRL+p2TBBUVEkxo1n4iRF6s3R9fZcvQvyt5du8=
github.com/dnsimple/dnsimple-go/v8 v8.2.0 h1:nNgtqKrt1K1BIWIpKTCL2qCiQcfYUxzsyRGIKLYEYH0=
github.com/dnsimple/dnsimple-go/v8 v8.2.0/go.mod h1:61MdYHRL+p2TBBUVEkxo1n4iRF6s3R9fZcvQvyt5du8=
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
@ -231,8 +194,6 @@ github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
@ -249,56 +210,33 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-mail/mail v2.3.1+incompatible h1:UzNOn0k5lpfVtO31cK3hn6I4VEVGhe3lX8AJBAxXExM=
github.com/go-mail/mail v2.3.1+incompatible/go.mod h1:VPWjmmNyRsWXQZHVHT3g0YbIINUkSmuKOiLIDkWbL6M=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
@ -311,8 +249,6 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@ -321,8 +257,6 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe h1:zn8tqiUbec4wR94o7Qj3LZCAT6uGobhEgnDRg6isG5U=
github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXKkTfoE=
@ -333,7 +267,6 @@ github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
@ -376,12 +309,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
@ -408,14 +337,10 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g=
github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0=
github.com/hetznercloud/hcloud-go/v2 v2.36.0 h1:HlLL/aaVXUulqe+rsjoJmrxKhPi1MflL5O9iq5QEtvo=
github.com/hetznercloud/hcloud-go/v2 v2.36.0/go.mod h1:MnN/QJEa/RYNQiiVoJjNHPntM7Z1wlYPgJ2HA40/cDE=
github.com/hetznercloud/hcloud-go/v2 v2.37.0 h1:PMnuOA8pL8aHLLPp6nnnCTo2Xk2tqu4dAfYsC3bWdT0=
github.com/hetznercloud/hcloud-go/v2 v2.37.0/go.mod h1:zaDOCKmpnI86ftoCpUpaiYaw9Wew1ib1AcXTh96deYI=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 h1:J+U6+eUjIsBhefolFdZW5hQNJbkMj+7msxZrv56Cg2g=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.192 h1:xgKdmcALGqLGBrBG8stMli0k+irufCeNPenn76Y4U9o=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.192/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
@ -458,8 +383,6 @@ github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwf
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@ -474,8 +397,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@ -489,12 +410,16 @@ github.com/libdns/ionos v1.2.0 h1:FQ2xQTBfsjc7aMArRBBCs9l48Squt76GHXbxDsqOKgw=
github.com/libdns/ionos v1.2.0/go.mod h1:g/JYno/+VXdujTGPBDMDeCfeLF0PJyJynsCrFu+2EFQ=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP8=
github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4=
github.com/likexian/whois v1.15.7 h1:sajjDhi2bVD71AHJhjV7jLYxN92H4AWhTwxM8hmj7c0=
github.com/likexian/whois v1.15.7/go.mod h1:kdPQtYb+7SQVftBEbCblDadUkycN7Mg1k1/Li/rwvmc=
github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpOtTDA5OsM=
github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4=
github.com/luadns/luadns-go v0.3.0 h1:mN2yhFv/LnGvPw/HmvYUhXe+lc95oXUqjlYVeJeOJng=
github.com/luadns/luadns-go v0.3.0/go.mod h1:DmPXbrGMpynq1YNDpvgww3NP5Zf4wXM5raAbGrp5L+8=
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@ -504,8 +429,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
@ -545,19 +468,16 @@ github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGm
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/openrdap/rdap v0.9.1 h1:Rv6YbanbiVPsKRvOLdUmlU1AL5+2OFuEFLjFN+mQsCM=
github.com/openrdap/rdap v0.9.1/go.mod h1:vKSiotbsENrjM/vaHXLddXbW8iQkBfa+ldEuYEjyLTQ=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/oracle/nosql-go-sdk v1.4.7 h1:dqVBSMulObDj0JHm1mAncTHrQg8wIiQJNC0JRNKPACg=
github.com/oracle/nosql-go-sdk v1.4.7/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
github.com/oracle/oci-go-sdk/v65 v65.109.0 h1:EsbFVvcV+uid9SoQnFQbTKS6FgqsM9NtoKmUIovKsbk=
github.com/oracle/oci-go-sdk/v65 v65.109.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
github.com/oracle/oci-go-sdk/v65 v65.111.0 h1:eDkWg6ZN0uKwWzSekoFcQJhR+C+F/aVdTwr+lGHU9Qk=
github.com/oracle/oci-go-sdk/v65 v65.111.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
github.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c=
@ -573,6 +493,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=
@ -580,8 +502,6 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 h1:wSmWgpuccqS2IOfmYrbRiUgv+g37W5suLLLxwwniTSc=
@ -590,6 +510,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0=
github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@ -635,6 +557,7 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@ -646,20 +569,14 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tdewolff/minify/v2 v2.24.9 h1:W6A570F9N6MuZtg9mdHXD93piZZIWJaGpbAw9Narrfw=
github.com/tdewolff/minify/v2 v2.24.9/go.mod h1:9F66jUzl/Pdf6Q5x0RXFUsI/8N1kjBb3ILg9ABSWoOI=
github.com/tdewolff/minify/v2 v2.24.12 h1:YXJxVJmz7vxgnEv1v8J/EI4x+Uw4MMohcRFK7TFOjmk=
github.com/tdewolff/minify/v2 v2.24.12/go.mod h1:exq1pjdrh9uAICdfVKQwqz6MsJmWmQahZuTC6pTO6ro=
github.com/tdewolff/parse/v2 v2.8.8 h1:l3yOJ4OUKq1sKeQQxZ7P2yZ6daW/Oq4IDxL98uTOpPI=
github.com/tdewolff/parse/v2 v2.8.8/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/parse/v2 v2.8.11 h1:SGyjEy3xEqd+W9WVzTlTQ5GkP/en4a1AZNZVJ1cvgm0=
github.com/tdewolff/parse/v2 v2.8.11/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/transip/gotransip/v6 v6.26.1 h1:MeqIjkTBBsZwWAK6giZyMkqLmKMclVHEuTNmoBdx4MA=
github.com/transip/gotransip/v6 v6.26.1/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=
github.com/transip/gotransip/v6 v6.26.2 h1:pnbDXrkFevOngpi6ertLw6e57lOW+Rk3djJ9AewmJ94=
github.com/transip/gotransip/v6 v6.26.2/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@ -676,7 +593,6 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vercel/terraform-provider-vercel v1.14.1 h1:ghAjFkMMzka4XuoBYdu1OXM/K7FQEj8wUd+xMPPOGrg=
github.com/vercel/terraform-provider-vercel v1.14.1/go.mod h1:AdFCiUD0XP8XOi6tnhaCh7I0vyq2TAPmI+GcIp3+7SI=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
@ -694,6 +610,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
@ -718,44 +636,32 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -777,8 +683,6 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
@ -795,8 +699,6 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -819,8 +721,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@ -859,7 +759,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@ -890,8 +789,6 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -908,8 +805,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -919,36 +814,27 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI=
google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=

View file

@ -26,11 +26,13 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/StackExchange/dnscontrol/v4/models"
dnscontrol "github.com/StackExchange/dnscontrol/v4/pkg/providers"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/model"
)
@ -150,7 +152,11 @@ func NewDNSControlProviderAdapter(configAdapter DNSControlConfigAdapter) (ret ha
auditor = p.RecordAuditor
}
return &DNSControlAdapterNSProvider{provider, auditor}, nil
return &DNSControlAdapterNSProvider{
DNSServiceProvider: provider,
RecordAuditor: auditor,
providerName: configAdapter.DNSControlName(),
}, nil
}
// DNSControlAdapterNSProvider wraps a DNSControl provider to implement the happyDomain ProviderActuator interface.
@ -160,6 +166,8 @@ type DNSControlAdapterNSProvider struct {
DNSServiceProvider dnscontrol.DNSServiceProvider
// RecordAuditor validates records for provider-specific requirements
RecordAuditor dnscontrol.RecordAuditor
// providerName is the DNSControl provider name used for metrics labels
providerName string
}
// CanListZones checks if the provider supports listing zones (domains).
@ -169,6 +177,26 @@ func (p *DNSControlAdapterNSProvider) CanListZones() bool {
return ok
}
// observeProviderCall starts timing a provider API call and returns a closure
// that records the outcome. Intended use:
//
// defer p.observeProviderCall("op")(&err)
//
// The returned closure reads *err at defer-execution time, so it observes the
// final value of the named return even if it is reassigned later in the
// function (including from a recover() block).
func (p *DNSControlAdapterNSProvider) observeProviderCall(operation string) func(err *error) {
start := time.Now()
return func(err *error) {
status := "success"
if err != nil && *err != nil {
status = "error"
}
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, operation, status).Inc()
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, operation).Observe(time.Since(start).Seconds())
}
}
// CanCreateDomain checks if the provider supports creating new domains.
// Returns true if the provider implements the ZoneCreator interface.
func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
@ -182,6 +210,7 @@ func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happydns.Record, err error) {
var records models.Records
defer p.observeProviderCall("get_zone_records")(&err)
defer func() {
if a := recover(); a != nil {
err = fmt.Errorf("%s", a)
@ -211,6 +240,8 @@ func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happy
// before computing corrections.
// Returns a slice of corrections, the total number of corrections needed, and any error.
func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []happydns.Record) (ret []*happydns.Correction, nbCorrections int, err error) {
defer p.observeProviderCall("get_zone_corrections")(&err)
var dc *models.DomainConfig
dc, err = NewDNSControlDomainConfig(strings.TrimSuffix(domain, "."), rrs)
if err != nil {
@ -261,23 +292,31 @@ func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []ha
// CreateDomain creates a new zone (domain) on the provider.
// The fqdn parameter should be a fully qualified domain name (with or without trailing dot).
// Returns an error if the provider doesn't support domain creation or if creation fails.
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) error {
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) (err error) {
defer p.observeProviderCall("create_domain")(&err)
zc, ok := p.DNSServiceProvider.(dnscontrol.ZoneCreator)
if !ok {
return fmt.Errorf("Provider doesn't support domain creation.")
err = fmt.Errorf("Provider doesn't support domain creation.")
return
}
return zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
err = zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
return
}
// ListZones retrieves a list of all zones (domains) managed by this provider.
// Returns a slice of domain names or an error if the provider doesn't support listing
// or if the operation fails.
func (p *DNSControlAdapterNSProvider) ListZones() ([]string, error) {
func (p *DNSControlAdapterNSProvider) ListZones() (zones []string, err error) {
defer p.observeProviderCall("list_zones")(&err)
zl, ok := p.DNSServiceProvider.(dnscontrol.ZoneLister)
if !ok {
return nil, fmt.Errorf("Provider doesn't support domain listing.")
err = fmt.Errorf("Provider doesn't support domain listing.")
return
}
return zl.ListZones()
zones, err = zl.ListZones()
return
}

View file

@ -0,0 +1,272 @@
// 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 adapter provides tests for DNSControlAdapterNSProvider instrumentation.
// These tests live in the internal package so they can construct the struct
// directly and set the unexported providerName field used in metric labels.
package adapter
import (
"errors"
"testing"
dnscontrolmodels "github.com/StackExchange/dnscontrol/v4/models"
dnscontrol "github.com/StackExchange/dnscontrol/v4/pkg/providers"
"github.com/prometheus/client_golang/prometheus/testutil"
"git.happydns.org/happyDomain/internal/metrics"
)
// --- mock DNSServiceProvider -------------------------------------------------
// mockDNSProvider implements dnscontrol.DNSServiceProvider (i.e. models.DNSProvider).
type mockDNSProvider struct {
getZoneRecordsErr error
getZoneRecordsResult dnscontrolmodels.Records
correctionsErr error
panicOnGetZoneRecords bool
}
func (m *mockDNSProvider) GetNameservers(domain string) ([]*dnscontrolmodels.Nameserver, error) {
return nil, nil
}
func (m *mockDNSProvider) GetZoneRecords(domain string, meta map[string]string) (dnscontrolmodels.Records, error) {
if m.panicOnGetZoneRecords {
panic("simulated provider panic")
}
return m.getZoneRecordsResult, m.getZoneRecordsErr
}
func (m *mockDNSProvider) GetZoneRecordsCorrections(dc *dnscontrolmodels.DomainConfig, existing dnscontrolmodels.Records) ([]*dnscontrolmodels.Correction, int, error) {
return nil, 0, m.correctionsErr
}
// mockZoneLister extends mockDNSProvider with ZoneLister.
type mockZoneLister struct {
mockDNSProvider
listErr error
listResult []string
}
func (m *mockZoneLister) ListZones() ([]string, error) {
return m.listResult, m.listErr
}
// mockZoneCreator extends mockDNSProvider with ZoneCreator.
type mockZoneCreator struct {
mockDNSProvider
ensureErr error
}
func (m *mockZoneCreator) EnsureZoneExists(domain string, metadata map[string]string) error {
return m.ensureErr
}
// --- helpers -----------------------------------------------------------------
// noopAuditor is a RecordAuditor that approves all records.
func noopAuditor(rcs []*dnscontrolmodels.RecordConfig) []error { return nil }
// newTestAdapter constructs a DNSControlAdapterNSProvider with the given
// provider mock and a fixed providerName so metric labels are predictable.
func newTestAdapter(provider dnscontrol.DNSServiceProvider) *DNSControlAdapterNSProvider {
return &DNSControlAdapterNSProvider{
DNSServiceProvider: provider,
RecordAuditor: noopAuditor,
providerName: "TEST_PROVIDER",
}
}
// --- GetZoneRecords ----------------------------------------------------------
func TestObserveProviderCall_GetZoneRecords_Success(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockDNSProvider{})
_, err := a.GetZoneRecords("example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "success")); got != 1 {
t.Errorf("expected counter=1 for success, got %v", got)
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "error")); got != 0 {
t.Errorf("expected error counter=0, got %v", got)
}
}
func TestObserveProviderCall_GetZoneRecords_Error(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockDNSProvider{getZoneRecordsErr: errors.New("upstream timeout")})
_, err := a.GetZoneRecords("example.com.")
if err == nil {
t.Fatal("expected an error, got nil")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "error")); got != 1 {
t.Errorf("expected error counter=1, got %v", got)
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "success")); got != 0 {
t.Errorf("expected success counter=0, got %v", got)
}
}
func TestObserveProviderCall_GetZoneRecords_Panic(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockDNSProvider{panicOnGetZoneRecords: true})
// The recover() block in GetZoneRecords must catch the panic and return an
// error, which the observe closure then records as "error".
_, err := a.GetZoneRecords("example.com.")
if err == nil {
t.Fatal("expected panic to be recovered as an error, got nil")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "error")); got != 1 {
t.Errorf("expected error counter=1 after recovered panic, got %v", got)
}
}
// --- GetZoneCorrections ------------------------------------------------------
func TestObserveProviderCall_GetZoneCorrections_Success(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockDNSProvider{})
_, _, err := a.GetZoneCorrections("example.com.", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_corrections", "success")); got != 1 {
t.Errorf("expected counter=1 for success, got %v", got)
}
}
func TestObserveProviderCall_GetZoneCorrections_Error(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockDNSProvider{getZoneRecordsErr: errors.New("provider down")})
_, _, err := a.GetZoneCorrections("example.com.", nil)
if err == nil {
t.Fatal("expected an error, got nil")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_corrections", "error")); got != 1 {
t.Errorf("expected error counter=1, got %v", got)
}
}
// --- CreateDomain ------------------------------------------------------------
func TestObserveProviderCall_CreateDomain_NotSupported(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
// mockDNSProvider does not implement ZoneCreator, so CreateDomain must fail.
a := newTestAdapter(&mockDNSProvider{})
err := a.CreateDomain("example.com.")
if err == nil {
t.Fatal("expected error when provider does not support domain creation")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "create_domain", "error")); got != 1 {
t.Errorf("expected error counter=1, got %v", got)
}
}
func TestObserveProviderCall_CreateDomain_Success(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockZoneCreator{})
err := a.CreateDomain("example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "create_domain", "success")); got != 1 {
t.Errorf("expected success counter=1, got %v", got)
}
}
func TestObserveProviderCall_CreateDomain_Error(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockZoneCreator{ensureErr: errors.New("zone already exists")})
err := a.CreateDomain("example.com.")
if err == nil {
t.Fatal("expected an error, got nil")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "create_domain", "error")); got != 1 {
t.Errorf("expected error counter=1, got %v", got)
}
}
// --- ListZones ---------------------------------------------------------------
func TestObserveProviderCall_ListZones_NotSupported(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
// mockDNSProvider does not implement ZoneLister, so ListZones must fail.
a := newTestAdapter(&mockDNSProvider{})
_, err := a.ListZones()
if err == nil {
t.Fatal("expected error when provider does not support zone listing")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "list_zones", "error")); got != 1 {
t.Errorf("expected error counter=1, got %v", got)
}
}
func TestObserveProviderCall_ListZones_Success(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockZoneLister{listResult: []string{"example.com", "example.net"}})
zones, err := a.ListZones()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(zones) != 2 {
t.Errorf("expected 2 zones, got %d", len(zones))
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "list_zones", "success")); got != 1 {
t.Errorf("expected success counter=1, got %v", got)
}
}
func TestObserveProviderCall_ListZones_Error(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockZoneLister{listErr: errors.New("API rate limit")})
_, err := a.ListZones()
if err == nil {
t.Fatal("expected an error, got nil")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "list_zones", "error")); got != 1 {
t.Errorf("expected error counter=1, got %v", got)
}
}

View file

@ -0,0 +1,66 @@
// 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 controller
import (
"net/http"
"github.com/gin-gonic/gin"
apicontroller "git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
)
// AdminCheckerController handles admin checker-related API endpoints.
// It embeds CheckerController and overrides GetCheckerOptions to return a flat
// (non-positional) map scoped to nil (global/admin) level.
type AdminCheckerController struct {
*apicontroller.CheckerController
}
// NewAdminCheckerController creates a new AdminCheckerController.
func NewAdminCheckerController(optionsUC *checkerUC.CheckerOptionsUsecase) *AdminCheckerController {
return &AdminCheckerController{
// countManualTriggers=false because admin has no budgetChecker (nil),
// which means the flag is inert on this path — value is for clarity.
CheckerController: apicontroller.NewCheckerController(nil, optionsUC, nil, nil, nil, nil, false),
}
}
// GetCheckerOptions returns admin-level options (nil scope) for a checker as a flat map.
//
// @Summary Get admin-level checker options
// @Tags admin,checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Success 200 {object} checker.CheckerOptions
// @Router /checkers/{checkerId}/options [get]
func (cc *AdminCheckerController) GetCheckerOptions(c *gin.Context) {
checkerID := c.Param("checkerId")
opts, err := cc.OptionsUC.GetCheckerOptions(checkerID, nil, nil, nil)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, opts)
}

View file

@ -74,7 +74,7 @@ func NewDomainController(
func (dc *DomainController) ListDomains(c *gin.Context) {
user := middleware.MyUser(c)
if user != nil {
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter)
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter, nil)
apidc.GetDomains(c)
return
}

View file

@ -0,0 +1,100 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
)
// AdminSchedulerController handles admin scheduler API endpoints.
type AdminSchedulerController struct {
scheduler *checkerUC.Scheduler
}
// NewAdminSchedulerController creates a new AdminSchedulerController.
func NewAdminSchedulerController(scheduler *checkerUC.Scheduler) *AdminSchedulerController {
return &AdminSchedulerController{scheduler: scheduler}
}
// GetSchedulerStatus returns the current scheduler status.
//
// @Summary Get scheduler status
// @Tags admin-scheduler
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} checkerUC.SchedulerStatus
// @Router /scheduler [get]
func (s *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) {
c.JSON(http.StatusOK, s.scheduler.GetStatus())
}
// EnableScheduler starts the scheduler and returns updated status.
//
// @Summary Enable the scheduler
// @Tags admin-scheduler
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} checkerUC.SchedulerStatus
// @Failure 500 {object} object
// @Router /scheduler/enable [post]
func (s *AdminSchedulerController) EnableScheduler(c *gin.Context) {
if err := s.scheduler.SetEnabled(c.Request.Context(), true); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, s.scheduler.GetStatus())
}
// DisableScheduler stops the scheduler and returns updated status.
//
// @Summary Disable the scheduler
// @Tags admin-scheduler
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} checkerUC.SchedulerStatus
// @Failure 500 {object} object
// @Router /scheduler/disable [post]
func (s *AdminSchedulerController) DisableScheduler(c *gin.Context) {
if err := s.scheduler.SetEnabled(c.Request.Context(), false); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, s.scheduler.GetStatus())
}
// RescheduleUpcoming rebuilds the job queue and returns the new count.
//
// @Summary Rebuild the scheduler queue
// @Tags admin-scheduler
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} map[string]int
// @Router /scheduler/reschedule-upcoming [post]
func (s *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) {
n := s.scheduler.RebuildQueue()
c.JSON(http.StatusOK, gin.H{"rescheduled": n})
}

View file

@ -24,6 +24,7 @@ package controller
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
@ -168,7 +169,20 @@ func (uc *UserController) UpdateUser(c *gin.Context) {
}
uu.Id = user.Id
happydns.ApiResponse(c, uu, uc.store.CreateOrUpdateUser(uu))
updated, err := uc.userService.UpdateUser(uu.Id, func(u *happydns.User) {
// Stamp quota update time if quota fields changed.
if uu.Quota != u.Quota {
uu.Quota.UpdatedAt = time.Now()
}
u.Email = uu.Email
u.CreatedAt = uu.CreatedAt
u.LastSeen = uu.LastSeen
u.Settings = uu.Settings
u.Quota = uu.Quota
})
happydns.ApiResponse(c, updated, err)
}
// deleteUser removes a specific user from the database.

View file

@ -128,7 +128,7 @@ func (zc *ZoneController) DeleteZone(c *gin.Context) {
// @Router /users/{uid}/domains/{domain}/zones/{zoneid} [get]
// @Router /users/{uid}/providers/{pid}/domains/{domain}/zones/{zoneid} [get]
func (zc *ZoneController) GetZone(c *gin.Context) {
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService)
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService, nil)
apizc.GetZone(c)
}

View file

@ -0,0 +1,51 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
)
func declareCheckersRoutes(router *gin.RouterGroup, dep Dependencies) {
if dep.CheckerOptionsUC == nil {
return
}
cc := controller.NewAdminCheckerController(dep.CheckerOptionsUC)
apiCheckersRoutes := router.Group("/checkers")
apiCheckersRoutes.GET("", cc.ListCheckers)
apiCheckerRoutes := apiCheckersRoutes.Group("/:checkerId")
apiCheckerRoutes.Use(cc.CheckerHandler)
apiCheckerRoutes.GET("", cc.GetChecker)
apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options")
apiCheckerOptionsRoutes.GET("", cc.GetCheckerOptions)
apiCheckerOptionsRoutes.POST("", cc.AddCheckerOptions)
apiCheckerOptionsRoutes.PUT("", cc.ChangeCheckerOptions)
apiCheckerOptionRoutes := apiCheckerOptionsRoutes.Group("/:optname")
apiCheckerOptionRoutes.GET("", cc.GetCheckerOption)
apiCheckerOptionRoutes.PUT("", cc.SetCheckerOption)
}

View file

@ -26,6 +26,7 @@ import (
api "git.happydns.org/happyDomain/internal/api/route"
"git.happydns.org/happyDomain/internal/storage"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
happydns "git.happydns.org/happyDomain/model"
)
@ -41,14 +42,18 @@ type Dependencies struct {
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
ZoneImporter happydns.ZoneImporterUsecase
ZoneService happydns.ZoneServiceUsecase
CheckerOptionsUC *checkerUC.CheckerOptionsUsecase
CheckScheduler *checkerUC.Scheduler
}
func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage, dep Dependencies) {
apiRoutes := router.Group("/api")
declareBackupRoutes(cfg, apiRoutes, s)
declareCheckersRoutes(apiRoutes, dep)
declareDomainRoutes(apiRoutes, dep, s)
declareProviderRoutes(apiRoutes, dep, s)
declareSchedulerRoutes(apiRoutes, dep)
declareSessionsRoutes(cfg, apiRoutes, s)
declareUserAuthsRoutes(apiRoutes, dep, s)
declareUsersRoutes(apiRoutes, dep, s)

View file

@ -0,0 +1,41 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
)
func declareSchedulerRoutes(router *gin.RouterGroup, dep Dependencies) {
if dep.CheckScheduler == nil {
return
}
ctrl := controller.NewAdminSchedulerController(dep.CheckScheduler)
schedulerRoute := router.Group("/scheduler")
schedulerRoute.GET("", ctrl.GetSchedulerStatus)
schedulerRoute.POST("/enable", ctrl.EnableScheduler)
schedulerRoute.POST("/disable", ctrl.DisableScheduler)
schedulerRoute.POST("/reschedule-upcoming", ctrl.RescheduleUpcoming)
}

View file

@ -0,0 +1,272 @@
// 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 controller
import (
"context"
"io"
"log"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
// CheckerController handles checker-related API endpoints.
type CheckerController struct {
engine happydns.CheckerEngine
OptionsUC *checkerUC.CheckerOptionsUsecase
planUC *checkerUC.CheckPlanUsecase
statusUC *checkerUC.CheckStatusUsecase
plannedProvider checkerUC.PlannedJobProvider
budgetChecker checkerUC.BudgetChecker
countManualTriggers bool
}
// NewCheckerController creates a new CheckerController.
//
// countManualTriggers controls whether manual (API-triggered) checker runs
// count against the user's MaxChecksPerDay budget. When true and
// budgetChecker is non-nil, TriggerCheck refuses the request with HTTP 429
// once the user is over budget and increments the counter on success.
// When false, manual triggers bypass the quota entirely (legacy behavior).
// The value is ignored when budgetChecker is nil.
func NewCheckerController(
engine happydns.CheckerEngine,
optionsUC *checkerUC.CheckerOptionsUsecase,
planUC *checkerUC.CheckPlanUsecase,
statusUC *checkerUC.CheckStatusUsecase,
plannedProvider checkerUC.PlannedJobProvider,
budgetChecker checkerUC.BudgetChecker,
countManualTriggers bool,
) *CheckerController {
return &CheckerController{
engine: engine,
OptionsUC: optionsUC,
planUC: planUC,
statusUC: statusUC,
plannedProvider: plannedProvider,
budgetChecker: budgetChecker,
countManualTriggers: countManualTriggers,
}
}
// StatusUC returns the CheckStatusUsecase for use by other controllers.
func (cc *CheckerController) StatusUC() *checkerUC.CheckStatusUsecase {
return cc.statusUC
}
// targetFromContext builds a CheckTarget from middleware context values.
func targetFromContext(c *gin.Context) happydns.CheckTarget {
user := middleware.MyUser(c)
target := happydns.CheckTarget{}
if user != nil {
target.UserId = user.Id.String()
}
if domain, exists := c.Get("domain"); exists {
d := domain.(*happydns.Domain)
target.DomainId = d.Id.String()
}
if sid, exists := c.Get("serviceid"); exists {
id := sid.(happydns.Identifier)
target.ServiceId = id.String()
if z, zExists := c.Get("zone"); zExists {
zone := z.(*happydns.Zone)
if _, svc := zone.FindService(id); svc != nil {
target.ServiceType = svc.Type
}
}
}
return target
}
// --- Global checker routes ---
// ListCheckers returns all registered checker definitions.
//
// @Summary List available checkers
// @Tags checkers
// @Produce json
// @Success 200 {object} map[string]checker.CheckerDefinition
// @Router /checkers [get]
func (cc *CheckerController) ListCheckers(c *gin.Context) {
c.JSON(http.StatusOK, checkerPkg.GetCheckers())
}
// GetChecker returns a specific checker definition.
//
// @Summary Get a checker definition
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Success 200 {object} checker.CheckerDefinition
// @Failure 404 {object} happydns.ErrorResponse
// @Router /checkers/{checkerId} [get]
func (cc *CheckerController) GetChecker(c *gin.Context) {
def, _ := c.Get("checker")
c.JSON(http.StatusOK, def)
}
// CheckerHandler is a middleware that validates the checkerId path parameter and sets "checker" in context.
func (cc *CheckerController) CheckerHandler(c *gin.Context) {
checkerID := c.Param("checkerId")
def := checkerPkg.FindChecker(checkerID)
if def == nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Checker not found"})
return
}
c.Set("checker", def)
c.Next()
}
// --- Scoped routes (domain/service) ---
// ListAvailableChecks lists all checkers with their latest status for a target.
//
// @Summary List available checks with status
// @Tags checkers
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {array} happydns.CheckerStatus
// @Router /domains/{domain}/checkers [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers [get]
func (cc *CheckerController) ListAvailableChecks(c *gin.Context) {
target := targetFromContext(c)
result, err := cc.statusUC.ListCheckerStatuses(target)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, result)
}
// TriggerCheck manually triggers a checker execution.
// By default the check runs asynchronously and returns an Execution (HTTP 202).
// Pass ?sync=true to block until the check completes and return a CheckEvaluation (HTTP 200).
//
// @Summary Trigger a manual check
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param sync query bool false "Run synchronously"
// @Param body body happydns.CheckerRunRequest false "Run request with options and enabled rules"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.CheckEvaluation
// @Success 202 {object} happydns.Execution
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 429 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [post]
func (cc *CheckerController) TriggerCheck(c *gin.Context) {
cname := c.Param("checkerId")
var req happydns.CheckerRunRequest
// Body is optional; io.EOF means no body was sent, which is valid (no custom options or rules).
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
target := targetFromContext(c)
if err := cc.OptionsUC.ValidateOptions(cname, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), req.Options, true); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
// Enforce the daily check quota on manual triggers when configured.
// Interval=0 means "long-interval" in UserGater terms, so manual triggers
// are only denied at the hard limit — never by interval-aware throttling.
if cc.countManualTriggers && cc.budgetChecker != nil && !cc.budgetChecker.AllowWithInterval(target, 0) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"errmsg": "daily check quota exhausted; try again after 00:00 UTC"})
return
}
// Build a temporary plan from enabled rules if provided.
var plan *happydns.CheckPlan
if len(req.EnabledRules) > 0 {
plan = &happydns.CheckPlan{
CheckerID: cname,
Target: target,
Enabled: req.EnabledRules,
}
}
exec, err := cc.engine.CreateExecution(cname, target, plan)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Count the manual execution against the user's daily budget. Mirrors
// the scheduler's onExecute semantics: increment after CreateExecution
// succeeds, regardless of whether RunExecution later fails.
if cc.countManualTriggers && cc.budgetChecker != nil {
cc.budgetChecker.IncrementUsage(target)
}
if c.Query("sync") == "true" {
eval, err := cc.engine.RunExecution(c.Request.Context(), exec, plan, req.Options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, eval)
} else {
go func() {
if _, err := cc.engine.RunExecution(context.WithoutCancel(c.Request.Context()), exec, plan, req.Options); err != nil {
log.Printf("async RunExecution error for checker %q execution %v: %v", cname, exec.Id, err)
}
}()
c.JSON(http.StatusAccepted, exec)
}
}
// GetExecutionStatus returns the status of an execution.
//
// @Summary Get execution status
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.Execution
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [get]
func (cc *CheckerController) GetExecutionStatus(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
c.JSON(http.StatusOK, exec)
}

View file

@ -0,0 +1,303 @@
// 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 controller
import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// respondWithMetrics writes metrics in the format requested by the Accept header.
// JSON is the default (preserving the previous API contract for clients that
// send Accept: */* or omit the header). Prometheus text exposition is only
// returned when explicitly requested via Accept: text/plain.
func respondWithMetrics(c *gin.Context, metrics []happydns.CheckMetric) {
if metrics == nil {
metrics = []happydns.CheckMetric{}
}
if wantsPrometheusText(c.GetHeader("Accept")) {
c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(renderPrometheus(metrics)))
return
}
c.JSON(http.StatusOK, metrics)
}
const maxLimit = 1000
// wantsPrometheusText returns true when the Accept header explicitly asks for
// text/plain (or the Prometheus exposition media type) without also accepting
// JSON. This keeps the JSON API the default for browsers and generic clients
// while letting `curl -H 'Accept: text/plain'` opt into the Prometheus format.
func wantsPrometheusText(accept string) bool {
if accept == "" {
return false
}
if strings.Contains(accept, "application/json") {
return false
}
return strings.Contains(accept, "text/plain") ||
strings.Contains(accept, "application/openmetrics-text")
}
// escapePromLabelValue escapes a label value for the Prometheus text exposition
// format. The spec only allows three escape sequences inside label values:
// `\\`, `\"` and `\n`. Using fmt's %q is unsafe because it can emit \xNN or
// \uNNNN sequences that Prometheus rejects.
func escapePromLabelValue(s string) string {
var b strings.Builder
b.Grow(len(s) + 2)
for i := 0; i < len(s); i++ {
switch c := s[i]; c {
case '\\':
b.WriteString(`\\`)
case '"':
b.WriteString(`\"`)
case '\n':
b.WriteString(`\n`)
default:
b.WriteByte(c)
}
}
return b.String()
}
// renderPrometheus formats metrics as Prometheus text exposition format
// (version 0.0.4). It only emits constructs allowed by that format: HELP/TYPE
// metadata and untyped samples — no OpenMetrics-only directives such as # UNIT.
func renderPrometheus(metrics []happydns.CheckMetric) string {
type metricMeta struct {
unit string
}
seen := map[string]metricMeta{}
var names []string
for _, m := range metrics {
if _, ok := seen[m.Name]; !ok {
seen[m.Name] = metricMeta{unit: m.Unit}
names = append(names, m.Name)
}
}
sort.Strings(names)
var b strings.Builder
nameIdx := map[string]int{}
for i, name := range names {
nameIdx[name] = i
}
// Sort metrics by name order, then by timestamp.
sorted := make([]happydns.CheckMetric, len(metrics))
copy(sorted, metrics)
sort.Slice(sorted, func(i, j int) bool {
ni, nj := nameIdx[sorted[i].Name], nameIdx[sorted[j].Name]
if ni != nj {
return ni < nj
}
return sorted[i].Timestamp.Before(sorted[j].Timestamp)
})
currentName := ""
for _, m := range sorted {
if m.Name != currentName {
currentName = m.Name
meta := seen[m.Name]
if meta.unit != "" {
// Surface the unit as a HELP comment so it stays parseable
// under Prometheus text 0.0.4 (which has no # UNIT directive).
fmt.Fprintf(&b, "# HELP %s unit: %s\n", m.Name, meta.unit)
}
fmt.Fprintf(&b, "# TYPE %s untyped\n", m.Name)
}
b.WriteString(m.Name)
if len(m.Labels) > 0 {
b.WriteByte('{')
first := true
labelKeys := make([]string, 0, len(m.Labels))
for k := range m.Labels {
labelKeys = append(labelKeys, k)
}
sort.Strings(labelKeys)
for _, k := range labelKeys {
if !first {
b.WriteByte(',')
}
fmt.Fprintf(&b, "%s=\"%s\"", k, escapePromLabelValue(m.Labels[k]))
first = false
}
b.WriteByte('}')
}
fmt.Fprintf(&b, " %g", m.Value)
if !m.Timestamp.IsZero() {
fmt.Fprintf(&b, " %d", m.Timestamp.UnixMilli())
}
b.WriteByte('\n')
}
return b.String()
}
func getLimitParam(c *gin.Context, defaultLimit int) int {
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
if parsed > maxLimit {
return maxLimit
}
return parsed
}
}
return defaultLimit
}
// GetUserMetrics returns metrics across all checkers for the authenticated user.
//
// @Summary Get all user metrics
// @Description Returns metrics from all recent executions for the authenticated user. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
// @Tags checkers
// @Produce json,plain
// @Param limit query int false "Maximum number of executions to extract metrics from (default: 100)"
// @Success 200 {array} checker.CheckMetric
// @Router /checkers/metrics [get]
func (cc *CheckerController) GetUserMetrics(c *gin.Context) {
target := targetFromContext(c)
userID := happydns.TargetIdentifier(target.UserId)
if userID == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not authenticated"})
return
}
limit := getLimitParam(c, 100)
metrics, err := cc.statusUC.GetMetricsByUser(*userID, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
respondWithMetrics(c, metrics)
}
// GetDomainMetrics returns metrics for a domain and its service children.
//
// @Summary Get domain metrics
// @Description Returns metrics from recent executions for a domain and all its services. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
// @Tags checkers
// @Produce json,plain
// @Param domain path string true "Domain identifier"
// @Param limit query int false "Maximum number of executions (default: 100)"
// @Success 200 {array} checker.CheckMetric
// @Router /domains/{domain}/checkers/metrics [get]
func (cc *CheckerController) GetDomainMetrics(c *gin.Context) {
target := targetFromContext(c)
domainID := happydns.TargetIdentifier(target.DomainId)
if domainID == nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Domain context required"})
return
}
limit := getLimitParam(c, 100)
metrics, err := cc.statusUC.GetMetricsByDomain(*domainID, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
respondWithMetrics(c, metrics)
}
// GetCheckerMetrics returns metrics for a specific checker on a target.
//
// @Summary Get checker metrics
// @Description Returns metrics from recent executions of a specific checker on a target. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
// @Tags checkers
// @Produce json,plain
// @Param checkerId path string true "Checker ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param limit query int false "Maximum number of executions (default: 100)"
// @Success 200 {array} checker.CheckMetric
// @Router /domains/{domain}/checkers/{checkerId}/metrics [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/metrics [get]
func (cc *CheckerController) GetCheckerMetrics(c *gin.Context) {
checkerID := c.Param("checkerId")
target := targetFromContext(c)
limit := getLimitParam(c, 100)
metrics, err := cc.statusUC.GetMetricsByChecker(checkerID, target, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
respondWithMetrics(c, metrics)
}
// GetExecutionMetrics returns metrics for a single execution.
//
// @Summary Get execution metrics
// @Description Returns metrics extracted from a single execution's observation snapshot. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
// @Tags checkers
// @Produce json,plain
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Success 200 {array} checker.CheckMetric
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/metrics [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/metrics [get]
func (cc *CheckerController) GetExecutionMetrics(c *gin.Context) {
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
return
}
target := targetFromContext(c)
exec, err := cc.statusUC.GetExecution(target, execID)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
return
}
metrics, err := cc.statusUC.GetMetricsByExecution(target, exec.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
respondWithMetrics(c, metrics)
}

View file

@ -0,0 +1,117 @@
// 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 controller
import (
"strings"
"testing"
"time"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
"git.happydns.org/happyDomain/model"
)
func TestRenderPrometheus_ParsesAsValidExposition(t *testing.T) {
// Include a label value with characters that fmt's %q would have escaped
// as \xNN / \uNNNN — sequences which are NOT valid in Prometheus text
// format. The output must still parse cleanly via the upstream parser.
out := renderPrometheus([]happydns.CheckMetric{
{
Name: "happydomain_check_latency_seconds",
Unit: "seconds",
Value: 0.123,
Timestamp: time.Unix(1700000000, 0),
Labels: map[string]string{
"target": "exämple.com", // non-ASCII
"note": "line1\nline2", // newline (must become \n)
"quoted": `he said "hi"`, // quotes
"slash": `a\b`, // backslash
},
},
{
Name: "happydomain_check_latency_seconds",
Value: 0.456,
Labels: map[string]string{
"target": "second.example",
},
},
})
p := expfmt.NewTextParser(model.LegacyValidation)
if _, err := p.TextToMetricFamilies(strings.NewReader(out)); err != nil {
t.Fatalf("renderPrometheus output is not valid Prometheus text format: %v\noutput:\n%s", err, out)
}
}
func TestRenderPrometheus_EscapesLabelValues(t *testing.T) {
out := renderPrometheus([]happydns.CheckMetric{{
Name: "x",
Value: 1,
Labels: map[string]string{
"a": `\`,
"b": `"`,
"c": "\n",
},
}})
if !strings.Contains(out, `a="\\"`) {
t.Errorf("backslash not escaped: %q", out)
}
if !strings.Contains(out, `b="\""`) {
t.Errorf("quote not escaped: %q", out)
}
if !strings.Contains(out, `c="\n"`) {
t.Errorf("newline not escaped: %q", out)
}
}
func TestRenderPrometheus_NoOpenMetricsDirectives(t *testing.T) {
out := renderPrometheus([]happydns.CheckMetric{{
Name: "x",
Unit: "seconds",
Value: 1,
}})
if strings.Contains(out, "# UNIT") {
t.Errorf("output contains OpenMetrics-only # UNIT directive incompatible with text/plain;version=0.0.4: %q", out)
}
}
func TestWantsPrometheusText(t *testing.T) {
cases := []struct {
accept string
want bool
}{
{"", false},
{"*/*", false},
{"application/json", false},
{"application/json, text/plain", false}, // explicit JSON wins
{"text/plain", true},
{"text/plain; version=0.0.4", true},
{"application/openmetrics-text; version=1.0.0", true},
}
for _, tc := range cases {
if got := wantsPrometheusText(tc.accept); got != tc.want {
t.Errorf("wantsPrometheusText(%q) = %v, want %v", tc.accept, got, tc.want)
}
}
}

View file

@ -0,0 +1,223 @@
// 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 controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// GetCheckerOptions returns layered options for a checker, from least to most specific scope.
// The scope is determined by the route context (user-only at /api/checkers, domain/service at scoped routes).
//
// @Summary Get checker options by scope
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {array} happydns.CheckerOptionsPositional
// @Router /checkers/{checkerId}/options [get]
// @Router /domains/{domain}/checkers/{checkerId}/options [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [get]
func (cc *CheckerController) GetCheckerOptions(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
positionals, err := cc.OptionsUC.GetCheckerOptionsPositional(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId))
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if positionals == nil {
positionals = []*happydns.CheckerOptionsPositional{}
}
// Append auto-fill resolved values so the frontend can display them.
autoFillOpts, err := cc.OptionsUC.GetAutoFillOptions(checkerID, target)
if err == nil && autoFillOpts != nil {
positionals = append(positionals, &happydns.CheckerOptionsPositional{
CheckName: checkerID,
UserId: happydns.TargetIdentifier(target.UserId),
DomainId: happydns.TargetIdentifier(target.DomainId),
ServiceId: happydns.TargetIdentifier(target.ServiceId),
Options: autoFillOpts,
})
}
c.JSON(http.StatusOK, positionals)
}
// AddCheckerOptions partially merges options at the current scope.
//
// @Summary Merge checker options
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param options body checker.CheckerOptions true "Options to merge"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} checker.CheckerOptions
// @Router /checkers/{checkerId}/options [post]
// @Router /domains/{domain}/checkers/{checkerId}/options [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [post]
func (cc *CheckerController) AddCheckerOptions(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
var opts happydns.CheckerOptions
if err := c.ShouldBindJSON(&opts); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
merged, err := cc.OptionsUC.MergeCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), merged, false); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if _, err := cc.OptionsUC.AddCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, merged)
}
// ChangeCheckerOptions fully replaces options at the current scope.
//
// @Summary Replace checker options
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param options body checker.CheckerOptions true "Options to set"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} checker.CheckerOptions
// @Router /checkers/{checkerId}/options [put]
// @Router /domains/{domain}/checkers/{checkerId}/options [put]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [put]
func (cc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
var opts happydns.CheckerOptions
if err := c.ShouldBindJSON(&opts); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts, false); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if err := cc.OptionsUC.SetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, opts)
}
// GetCheckerOption returns a single option value at the current scope.
//
// @Summary Get a single checker option
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param optname path string true "Option name"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} any
// @Router /checkers/{checkerId}/options/{optname} [get]
// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [get]
func (cc *CheckerController) GetCheckerOption(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
optname := c.Param("optname")
val, err := cc.OptionsUC.GetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if val == nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Option not set"})
return
}
c.JSON(http.StatusOK, val)
}
// SetCheckerOption sets a single option value at the current scope.
//
// @Summary Set a single checker option
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param optname path string true "Option name"
// @Param value body any true "Option value"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} any
// @Router /checkers/{checkerId}/options/{optname} [put]
// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [put]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [put]
func (cc *CheckerController) SetCheckerOption(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
optname := c.Param("optname")
var value any
if err := c.ShouldBindJSON(&value); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
// Validate the full merged options after inserting the key.
existing, err := cc.OptionsUC.GetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId))
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
existing[optname] = value
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), existing, false); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if err := cc.OptionsUC.SetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname, value); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, value)
}

View file

@ -0,0 +1,196 @@
// 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 controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// PlanHandler is a middleware that validates the planId path parameter,
// checks target scope, and sets "plan" in context.
func (cc *CheckerController) PlanHandler(c *gin.Context) {
planID, err := happydns.NewIdentifierFromString(c.Param("planId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid plan ID"})
return
}
plan, err := cc.planUC.GetCheckPlan(targetFromContext(c), planID)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"})
return
}
c.Set("plan", plan)
c.Next()
}
// ListCheckPlans returns all check plans for a domain.
//
// @Summary List check plans for a domain
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {array} happydns.CheckPlan
// @Router /domains/{domain}/checkers/{checkerId}/plans [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [get]
func (cc *CheckerController) ListCheckPlans(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
plans, err := cc.planUC.ListCheckPlansByTargetAndChecker(target, checkerID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, plans)
}
// CreateCheckPlan creates a new check plan.
//
// @Summary Create a check plan
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param plan body happydns.CheckPlan true "Check plan to create"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 201 {object} happydns.CheckPlan
// @Failure 400 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/plans [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [post]
func (cc *CheckerController) CreateCheckPlan(c *gin.Context) {
target := targetFromContext(c)
var plan happydns.CheckPlan
if err := c.ShouldBindJSON(&plan); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
plan.Target = target
plan.CheckerID = c.Param("checkerId")
if err := cc.planUC.CreateCheckPlan(&plan); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("cannot create check plan: %w", err))
return
}
c.JSON(http.StatusCreated, plan)
}
// GetCheckPlan returns a specific check plan.
//
// @Summary Get a check plan
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param planId path string true "Plan ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.CheckPlan
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [get]
func (cc *CheckerController) GetCheckPlan(c *gin.Context) {
plan := c.MustGet("plan").(*happydns.CheckPlan)
c.JSON(http.StatusOK, plan)
}
// UpdateCheckPlan updates an existing check plan.
//
// @Summary Update a check plan
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param planId path string true "Plan ID"
// @Param plan body happydns.CheckPlan true "Updated check plan"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.CheckPlan
// @Failure 400 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [put]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [put]
func (cc *CheckerController) UpdateCheckPlan(c *gin.Context) {
existing := c.MustGet("plan").(*happydns.CheckPlan)
var plan happydns.CheckPlan
if err := c.ShouldBindJSON(&plan); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
plan.Target = targetFromContext(c)
plan.CheckerID = c.Param("checkerId")
updated, err := cc.planUC.UpdateCheckPlan(plan.Target, existing.Id, &plan)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("cannot update check plan: %w", err))
return
}
c.JSON(http.StatusOK, updated)
}
// DeleteCheckPlan deletes a check plan.
//
// @Summary Delete a check plan
// @Tags checkers
// @Param checkerId path string true "Checker ID"
// @Param planId path string true "Plan ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 204
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [delete]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [delete]
func (cc *CheckerController) DeleteCheckPlan(c *gin.Context) {
plan := c.MustGet("plan").(*happydns.CheckPlan)
if err := cc.planUC.DeleteCheckPlan(targetFromContext(c), plan.Id); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"})
return
}
c.Status(http.StatusNoContent)
}

View file

@ -0,0 +1,307 @@
// 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 controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
// ExecutionHandler is a middleware that validates the executionId path parameter,
// checks target scope, and sets "execution" in context.
func (cc *CheckerController) ExecutionHandler(c *gin.Context) {
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
return
}
exec, err := cc.statusUC.GetExecution(targetFromContext(c), execID)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
return
}
c.Set("execution", exec)
c.Next()
}
// ListExecutions returns executions for a checker on a target.
//
// @Summary List executions for a checker
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param limit query int false "Maximum number of results"
// @Param include_planned query bool false "Include upcoming planned executions from the scheduler"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {array} happydns.Execution
// @Router /domains/{domain}/checkers/{checkerId}/executions [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [get]
func (cc *CheckerController) ListExecutions(c *gin.Context) {
cname := c.Param("checkerId")
target := targetFromContext(c)
limit := getLimitParam(c, 0)
execs, err := cc.statusUC.ListExecutionsByChecker(cname, target, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if execs == nil {
execs = []*happydns.Execution{}
}
if c.Query("include_planned") == "true" || c.Query("include_planned") == "1" {
planned := checkerUC.ListPlannedExecutions(cc.plannedProvider, cc.budgetChecker, cname, target)
execs = append(planned, execs...)
}
c.JSON(http.StatusOK, execs)
}
// DeleteExecution deletes an execution record.
//
// @Summary Delete an execution
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 204
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [delete]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [delete]
func (cc *CheckerController) DeleteExecution(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
if err := cc.statusUC.DeleteExecution(targetFromContext(c), exec.Id); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}
// DeleteCheckerExecutions deletes all executions for a checker on a target.
//
// @Summary Delete all executions for a checker
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 204
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions [delete]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [delete]
func (cc *CheckerController) DeleteCheckerExecutions(c *gin.Context) {
cname := c.Param("checkerId")
target := targetFromContext(c)
if err := cc.statusUC.DeleteExecutionsByChecker(cname, target); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}
// GetExecutionObservations returns the observation snapshot for an execution.
//
// @Summary Get observations for an execution
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.ObservationSnapshot
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations [get]
func (cc *CheckerController) GetExecutionObservations(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
snap, err := cc.statusUC.GetObservationsByExecution(targetFromContext(c), exec.Id)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"})
return
}
c.JSON(http.StatusOK, snap)
}
// GetExecutionObservation returns a specific observation key from an execution's snapshot.
//
// @Summary Get a specific observation for an execution
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param obsKey path string true "Observation key"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} any
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get]
func (cc *CheckerController) GetExecutionObservation(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
obsKey := c.Param("obsKey")
val, err := cc.statusUC.GetSnapshotByExecution(targetFromContext(c), exec.Id, obsKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation not available"})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", val)
}
// GetExecutionResults returns the evaluation (per-rule states) for an execution.
//
// @Summary Get results for an execution
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.CheckEvaluation
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results [get]
func (cc *CheckerController) GetExecutionResults(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
eval, err := cc.statusUC.GetResultsByExecution(targetFromContext(c), exec.Id)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"})
return
}
c.JSON(http.StatusOK, eval)
}
// GetExecutionResult returns a specific rule's result from an execution.
//
// @Summary Get a specific rule result for an execution
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param ruleName path string true "Rule name"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} checker.CheckState
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get]
func (cc *CheckerController) GetExecutionResult(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
eval, err := cc.statusUC.GetResultsByExecution(targetFromContext(c), exec.Id)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"})
return
}
ruleName := c.Param("ruleName")
for _, state := range eval.States {
if state.Code == ruleName {
c.JSON(http.StatusOK, state)
return
}
}
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Rule result not found"})
}
// GetExecutionHTMLReport returns the HTML report for a specific observation of an execution.
//
// @Summary Get execution observation HTML report
// @Description Returns the full HTML document generated from an observation's data. Only available for observation providers that implement HTML reporting.
// @Tags checkers
// @Produce html
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param obsKey path string true "Observation key"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {string} string "HTML document"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
func (cc *CheckerController) GetExecutionHTMLReport(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
obsKey := c.Param("obsKey")
val, err := cc.statusUC.GetSnapshotByExecution(targetFromContext(c), exec.Id, obsKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation not available"})
return
}
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, val)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if !supported {
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("observation %q does not support HTML reports", obsKey))
return
}
c.Header("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'")
c.Header("X-Content-Type-Options", "nosniff")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
}

View file

@ -0,0 +1,904 @@
// 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 controller
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/storage/inmemory"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
func init() {
gin.SetMode(gin.TestMode)
}
// --- Stub types ---
// stubCheckerEngine implements happydns.CheckerEngine for testing.
type stubCheckerEngine struct {
exec *happydns.Execution
eval *happydns.CheckEvaluation
err error
}
func (s *stubCheckerEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) {
if s.err != nil {
return nil, s.err
}
if s.exec != nil {
return s.exec, nil
}
id, _ := happydns.NewRandomIdentifier()
return &happydns.Execution{
Id: id,
CheckerID: checkerID,
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionPending,
}, nil
}
func (s *stubCheckerEngine) RunExecution(ctx context.Context, exec *happydns.Execution, plan *happydns.CheckPlan, runOpts happydns.CheckerOptions) (*happydns.CheckEvaluation, error) {
if s.err != nil {
return nil, s.err
}
if s.eval != nil {
return s.eval, nil
}
return &happydns.CheckEvaluation{
CheckerID: exec.CheckerID,
Target: exec.Target,
States: []happydns.CheckState{{Status: happydns.StatusOK, Code: "ok"}},
}, nil
}
// testObservationProvider is a no-op provider for tests.
type testObservationProvider struct{}
func (p *testObservationProvider) Key() happydns.ObservationKey { return "test_ctrl_obs" }
func (p *testObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
return map[string]any{"v": 1}, nil
}
// testHTMLObservationProvider implements CheckerHTMLReporter for HTML report tests.
type testHTMLObservationProvider struct{}
func (p *testHTMLObservationProvider) Key() happydns.ObservationKey { return "test_html_obs" }
func (p *testHTMLObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
return map[string]any{"html": true}, nil
}
func (p *testHTMLObservationProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
return "<html><body>test report</body></html>", nil
}
// testCheckRule produces a fixed status.
type testCheckRule struct {
name string
status happydns.Status
}
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 {
return happydns.CheckState{Status: r.status, Code: r.name}
}
// registerTestChecker registers a checker for controller tests and returns its ID.
// Uses a unique name to avoid collisions with other tests.
var testCheckerSeq int
func registerTestChecker() string {
testCheckerSeq++
id := fmt.Sprintf("ctrl_test_checker_%d", testCheckerSeq)
checkerPkg.RegisterObservationProvider(&testObservationProvider{})
checkerPkg.RegisterChecker(&happydns.CheckerDefinition{
ID: id,
Name: "Controller Test Checker",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_a", status: happydns.StatusOK},
},
})
return id
}
// newTestController creates a CheckerController with in-memory storage.
func newTestController(engine happydns.CheckerEngine) *CheckerController {
cc, _ := newTestControllerWithStorage(engine)
return cc
}
// newTestControllerWithStorage creates a CheckerController and returns the underlying storage.
func newTestControllerWithStorage(engine happydns.CheckerEngine) (*CheckerController, storage.Storage) {
store, err := inmemory.Instantiate()
if err != nil {
panic(err)
}
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
planUC := checkerUC.NewCheckPlanUsecase(store)
statusUC := checkerUC.NewCheckStatusUsecase(store, store, store, store)
return NewCheckerController(engine, optionsUC, planUC, statusUC, nil, nil, false), store
}
// newTestControllerWithBudget creates a CheckerController with a custom
// BudgetChecker and an explicit countManualTriggers flag, for the
// manual-trigger quota tests.
func newTestControllerWithBudget(engine happydns.CheckerEngine, budget checkerUC.BudgetChecker, countManualTriggers bool) *CheckerController {
store, err := inmemory.Instantiate()
if err != nil {
panic(err)
}
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
planUC := checkerUC.NewCheckPlanUsecase(store)
statusUC := checkerUC.NewCheckStatusUsecase(store, store, store, store)
return NewCheckerController(engine, optionsUC, planUC, statusUC, nil, budget, countManualTriggers)
}
// stubBudgetChecker is a minimal BudgetChecker for controller tests.
// allow controls AllowWithInterval; increments counts IncrementUsage calls.
type stubBudgetChecker struct {
allow bool
increments int
}
func (s *stubBudgetChecker) RateLimiterFor(_ string) func(time.Duration) bool {
return func(time.Duration) bool { return !s.allow }
}
func (s *stubBudgetChecker) AllowWithInterval(_ happydns.CheckTarget, _ time.Duration) bool {
return s.allow
}
func (s *stubBudgetChecker) IncrementUsage(_ happydns.CheckTarget) { s.increments++ }
// countingCheckerEngine wraps stubCheckerEngine and counts CreateExecution calls.
type countingCheckerEngine struct {
stubCheckerEngine
created int
}
func (c *countingCheckerEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) {
c.created++
return c.stubCheckerEngine.CreateExecution(checkerID, target, plan)
}
// --- targetFromContext tests ---
func TestTargetFromContext_Empty(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
target := targetFromContext(c)
if target.UserId != "" {
t.Errorf("expected empty UserId, got %q", target.UserId)
}
if target.DomainId != "" {
t.Errorf("expected empty DomainId, got %q", target.DomainId)
}
if target.ServiceId != "" {
t.Errorf("expected empty ServiceId, got %q", target.ServiceId)
}
}
func TestTargetFromContext_WithUser(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
c.Set("LoggedUser", user)
target := targetFromContext(c)
if target.UserId != uid.String() {
t.Errorf("expected UserId %q, got %q", uid.String(), target.UserId)
}
}
func TestTargetFromContext_WithDomain(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
did, _ := happydns.NewRandomIdentifier()
domain := &happydns.Domain{Id: did}
c.Set("domain", domain)
target := targetFromContext(c)
if target.DomainId != did.String() {
t.Errorf("expected DomainId %q, got %q", did.String(), target.DomainId)
}
}
func TestTargetFromContext_WithService(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
sid, _ := happydns.NewRandomIdentifier()
c.Set("serviceid", happydns.Identifier(sid))
target := targetFromContext(c)
if target.ServiceId != sid.String() {
t.Errorf("expected ServiceId %q, got %q", sid.String(), target.ServiceId)
}
}
func TestTargetFromContext_WithServiceAndZone(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
sid, _ := happydns.NewRandomIdentifier()
svc := &happydns.Service{
ServiceMeta: happydns.ServiceMeta{
Id: sid,
Type: "svcs.TestType",
},
}
zone := &happydns.Zone{
Services: map[happydns.Subdomain][]*happydns.Service{
"": {svc},
},
}
c.Set("serviceid", happydns.Identifier(sid))
c.Set("zone", zone)
target := targetFromContext(c)
if target.ServiceType != "svcs.TestType" {
t.Errorf("expected ServiceType %q, got %q", "svcs.TestType", target.ServiceType)
}
}
// --- ListCheckers tests ---
func TestListCheckers_ReturnsRegistered(t *testing.T) {
checkerID := registerTestChecker()
cc := newTestController(&stubCheckerEngine{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/checkers", nil)
cc.ListCheckers(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if _, ok := result[checkerID]; !ok {
t.Errorf("expected checker %q in response, got keys: %v", checkerID, keysOf(result))
}
}
func keysOf(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// --- CheckerHandler tests ---
func TestCheckerHandler_NotFound(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/checkers/nonexistent", nil)
c.Params = gin.Params{{Key: "checkerId", Value: "nonexistent_checker_xyz"}}
cc.CheckerHandler(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if _, ok := resp["errmsg"]; !ok {
t.Error("expected errmsg in response")
}
}
func TestCheckerHandler_Found(t *testing.T) {
checkerID := registerTestChecker()
cc := newTestController(&stubCheckerEngine{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/checkers/"+checkerID, nil)
c.Params = gin.Params{{Key: "checkerId", Value: checkerID}}
// CheckerHandler calls c.Next(), so we need to verify context is set.
// Use a gin engine to test the middleware chain.
router := gin.New()
router.GET("/checkers/:checkerId", cc.CheckerHandler, cc.GetChecker)
req := httptest.NewRequest("GET", "/checkers/"+checkerID, nil)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var def map[string]any
if err := json.Unmarshal(w2.Body.Bytes(), &def); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if def["id"] != checkerID {
t.Errorf("expected checker id %q, got %v", checkerID, def["id"])
}
}
// --- TriggerCheck tests ---
func TestTriggerCheck_Sync_Returns200(t *testing.T) {
checkerID := registerTestChecker()
eval := &happydns.CheckEvaluation{
CheckerID: checkerID,
States: []happydns.CheckState{{Status: happydns.StatusOK, Code: "ok"}},
}
engine := &stubCheckerEngine{eval: eval}
cc := newTestController(engine)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
body, _ := json.Marshal(happydns.CheckerRunRequest{})
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions?sync=true", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router := gin.New()
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
c.Set("LoggedUser", user)
c.Next()
}, cc.TriggerCheck)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if result["checkerId"] != checkerID {
t.Errorf("expected checkerId %q, got %v", checkerID, result["checkerId"])
}
}
func TestTriggerCheck_Async_Returns202(t *testing.T) {
checkerID := registerTestChecker()
engine := &stubCheckerEngine{}
cc := newTestController(engine)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
body, _ := json.Marshal(happydns.CheckerRunRequest{})
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router := gin.New()
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
c.Set("LoggedUser", user)
c.Next()
}, cc.TriggerCheck)
router.ServeHTTP(w, req)
if w.Code != http.StatusAccepted {
t.Fatalf("expected 202, got %d: %s", w.Code, w.Body.String())
}
}
func TestTriggerCheck_EngineError_Returns500(t *testing.T) {
checkerID := registerTestChecker()
engine := &stubCheckerEngine{err: fmt.Errorf("engine failure")}
cc := newTestController(engine)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
body, _ := json.Marshal(happydns.CheckerRunRequest{})
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router := gin.New()
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
c.Set("LoggedUser", user)
c.Next()
}, cc.TriggerCheck)
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
// postTrigger is a helper that wires up LoggedUser via middleware and fires
// an async POST to TriggerCheck. Used by the budget-enforcement tests below.
func postTrigger(cc *CheckerController, user *happydns.User, checkerID string) *httptest.ResponseRecorder {
body, _ := json.Marshal(happydns.CheckerRunRequest{})
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router := gin.New()
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
c.Set("LoggedUser", user)
c.Next()
}, cc.TriggerCheck)
router.ServeHTTP(w, req)
return w
}
// When countManualTriggers=false, the quota is never consulted and the
// counter is never incremented — legacy behavior.
func TestTriggerCheck_BudgetBypassWhenDisabled(t *testing.T) {
checkerID := registerTestChecker()
engine := &countingCheckerEngine{}
// allow=false would normally refuse, but countManualTriggers=false bypasses.
budget := &stubBudgetChecker{allow: false}
cc := newTestControllerWithBudget(engine, budget, false)
uid, _ := happydns.NewRandomIdentifier()
w := postTrigger(cc, &happydns.User{Id: uid}, checkerID)
if w.Code != http.StatusAccepted {
t.Fatalf("expected 202 (bypass), got %d: %s", w.Code, w.Body.String())
}
if budget.increments != 0 {
t.Errorf("expected no IncrementUsage calls when flag off, got %d", budget.increments)
}
if engine.created != 1 {
t.Errorf("expected 1 CreateExecution call, got %d", engine.created)
}
}
// When countManualTriggers=true but budgetChecker is nil (e.g. checker
// subsystem initialised without a gater), the code falls through without
// panicking and behaves like the bypass mode.
func TestTriggerCheck_NilBudgetCheckerIgnored(t *testing.T) {
checkerID := registerTestChecker()
engine := &countingCheckerEngine{}
cc := newTestControllerWithBudget(engine, nil, true)
uid, _ := happydns.NewRandomIdentifier()
w := postTrigger(cc, &happydns.User{Id: uid}, checkerID)
if w.Code != http.StatusAccepted {
t.Fatalf("expected 202 (nil-budget fallback), got %d: %s", w.Code, w.Body.String())
}
if engine.created != 1 {
t.Errorf("expected 1 CreateExecution call, got %d", engine.created)
}
}
// Happy path with flag on and budget allowing: 202 and the usage counter is
// bumped exactly once.
func TestTriggerCheck_CountsOnSuccess(t *testing.T) {
checkerID := registerTestChecker()
engine := &countingCheckerEngine{}
budget := &stubBudgetChecker{allow: true}
cc := newTestControllerWithBudget(engine, budget, true)
uid, _ := happydns.NewRandomIdentifier()
w := postTrigger(cc, &happydns.User{Id: uid}, checkerID)
if w.Code != http.StatusAccepted {
t.Fatalf("expected 202, got %d: %s", w.Code, w.Body.String())
}
if budget.increments != 1 {
t.Errorf("expected IncrementUsage called once, got %d", budget.increments)
}
if engine.created != 1 {
t.Errorf("expected 1 CreateExecution call, got %d", engine.created)
}
}
// Over-budget: the request must be refused with 429 and CreateExecution
// must NOT be called (no side-effects on a rejected request).
func TestTriggerCheck_RefusedWhenOverBudget(t *testing.T) {
checkerID := registerTestChecker()
engine := &countingCheckerEngine{}
budget := &stubBudgetChecker{allow: false}
cc := newTestControllerWithBudget(engine, budget, true)
uid, _ := happydns.NewRandomIdentifier()
w := postTrigger(cc, &happydns.User{Id: uid}, checkerID)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429, got %d: %s", w.Code, w.Body.String())
}
if engine.created != 0 {
t.Errorf("expected no CreateExecution call on 429, got %d", engine.created)
}
if budget.increments != 0 {
t.Errorf("expected no IncrementUsage call on 429, got %d", budget.increments)
}
}
// --- GetExecutionStatus tests ---
func TestGetExecutionStatus_ReturnsExecution(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
execID, _ := happydns.NewRandomIdentifier()
exec := &happydns.Execution{
Id: execID,
CheckerID: "test",
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK, Message: "done"},
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/executions/"+execID.String(), nil)
c.Set("execution", exec)
cc.GetExecutionStatus(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if result["checkerId"] != "test" {
t.Errorf("expected checkerId %q, got %v", "test", result["checkerId"])
}
}
// --- GetChecker tests ---
func TestGetChecker_ReturnsDefinition(t *testing.T) {
checkerID := registerTestChecker()
cc := newTestController(&stubCheckerEngine{})
def := checkerPkg.FindChecker(checkerID)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/checkers/"+checkerID, nil)
c.Set("checker", def)
cc.GetChecker(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if result["id"] != checkerID {
t.Errorf("expected id %q, got %v", checkerID, result["id"])
}
}
// --- ExecutionHandler tests ---
func TestExecutionHandler_InvalidID(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/executions/not-valid", nil)
c.Params = gin.Params{{Key: "executionId", Value: "not-valid"}}
cc.ExecutionHandler(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestExecutionHandler_NotFound(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
fakeID, _ := happydns.NewRandomIdentifier()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/executions/"+fakeID.String(), nil)
c.Params = gin.Params{{Key: "executionId", Value: fakeID.String()}}
cc.ExecutionHandler(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
// --- PlanHandler tests ---
func TestPlanHandler_InvalidID(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plans/not-valid", nil)
c.Params = gin.Params{{Key: "planId", Value: "not-valid"}}
cc.PlanHandler(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestPlanHandler_NotFound(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
fakeID, _ := happydns.NewRandomIdentifier()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plans/"+fakeID.String(), nil)
c.Params = gin.Params{{Key: "planId", Value: fakeID.String()}}
cc.PlanHandler(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
// --- GetExecutionHTMLReport tests ---
// seedExecutionWithObservations creates an execution backed by a snapshot containing the given
// observation data. It returns the execution (with ID assigned by the store).
func seedExecutionWithObservations(t *testing.T, store storage.Storage, target happydns.CheckTarget, data map[happydns.ObservationKey]json.RawMessage) *happydns.Execution {
t.Helper()
snap := &happydns.ObservationSnapshot{
Target: target,
CollectedAt: time.Now(),
Data: data,
}
if err := store.CreateSnapshot(snap); err != nil {
t.Fatalf("CreateSnapshot: %v", err)
}
eval := &happydns.CheckEvaluation{
CheckerID: "html_test_checker",
Target: target,
SnapshotID: snap.Id,
}
if err := store.CreateEvaluation(eval); err != nil {
t.Fatalf("CreateEvaluation: %v", err)
}
exec := &happydns.Execution{
CheckerID: "html_test_checker",
Target: target,
Status: happydns.ExecutionDone,
EvaluationID: &eval.Id,
}
if err := store.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution: %v", err)
}
return exec
}
func init() {
// Register the HTML observation provider once for tests.
checkerPkg.RegisterObservationProvider(&testHTMLObservationProvider{})
}
func TestGetExecutionHTMLReport_ObservationsNotAvailable(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
// Create an execution with no evaluation/snapshot backing.
fakeExecID, _ := happydns.NewRandomIdentifier()
exec := &happydns.Execution{
Id: fakeExecID,
CheckerID: "html_test_checker",
Status: happydns.ExecutionDone,
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/report", nil)
c.Set("execution", exec)
c.Params = gin.Params{{Key: "obsKey", Value: "test_html_obs"}}
cc.GetExecutionHTMLReport(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetExecutionHTMLReport_ObservationKeyNotFound(t *testing.T) {
cc, store := newTestControllerWithStorage(&stubCheckerEngine{})
target := happydns.CheckTarget{DomainId: "d1"}
exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{
"test_html_obs": json.RawMessage(`{"v":1}`),
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/report", nil)
c.Set("execution", exec)
c.Params = gin.Params{{Key: "obsKey", Value: "nonexistent_key"}}
cc.GetExecutionHTMLReport(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
// testNoHTMLObservationProvider is a provider that does NOT implement CheckerHTMLReporter.
type testNoHTMLObservationProvider struct{}
func (p *testNoHTMLObservationProvider) Key() happydns.ObservationKey { return "test_no_html_obs" }
func (p *testNoHTMLObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
return map[string]any{"v": 1}, nil
}
func init() {
checkerPkg.RegisterObservationProvider(&testNoHTMLObservationProvider{})
}
func TestGetExecutionHTMLReport_ProviderDoesNotSupportHTML(t *testing.T) {
cc, store := newTestControllerWithStorage(&stubCheckerEngine{})
target := happydns.CheckTarget{DomainId: "d1"}
exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{
"test_no_html_obs": json.RawMessage(`{"v":1}`),
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/report", nil)
c.Set("execution", exec)
c.Params = gin.Params{{Key: "obsKey", Value: "test_no_html_obs"}}
cc.GetExecutionHTMLReport(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 (unsupported), got %d: %s", w.Code, w.Body.String())
}
}
func TestGetExecutionHTMLReport_Success(t *testing.T) {
cc, store := newTestControllerWithStorage(&stubCheckerEngine{})
target := happydns.CheckTarget{DomainId: "d1"}
exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{
"test_html_obs": json.RawMessage(`{"v":1}`),
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/report", nil)
c.Set("execution", exec)
c.Params = gin.Params{{Key: "obsKey", Value: "test_html_obs"}}
cc.GetExecutionHTMLReport(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if body != "<html><body>test report</body></html>" {
t.Errorf("unexpected body: %s", body)
}
ct := w.Header().Get("Content-Type")
if ct != "text/html; charset=utf-8" {
t.Errorf("expected Content-Type text/html, got %q", ct)
}
csp := w.Header().Get("Content-Security-Policy")
if csp == "" {
t.Error("expected Content-Security-Policy header to be set")
}
xcto := w.Header().Get("X-Content-Type-Options")
if xcto != "nosniff" {
t.Errorf("expected X-Content-Type-Options nosniff, got %q", xcto)
}
}
// --- getLimitParam tests ---
func newContextWithQuery(query string) *gin.Context {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/?"+query, nil)
return c
}
func TestGetLimitParam(t *testing.T) {
tests := []struct {
name string
query string
defaultLimit int
expected int
}{
{"empty query returns default", "", 100, 100},
{"valid limit", "limit=50", 100, 50},
{"zero returns default", "limit=0", 100, 100},
{"negative returns default", "limit=-5", 100, 100},
{"non-numeric returns default", "limit=abc", 100, 100},
{"large value capped to maxLimit", "limit=1500", 100, 1000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := newContextWithQuery(tt.query)
got := getLimitParam(c, tt.defaultLimit)
if got != tt.expected {
t.Errorf("getLimitParam(%q, %d) = %d, want %d", tt.query, tt.defaultLimit, got, tt.expected)
}
})
}
}

View file

@ -30,6 +30,7 @@ import (
"github.com/miekg/dns"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
@ -37,13 +38,15 @@ type DomainController struct {
domainService happydns.DomainUsecase
remoteZoneImporter happydns.RemoteZoneImporterUsecase
zoneImporter happydns.ZoneImporterUsecase
checkStatusUC *checkerUC.CheckStatusUsecase
}
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase) *DomainController {
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, checkStatusUC *checkerUC.CheckStatusUsecase) *DomainController {
return &DomainController{
domainService: domainService,
remoteZoneImporter: remoteZoneImporter,
zoneImporter: zoneImporter,
checkStatusUC: checkStatusUC,
}
}
@ -56,7 +59,7 @@ func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporte
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {array} happydns.Domain
// @Success 200 {array} happydns.DomainWithCheckStatus
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Unable to retrieve user's domains"
// @Router /domains [get]
@ -73,7 +76,25 @@ func (dc *DomainController) GetDomains(c *gin.Context) {
return
}
c.JSON(http.StatusOK, domains)
var statusByDomain map[string]*happydns.Status
if dc.checkStatusUC != nil {
var err error
statusByDomain, err = dc.checkStatusUC.GetWorstDomainStatuses(user.Id)
if err != nil {
log.Printf("GetWorstDomainStatuses: %s", err.Error())
}
}
result := make([]*happydns.DomainWithCheckStatus, 0, len(domains))
for _, d := range domains {
entry := &happydns.DomainWithCheckStatus{Domain: d}
if statusByDomain != nil {
entry.LastCheckStatus = statusByDomain[d.Id.String()]
}
result = append(result, entry)
}
c.JSON(http.StatusOK, result)
}
// AddDomain appends a new domain to those managed.

View file

@ -0,0 +1,86 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 controller
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
)
type DomainInfoController struct {
diuService happydns.DomainInfoUsecase
}
func NewDomainInfoController(diuService happydns.DomainInfoUsecase) *DomainInfoController {
return &DomainInfoController{
diuService: diuService,
}
}
// GetDomainInfo retrieves domain's administrative information.
//
// @Summary Get domain administrative information
// @Schemes
// @Description Retrieve domain's administrative information.
// @Tags domains
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Param domain path string true "Domain name"
// @Success 200 {object} happydns.DomainInfo
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domaininfo/{domain} [post]
func (dc *DomainInfoController) GetDomainInfo(c *gin.Context) {
domain := c.Param("domain")
if dn, ok := c.Get("domain"); ok {
domain = dn.(*happydns.Domain).DomainName
}
domain = dns.Fqdn(strings.TrimSpace(domain))
if domain == "." {
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: "empty domain name"})
return
}
if _, ok := dns.IsDomainName(domain); !ok {
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: "invalid domain name"})
return
}
info, err := dc.diuService.GetDomainInfo(c.Request.Context(), happydns.Origin(domain))
if err != nil {
if errors.Is(err, happydns.ErrDomainDoesNotExist) {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: err.Error()})
} else {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
}
return
}
c.JSON(http.StatusOK, info)
}

View file

@ -0,0 +1,296 @@
// 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 controller
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/storage/inmemory"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
// --- Stub types for domain tests ---
// stubDomainUsecase implements happydns.DomainUsecase for testing.
type stubDomainUsecase struct {
domains []*happydns.Domain
err error
}
func (s *stubDomainUsecase) CreateDomain(ctx context.Context, user *happydns.User, input *happydns.DomainCreationInput) (*happydns.Domain, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) DeleteDomain(id happydns.Identifier) error {
return fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) ExtendsDomainWithZoneMeta(d *happydns.Domain) (*happydns.DomainWithZoneMetadata, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) GetUserDomain(user *happydns.User, id happydns.Identifier) (*happydns.Domain, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) GetUserDomainByFQDN(user *happydns.User, fqdn string) ([]*happydns.Domain, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) ListUserDomains(user *happydns.User) ([]*happydns.Domain, error) {
if s.err != nil {
return nil, s.err
}
return s.domains, nil
}
func (s *stubDomainUsecase) UpdateDomain(id happydns.Identifier, user *happydns.User, fn func(*happydns.Domain)) error {
return fmt.Errorf("not implemented")
}
// newDomainTestContext creates a gin context with a logged-in user and a recorder.
func newDomainTestContext(user *happydns.User) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/domains", nil)
if user != nil {
c.Set("LoggedUser", user)
}
return w, c
}
// --- GetDomains tests ---
func TestGetDomains_Unauthenticated(t *testing.T) {
dc := NewDomainController(&stubDomainUsecase{}, nil, nil, nil)
w, c := newDomainTestContext(nil)
dc.GetDomains(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
}
func TestGetDomains_ListError(t *testing.T) {
stub := &stubDomainUsecase{err: fmt.Errorf("db failure")}
dc := NewDomainController(stub, nil, nil, nil)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
func TestGetDomains_EmptyList(t *testing.T) {
stub := &stubDomainUsecase{domains: []*happydns.Domain{}}
dc := NewDomainController(stub, nil, nil, nil)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
var result []happydns.DomainWithCheckStatus
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(result) != 0 {
t.Errorf("expected 0 domains, got %d", len(result))
}
}
func TestGetDomains_NilCheckStatusUC(t *testing.T) {
did1, _ := happydns.NewRandomIdentifier()
did2, _ := happydns.NewRandomIdentifier()
stub := &stubDomainUsecase{
domains: []*happydns.Domain{
{Id: did1, DomainName: "example.com."},
{Id: did2, DomainName: "example.org."},
},
}
dc := NewDomainController(stub, nil, nil, nil)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
var result []happydns.DomainWithCheckStatus
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 domains, got %d", len(result))
}
for _, d := range result {
if d.LastCheckStatus != nil {
t.Errorf("expected nil LastCheckStatus when checkStatusUC is nil, got %v for domain %s", *d.LastCheckStatus, d.DomainName)
}
}
}
func TestGetDomains_WithCheckStatuses(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
did1, _ := happydns.NewRandomIdentifier()
did2, _ := happydns.NewRandomIdentifier()
did3, _ := happydns.NewRandomIdentifier()
stub := &stubDomainUsecase{
domains: []*happydns.Domain{
{Id: did1, DomainName: "warn.example.com.", Owner: uid},
{Id: did2, DomainName: "ok.example.com.", Owner: uid},
{Id: did3, DomainName: "unchecked.example.com.", Owner: uid},
},
}
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("failed to create in-memory store: %v", err)
}
statusUC := checkerUC.NewCheckStatusUsecase(store, store, store, store)
// Create executions: domain 1 has WARN, domain 2 has OK, domain 3 has none.
for _, tc := range []struct {
domainId happydns.Identifier
status happydns.Status
}{
{did1, happydns.StatusOK},
{did1, happydns.StatusWarn},
{did2, happydns.StatusOK},
} {
exec := &happydns.Execution{
CheckerID: "test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: tc.domainId.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: tc.status},
}
if err := store.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
dc := NewDomainController(stub, nil, nil, statusUC)
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
var result []happydns.DomainWithCheckStatus
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(result) != 3 {
t.Fatalf("expected 3 domains, got %d", len(result))
}
statusByDomain := make(map[string]*happydns.Status)
for _, d := range result {
statusByDomain[d.Id.String()] = d.LastCheckStatus
}
// Domain 1: worst is WARN.
if s := statusByDomain[did1.String()]; s == nil {
t.Error("expected non-nil status for domain 1 (warn.example.com)")
} else if *s != happydns.StatusWarn {
t.Errorf("expected WARN for domain 1, got %v", *s)
}
// Domain 2: worst is OK.
if s := statusByDomain[did2.String()]; s == nil {
t.Error("expected non-nil status for domain 2 (ok.example.com)")
} else if *s != happydns.StatusOK {
t.Errorf("expected OK for domain 2, got %v", *s)
}
// Domain 3: no executions → nil.
if s := statusByDomain[did3.String()]; s != nil {
t.Errorf("expected nil status for domain 3 (unchecked.example.com), got %v", *s)
}
}
func TestGetDomains_ResponseIncludesDomainFields(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
pid, _ := happydns.NewRandomIdentifier()
stub := &stubDomainUsecase{
domains: []*happydns.Domain{
{Id: did, DomainName: "test.example.com.", Owner: uid, ProviderId: pid, Group: "mygroup"},
},
}
dc := NewDomainController(stub, nil, nil, nil)
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
var result []json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(result) != 1 {
t.Fatalf("expected 1 domain, got %d", len(result))
}
// Verify the JSON contains the expected domain fields (embedded from *Domain).
var fields map[string]json.RawMessage
if err := json.Unmarshal(result[0], &fields); err != nil {
t.Fatalf("failed to unmarshal domain entry: %v", err)
}
for _, key := range []string{"id", "id_owner", "id_provider", "domain", "group"} {
if _, ok := fields[key]; !ok {
t.Errorf("expected field %q in response JSON", key)
}
}
// last_check_status should be omitted when nil (omitempty).
if _, ok := fields["last_check_status"]; ok {
t.Error("expected last_check_status to be omitted when nil")
}
}

View file

@ -31,6 +31,7 @@ import (
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/internal/helpers"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
@ -38,13 +39,15 @@ type ZoneController struct {
domainService happydns.DomainUsecase
zoneCorrectionService happydns.ZoneCorrectionApplierUsecase
zoneService happydns.ZoneUsecase
checkStatusUC *checkerUC.CheckStatusUsecase
}
func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase) *ZoneController {
func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase, checkStatusUC *checkerUC.CheckStatusUsecase) *ZoneController {
return &ZoneController{
domainService: domainService,
zoneCorrectionService: zoneCorrectionService,
zoneService: zoneService,
checkStatusUC: checkStatusUC,
}
}
@ -59,14 +62,27 @@ func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.
// @Security securitydefinitions.basic
// @Param domainId path string true "Domain identifier"
// @Param zoneId path string true "Zone identifier"
// @Success 200 {object} happydns.Zone
// @Success 200 {object} happydns.ZoneWithServicesCheckStatus
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
// @Router /domains/{domainId}/zone/{zoneId} [get]
func (zc *ZoneController) GetZone(c *gin.Context) {
zone := c.MustGet("zone").(*happydns.Zone)
c.JSON(http.StatusOK, zone)
result := &happydns.ZoneWithServicesCheckStatus{Zone: zone}
if zc.checkStatusUC != nil {
user := middleware.MyUser(c)
domain := c.MustGet("domain").(*happydns.Domain)
statusByService, err := zc.checkStatusUC.GetWorstServiceStatuses(user.Id, domain.Id)
if err != nil {
log.Printf("GetWorstServiceStatuses: %s", err.Error())
} else {
result.ServicesCheckStatus = statusByService
}
}
c.JSON(http.StatusOK, result)
}
// GetZoneSubdomain returns the services associated with a given subdomain.

View file

@ -30,6 +30,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"git.happydns.org/happyDomain/internal/session"
"git.happydns.org/happyDomain/model"
)
@ -61,6 +62,12 @@ func JwtAuthMiddleware(authService happydns.AuthenticationUsecase, signingMethod
return
}
// Session IDs are handled by the session store; skip JWT parsing.
if session.IsValidSessionID(token) {
c.Next()
return
}
// Validate the token and retrieve claims
claims := &UserClaims{}
_, err := jwt.ParseWithClaims(token, claims,

View file

@ -0,0 +1,130 @@
// 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 middleware_test
import (
"bytes"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
"git.happydns.org/happyDomain/model"
)
func init() {
gin.SetMode(gin.TestMode)
}
// stubAuthUsecase is a no-op implementation of happydns.AuthenticationUsecase.
// The middleware should never reach a method of this stub when the token is a
// session ID, and should never reach it on a malformed JWT either (it returns
// after logging). We still assert it was not called by leaving the methods
// panicking — if any test trips one, we know the branching logic regressed.
type stubAuthUsecase struct{}
func (stubAuthUsecase) AuthenticateUserWithPassword(_ happydns.LoginRequest) (*happydns.User, error) {
panic("AuthenticateUserWithPassword should not be called in these tests")
}
func (stubAuthUsecase) CompleteAuthentication(_ happydns.UserInfo) (*happydns.User, error) {
panic("CompleteAuthentication should not be called in these tests")
}
// captureLog redirects the default logger to a buffer for the duration of fn.
func captureLog(t *testing.T, fn func()) string {
t.Helper()
var buf bytes.Buffer
prevOut := log.Writer()
prevFlags := log.Flags()
log.SetOutput(&buf)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(prevOut)
log.SetFlags(prevFlags)
})
fn()
return buf.String()
}
func newRouter() *gin.Engine {
r := gin.New()
r.Use(middleware.JwtAuthMiddleware(stubAuthUsecase{}, "HS256", []byte("test-secret")))
r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
return r
}
func Test_JwtAuthMiddleware_SessionIDTokenIsSilent(t *testing.T) {
r := newRouter()
output := captureLog(t, func() {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+sessionUC.NewSessionID())
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
})
if strings.Contains(output, "bad JWT claims") {
t.Errorf("expected no %q log for a session-ID token, got:\n%s", "bad JWT claims", output)
}
if strings.TrimSpace(output) != "" {
t.Errorf("expected no log output at all for a session-ID token, got:\n%s", output)
}
}
func Test_JwtAuthMiddleware_MalformedTokenStillLogs(t *testing.T) {
r := newRouter()
output := captureLog(t, func() {
req := httptest.NewRequest(http.MethodGet, "/", nil)
// Contains a dot, so it can't match the session-ID shape and will be
// routed to the JWT parser, which will fail.
req.Header.Set("Authorization", "Bearer not.a.jwt")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
})
if !strings.Contains(output, "bad JWT claims") {
t.Errorf("expected %q log for a malformed JWT, got:\n%s", "bad JWT claims", output)
}
}
func Test_JwtAuthMiddleware_NoAuthHeaderIsSilent(t *testing.T) {
r := newRouter()
output := captureLog(t, func() {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
})
if strings.TrimSpace(output) != "" {
t.Errorf("expected no log output without an Authorization header, got:\n%s", output)
}
}

View file

@ -0,0 +1,116 @@
// 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 route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
// declareCheckerOptionsRoutes registers the options sub-routes on a checker group.
func declareCheckerOptionsRoutes(checkerID *gin.RouterGroup, cc *controller.CheckerController) {
checkerID.GET("/options", cc.GetCheckerOptions)
checkerID.POST("/options", cc.AddCheckerOptions)
checkerID.PUT("/options", cc.ChangeCheckerOptions)
checkerID.GET("/options/:optname", cc.GetCheckerOption)
checkerID.PUT("/options/:optname", cc.SetCheckerOption)
}
// DeclareCheckerRoutes registers global checker routes under /api/checkers.
// Returns the controller so it can be reused for scoped routes.
func DeclareCheckerRoutes(
apiRoutes *gin.RouterGroup,
engine happydns.CheckerEngine,
optionsUC *checkerUC.CheckerOptionsUsecase,
planUC *checkerUC.CheckPlanUsecase,
statusUC *checkerUC.CheckStatusUsecase,
plannedProvider checkerUC.PlannedJobProvider,
budgetChecker checkerUC.BudgetChecker,
countManualTriggers bool,
) *controller.CheckerController {
cc := controller.NewCheckerController(engine, optionsUC, planUC, statusUC, plannedProvider, budgetChecker, countManualTriggers)
// Global: /api/checkers
checkers := apiRoutes.Group("/checkers")
checkers.GET("", cc.ListCheckers)
checkers.GET("/metrics", cc.GetUserMetrics)
checkerID := checkers.Group("/:checkerId")
checkerID.Use(cc.CheckerHandler)
checkerID.GET("", cc.GetChecker)
declareCheckerOptionsRoutes(checkerID, cc)
return cc
}
// DeclareScopedCheckerRoutes registers checker routes scoped to a domain or service.
// Called for both /api/domains/:domain/checkers and .../services/:serviceid/checkers.
func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController) {
checkers := scopedRouter.Group("/checkers")
checkers.GET("", cc.ListAvailableChecks)
checkers.GET("/metrics", cc.GetDomainMetrics)
checkerID := checkers.Group("/:checkerId")
checkerID.Use(cc.CheckerHandler)
declareCheckerOptionsRoutes(checkerID, cc)
// Plans (schedules).
checkerID.GET("/plans", cc.ListCheckPlans)
checkerID.POST("/plans", cc.CreateCheckPlan)
planID := checkerID.Group("/plans/:planId")
planID.Use(cc.PlanHandler)
planID.GET("", cc.GetCheckPlan)
planID.PUT("", cc.UpdateCheckPlan)
planID.DELETE("", cc.DeleteCheckPlan)
// Per-checker metrics.
checkerID.GET("/metrics", cc.GetCheckerMetrics)
// Executions.
executions := checkerID.Group("/executions")
executions.GET("", cc.ListExecutions)
executions.POST("", cc.TriggerCheck)
executions.DELETE("", cc.DeleteCheckerExecutions)
executionID := executions.Group("/:executionId")
executionID.Use(cc.ExecutionHandler)
executionID.GET("", cc.GetExecutionStatus)
executionID.DELETE("", cc.DeleteExecution)
// Metrics (under execution).
executionID.GET("/metrics", cc.GetExecutionMetrics)
// Observations (under execution).
executionID.GET("/observations", cc.GetExecutionObservations)
executionID.GET("/observations/:obsKey", cc.GetExecutionObservation)
executionID.GET("/observations/:obsKey/report", cc.GetExecutionHTMLReport)
// Results (under execution).
executionID.GET("/results", cc.GetExecutionResults)
executionID.GET("/results/:ruleName", cc.GetExecutionResult)
}

View file

@ -26,6 +26,7 @@ import (
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
@ -39,11 +40,15 @@ func DeclareDomainRoutes(
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
cc *controller.CheckerController,
checkStatusUC *checkerUC.CheckStatusUsecase,
domainInfoUC happydns.DomainInfoUsecase,
) {
dc := controller.NewDomainController(
domainUC,
remoteZoneImporter,
zoneImporter,
checkStatusUC,
)
router.GET("/domains", dc.GetDomains)
@ -56,11 +61,17 @@ func DeclareDomainRoutes(
apiDomainsRoutes.PUT("", dc.UpdateDomain)
apiDomainsRoutes.DELETE("", dc.DelDomain)
DeclareDomainInfoRoutes(apiDomainsRoutes.Group("/info"), domainInfoUC)
DeclareDomainLogRoutes(apiDomainsRoutes, domainLogUC)
apiDomainsRoutes.POST("/zone", dc.ImportZone)
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
// Mount domain-scoped checker routes.
if cc != nil {
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc)
}
DeclareZoneRoutes(
apiDomainsRoutes,
zoneUC,
@ -68,5 +79,6 @@ func DeclareDomainRoutes(
zoneCorrApplier,
zoneServiceUC,
serviceUC,
cc,
)
}

View file

@ -0,0 +1,37 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
func DeclareDomainInfoRoutes(router *gin.RouterGroup, domainInfoUC happydns.DomainInfoUsecase) {
dc := controller.NewDomainInfoController(
domainInfoUC,
)
router.POST("", dc.GetDomainInfo)
}

View file

@ -22,19 +22,27 @@
package route
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
ratelimit "github.com/JGLTechnologies/gin-rate-limit"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
happydns "git.happydns.org/happyDomain/model"
)
// Dependencies holds all use cases required to register the public API routes.
// It is a plain struct — no methods, no interface — constructed once in app.go.
// It is a plain struct - no methods, no interface - constructed once in app.go.
type Dependencies struct {
Authentication happydns.AuthenticationUsecase
AuthUser happydns.AuthUserUsecase
CaptchaVerifier happydns.CaptchaVerifier
Domain happydns.DomainUsecase
DomainInfo happydns.DomainInfoUsecase
DomainLog happydns.DomainLogUsecase
FailureTracker happydns.FailureTracker
Provider happydns.ProviderUsecase
@ -50,6 +58,14 @@ type Dependencies struct {
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
ZoneImporter happydns.ZoneImporterUsecase
ZoneService happydns.ZoneServiceUsecase
CheckerEngine happydns.CheckerEngine
CheckerOptionsUC *checkerUC.CheckerOptionsUsecase
CheckPlanUC *checkerUC.CheckPlanUsecase
CheckStatusUC *checkerUC.CheckStatusUsecase
PlannedProvider checkerUC.PlannedJobProvider
BudgetChecker checkerUC.BudgetChecker
CountManualTriggers bool
}
// @title happyDomain API
@ -88,6 +104,22 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
dep.FailureTracker,
)
auc := DeclareAuthUserRoutes(apiRoutes, dep.AuthUser, lc)
domainInfoRL := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{
Rate: time.Minute,
Limit: 10,
})
domainInfoRLMiddleware := ratelimit.RateLimiter(domainInfoRL, &ratelimit.Options{
ErrorHandler: func(c *gin.Context, info ratelimit.Info) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, happydns.ErrorResponse{
Message: "Too many requests. Please try again later.",
})
},
KeyFunc: func(c *gin.Context) string {
return c.ClientIP()
},
})
DeclareDomainInfoRoutes(apiRoutes.Group("/domaininfo/:domain", domainInfoRLMiddleware), dep.DomainInfo)
DeclareProviderSpecsRoutes(apiRoutes, dep.ProviderSpecs)
DeclareRegistrationRoutes(apiRoutes, dep.AuthUser, dep.CaptchaVerifier)
DeclareResolverRoutes(apiRoutes, dep.Resolver)
@ -105,6 +137,21 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
}
apiAuthRoutes.Use(middleware.AuthRequired())
// Initialize checker controller if checker engine is available.
var cc *controller.CheckerController
if dep.CheckerEngine != nil {
cc = DeclareCheckerRoutes(
apiAuthRoutes,
dep.CheckerEngine,
dep.CheckerOptionsUC,
dep.CheckPlanUC,
dep.CheckStatusUC,
dep.PlannedProvider,
dep.BudgetChecker,
dep.CountManualTriggers,
)
}
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
DeclareDomainRoutes(
apiAuthRoutes,
@ -116,6 +163,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
dep.ZoneCorrectionApplier,
dep.ZoneService,
dep.Service,
cc,
dep.CheckStatusUC,
dep.DomainInfo,
)
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)

View file

@ -36,6 +36,7 @@ func DeclareZoneServiceRoutes(
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
zoneUC happydns.ZoneUsecase,
cc *controller.CheckerController,
) {
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
@ -47,4 +48,9 @@ func DeclareZoneServiceRoutes(
apiZonesSubdomainServiceIDRoutes.Use(middleware.ServiceIdHandler(serviceUC))
apiZonesSubdomainServiceIDRoutes.GET("", sc.GetZoneService)
apiZonesSubdomainServiceIDRoutes.DELETE("", sc.DeleteZoneService)
// Mount service-scoped checker routes.
if cc != nil {
DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc)
}
}

View file

@ -26,6 +26,7 @@ import (
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
happydns "git.happydns.org/happyDomain/model"
)
@ -36,11 +37,18 @@ func DeclareZoneRoutes(
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
cc *controller.CheckerController,
) {
var checkStatusUC *checkerUC.CheckStatusUsecase
if cc != nil {
checkStatusUC = cc.StatusUC()
}
zc := controller.NewZoneController(
zoneUC,
domainUC,
zoneCorrApplier,
checkStatusUC,
)
apiZonesRoutes := router.Group("/zone/:zoneid")
@ -65,6 +73,7 @@ func DeclareZoneRoutes(
zoneServiceUC,
serviceUC,
zoneUC,
cc,
)
apiZonesRoutes.POST("/records", zc.AddRecords)

View file

@ -31,8 +31,10 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
admin "git.happydns.org/happyDomain/internal/api-admin/route"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/web-admin"
@ -55,6 +57,11 @@ func NewAdmin(app *App) *Admin {
// Prepare usecases (admin uses unrestricted provider access)
app.usecases.providerAdmin = providerUC.NewService(app.store, nil)
if app.usecases.checkerOptionsUC == nil {
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store)
}
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
admin.DeclareRoutes(
app.cfg,
@ -71,6 +78,8 @@ func NewAdmin(app *App) *Admin {
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
ZoneService: app.usecases.zoneService,
CheckerOptionsUC: app.usecases.checkerOptionsUC,
CheckScheduler: app.usecases.checkerScheduler,
},
)
web.DeclareRoutes(app.cfg, router)

View file

@ -33,11 +33,13 @@ import (
api "git.happydns.org/happyDomain/internal/api/route"
"git.happydns.org/happyDomain/internal/captcha"
"git.happydns.org/happyDomain/internal/mailer"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/internal/newsletter"
"git.happydns.org/happyDomain/internal/session"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase"
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
@ -48,6 +50,7 @@ import (
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
zoneServiceUC "git.happydns.org/happyDomain/internal/usecase/zone_service"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/pkg/domaininfo"
"git.happydns.org/happyDomain/web"
)
@ -55,6 +58,7 @@ type Usecases struct {
authentication happydns.AuthenticationUsecase
authUser happydns.AuthUserUsecase
domain happydns.DomainUsecase
domainInfo happydns.DomainInfoUsecase
domainLog happydns.DomainLogUsecase
provider happydns.ProviderUsecase
providerAdmin happydns.ProviderUsecase
@ -69,6 +73,14 @@ type Usecases struct {
zoneService happydns.ZoneServiceUsecase
orchestrator *orchestrator.Orchestrator
checkerEngine happydns.CheckerEngine
checkerOptionsUC *checkerUC.CheckerOptionsUsecase
checkerPlanUC *checkerUC.CheckPlanUsecase
checkerStatusUC *checkerUC.CheckStatusUsecase
checkerScheduler *checkerUC.Scheduler
checkerJanitor *checkerUC.Janitor
checkerUserGater *checkerUC.UserGater
}
type App struct {
@ -93,6 +105,9 @@ func NewApp(cfg *happydns.Options) *App {
app.initStorageEngine()
app.initNewsletter()
app.initInsights()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.setupRouter()
@ -108,6 +123,9 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
app.initMailer()
app.initNewsletter()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.setupRouter()
@ -162,6 +180,9 @@ func (app *App) initStorageEngine() {
if err = app.store.MigrateSchema(); err != nil {
log.Fatal("Could not migrate database: ", err)
}
metrics.NewStorageStatsCollector(storage.NewStatsProvider(app.store))
app.store = newInstrumentedStorage(app.store)
}
}
@ -207,6 +228,10 @@ func (app *App) initUsecases() {
app.usecases.service = serviceService
app.usecases.serviceSpecs = usecase.NewServiceSpecsUsecase()
app.usecases.zone = zoneService
app.usecases.domainInfo = usecase.NewDomainInfoUsecase(
domaininfo.GetDomainRDAPInfo,
domaininfo.GetDomainWhoisInfo,
)
app.usecases.domainLog = domainLogService
domainService := domainUC.NewService(
@ -224,12 +249,13 @@ func (app *App) initUsecases() {
app.store,
)
app.usecases.user = userUC.NewUserUsecases(
userService := userUC.NewUserUsecases(
app.store,
app.newsletter,
authUserService,
sessionService,
)
app.usecases.user = userService
app.usecases.authentication = usecase.NewAuthenticationUsecase(app.cfg, app.store, app.usecases.user)
app.usecases.authUser = authUserService
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
@ -246,6 +272,50 @@ func (app *App) initUsecases() {
providerAdminService,
zoneService.UpdateZoneUC,
)
// Checker system.
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store)
app.usecases.checkerPlanUC = checkerUC.NewCheckPlanUsecase(app.store)
app.usecases.checkerStatusUC = checkerUC.NewCheckStatusUsecase(app.store, app.store, app.store, app.store)
app.usecases.checkerEngine = checkerUC.NewCheckerEngine(
app.usecases.checkerOptionsUC,
app.store,
app.store,
app.store,
app.store,
)
// Build the user-level gate so paused or long-inactive users do not
// get checked. The same user resolver is reused by the janitor for
// per-user retention overrides.
app.usecases.checkerUserGater = checkerUC.NewUserGater(app.store, app.cfg.CheckerInactivityPauseDays, app.cfg.CheckerMaxChecksPerDay)
app.usecases.checkerScheduler = checkerUC.NewScheduler(
app.usecases.checkerEngine,
app.cfg.CheckerMaxConcurrency,
app.store, app.store, app.store, app.store,
app.usecases.checkerUserGater.AllowWithInterval,
app.usecases.checkerUserGater.IncrementUsage,
)
// Invalidate the scheduler's user gate cache whenever a user is updated
// (e.g. login refreshing LastSeen, admin toggling SchedulingPaused).
userService.SetOnUserChanged(func(id happydns.Identifier) {
app.usecases.checkerUserGater.Invalidate(id.String())
})
// Retention janitor.
app.usecases.checkerJanitor = checkerUC.NewJanitor(
app.store,
app.store,
app.store,
app.store,
app.store,
checkerUC.DefaultRetentionPolicy(app.cfg.CheckerRetentionDays),
app.cfg.CheckerJanitorInterval,
)
// Wire scheduler notifications for incremental queue updates.
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
}
func (app *App) setupRouter() {
@ -255,7 +325,7 @@ func (app *App) setupRouter() {
gin.ForceConsoleColor()
app.router = gin.New()
app.router.Use(gin.Logger(), gin.Recovery(), sessions.Sessions(
app.router.Use(gin.Logger(), gin.Recovery(), metrics.HTTPMiddleware(), sessions.Sessions(
session.COOKIE_NAME,
session.NewSessionStore(app.cfg, app.store, []byte(app.cfg.JWTSecretKey)),
))
@ -276,6 +346,7 @@ func (app *App) setupRouter() {
AuthUser: app.usecases.authUser,
CaptchaVerifier: app.captchaVerifier,
Domain: app.usecases.domain,
DomainInfo: app.usecases.domainInfo,
DomainLog: app.usecases.domainLog,
FailureTracker: app.failureTracker,
Provider: app.usecases.provider,
@ -291,6 +362,14 @@ func (app *App) setupRouter() {
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
ZoneService: app.usecases.zoneService,
CheckerEngine: app.usecases.checkerEngine,
CheckerOptionsUC: app.usecases.checkerOptionsUC,
CheckPlanUC: app.usecases.checkerPlanUC,
CheckStatusUC: app.usecases.checkerStatusUC,
PlannedProvider: app.usecases.checkerScheduler,
BudgetChecker: app.usecases.checkerUserGater,
CountManualTriggers: app.cfg.CheckerCountManualTriggers,
},
)
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
@ -308,6 +387,18 @@ func (app *App) Start() {
go app.insights.Run()
}
if app.usecases.checkerScheduler != nil {
app.usecases.checkerScheduler.Start(context.Background())
}
if app.usecases.checkerJanitor != nil {
app.usecases.checkerJanitor.Start(context.Background())
}
if app.usecases.checkerUserGater != nil {
app.usecases.checkerUserGater.Start(context.Background())
}
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
@ -321,6 +412,18 @@ func (app *App) Stop() {
log.Fatal("Server Shutdown:", err)
}
if app.usecases.checkerScheduler != nil {
app.usecases.checkerScheduler.Stop()
}
if app.usecases.checkerJanitor != nil {
app.usecases.checkerJanitor.Stop()
}
if app.usecases.checkerUserGater != nil {
app.usecases.checkerUserGater.Stop()
}
// Close storage
if app.store != nil {
app.store.Close()

View file

@ -0,0 +1,557 @@
// Code generated by go run tools/gen_instrumented_storage.go; DO NOT EDIT.
package app
import (
"time"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/internal/storage"
happydns "git.happydns.org/happyDomain/model"
)
// instrumentedStorage wraps a storage.Storage to record Prometheus metrics for
// every operation.
type instrumentedStorage struct {
inner storage.Storage
}
// newInstrumentedStorage wraps the given Storage with metrics instrumentation.
func newInstrumentedStorage(s storage.Storage) storage.Storage {
return &instrumentedStorage{inner: s}
}
// observe starts a timer and returns a closure that, when called with a
// pointer to the named return error, records the operation outcome. Use as:
//
// defer observe("get", "user")(&err)
//
// The closure reads *err at defer-execution time, so it captures the final
// value of the named return.
func observe(operation, entity string) func(err *error) {
start := time.Now()
return func(err *error) {
status := "success"
if *err != nil {
status = "error"
}
metrics.StorageOperationsTotal.WithLabelValues(operation, entity, status).Inc()
metrics.StorageOperationDuration.WithLabelValues(operation, entity).Observe(time.Since(start).Seconds())
}
}
func (s *instrumentedStorage) AuthUserExists(email string) (ret bool, err error) {
defer observe("get", "authuser")(&err)
return s.inner.AuthUserExists(email)
}
func (s *instrumentedStorage) ClearAuthUsers() (err error) {
defer observe("delete", "authuser")(&err)
return s.inner.ClearAuthUsers()
}
func (s *instrumentedStorage) ClearCheckPlans() (err error) {
defer observe("delete", "check_plan")(&err)
return s.inner.ClearCheckPlans()
}
func (s *instrumentedStorage) ClearCheckerConfigurations() (err error) {
defer observe("delete", "check_config")(&err)
return s.inner.ClearCheckerConfigurations()
}
func (s *instrumentedStorage) ClearDomains() (err error) {
defer observe("delete", "domain")(&err)
return s.inner.ClearDomains()
}
func (s *instrumentedStorage) ClearEvaluations() (err error) {
defer observe("delete", "check_evaluation")(&err)
return s.inner.ClearEvaluations()
}
func (s *instrumentedStorage) ClearExecutions() (err error) {
defer observe("delete", "execution")(&err)
return s.inner.ClearExecutions()
}
func (s *instrumentedStorage) ClearProviders() (err error) {
defer observe("delete", "provider")(&err)
return s.inner.ClearProviders()
}
func (s *instrumentedStorage) ClearSessions() (err error) {
defer observe("delete", "session")(&err)
return s.inner.ClearSessions()
}
func (s *instrumentedStorage) ClearSnapshots() (err error) {
defer observe("delete", "observation_snapshot")(&err)
return s.inner.ClearSnapshots()
}
func (s *instrumentedStorage) ClearUsers() (err error) {
defer observe("delete", "user")(&err)
return s.inner.ClearUsers()
}
func (s *instrumentedStorage) ClearZones() (err error) {
defer observe("delete", "zone")(&err)
return s.inner.ClearZones()
}
func (s *instrumentedStorage) Close() error { return s.inner.Close() }
func (s *instrumentedStorage) CountDomains() (ret int, err error) {
defer observe("count", "domain")(&err)
return s.inner.CountDomains()
}
func (s *instrumentedStorage) CountProviders() (ret int, err error) {
defer observe("count", "provider")(&err)
return s.inner.CountProviders()
}
func (s *instrumentedStorage) CountUsers() (ret int, err error) {
defer observe("count", "user")(&err)
return s.inner.CountUsers()
}
func (s *instrumentedStorage) CountZones() (ret int, err error) {
defer observe("count", "zone")(&err)
return s.inner.CountZones()
}
func (s *instrumentedStorage) CreateAuthUser(user *happydns.UserAuth) (err error) {
defer observe("create", "authuser")(&err)
return s.inner.CreateAuthUser(user)
}
func (s *instrumentedStorage) CreateCheckPlan(plan *happydns.CheckPlan) (err error) {
defer observe("create", "check_plan")(&err)
return s.inner.CreateCheckPlan(plan)
}
func (s *instrumentedStorage) CreateDomain(domain *happydns.Domain) (err error) {
defer observe("create", "domain")(&err)
return s.inner.CreateDomain(domain)
}
func (s *instrumentedStorage) CreateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
defer observe("create", "domain_log")(&err)
return s.inner.CreateDomainLog(domain, log)
}
func (s *instrumentedStorage) CreateEvaluation(eval *happydns.CheckEvaluation) (err error) {
defer observe("create", "check_evaluation")(&err)
return s.inner.CreateEvaluation(eval)
}
func (s *instrumentedStorage) CreateExecution(exec *happydns.Execution) (err error) {
defer observe("create", "execution")(&err)
return s.inner.CreateExecution(exec)
}
func (s *instrumentedStorage) CreateOrUpdateUser(user *happydns.User) (err error) {
defer observe("update", "user")(&err)
return s.inner.CreateOrUpdateUser(user)
}
func (s *instrumentedStorage) CreateProvider(prvd *happydns.Provider) (err error) {
defer observe("create", "provider")(&err)
return s.inner.CreateProvider(prvd)
}
func (s *instrumentedStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) (err error) {
defer observe("create", "observation_snapshot")(&err)
return s.inner.CreateSnapshot(snap)
}
func (s *instrumentedStorage) CreateZone(zone *happydns.Zone) (err error) {
defer observe("create", "zone")(&err)
return s.inner.CreateZone(zone)
}
func (s *instrumentedStorage) DeleteAuthUser(user *happydns.UserAuth) (err error) {
defer observe("delete", "authuser")(&err)
return s.inner.DeleteAuthUser(user)
}
func (s *instrumentedStorage) DeleteCheckPlan(planID happydns.Identifier) (err error) {
defer observe("delete", "check_plan")(&err)
return s.inner.DeleteCheckPlan(planID)
}
func (s *instrumentedStorage) DeleteCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) (err error) {
defer observe("delete", "check_config")(&err)
return s.inner.DeleteCheckerConfiguration(checkerName, userId, domainId, serviceId)
}
func (s *instrumentedStorage) DeleteDomain(domainid happydns.Identifier) (err error) {
defer observe("delete", "domain")(&err)
return s.inner.DeleteDomain(domainid)
}
func (s *instrumentedStorage) DeleteDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
defer observe("delete", "domain_log")(&err)
return s.inner.DeleteDomainLog(domain, log)
}
func (s *instrumentedStorage) DeleteEvaluation(evalID happydns.Identifier) (err error) {
defer observe("delete", "check_evaluation")(&err)
return s.inner.DeleteEvaluation(evalID)
}
func (s *instrumentedStorage) DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) (err error) {
defer observe("delete", "check_evaluation")(&err)
return s.inner.DeleteEvaluationsByChecker(checkerID, target)
}
func (s *instrumentedStorage) DeleteExecution(execID happydns.Identifier) (err error) {
defer observe("delete", "execution")(&err)
return s.inner.DeleteExecution(execID)
}
func (s *instrumentedStorage) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) (err error) {
defer observe("delete", "execution")(&err)
return s.inner.DeleteExecutionsByChecker(checkerID, target)
}
func (s *instrumentedStorage) DeleteProvider(prvdid happydns.Identifier) (err error) {
defer observe("delete", "provider")(&err)
return s.inner.DeleteProvider(prvdid)
}
func (s *instrumentedStorage) DeleteSession(sessionid string) (err error) {
defer observe("delete", "session")(&err)
return s.inner.DeleteSession(sessionid)
}
func (s *instrumentedStorage) DeleteSnapshot(snapID happydns.Identifier) (err error) {
defer observe("delete", "observation_snapshot")(&err)
return s.inner.DeleteSnapshot(snapID)
}
func (s *instrumentedStorage) DeleteUser(userid happydns.Identifier) (err error) {
defer observe("delete", "user")(&err)
return s.inner.DeleteUser(userid)
}
func (s *instrumentedStorage) DeleteZone(zoneid happydns.Identifier) (err error) {
defer observe("delete", "zone")(&err)
return s.inner.DeleteZone(zoneid)
}
func (s *instrumentedStorage) GetAuthUser(id happydns.Identifier) (ret *happydns.UserAuth, err error) {
defer observe("get", "authuser")(&err)
return s.inner.GetAuthUser(id)
}
func (s *instrumentedStorage) GetAuthUserByEmail(email string) (ret *happydns.UserAuth, err error) {
defer observe("get", "authuser")(&err)
return s.inner.GetAuthUserByEmail(email)
}
func (s *instrumentedStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (ret *happydns.ObservationCacheEntry, err error) {
defer observe("get", "observation_cache")(&err)
return s.inner.GetCachedObservation(target, key)
}
func (s *instrumentedStorage) GetCheckPlan(planID happydns.Identifier) (ret *happydns.CheckPlan, err error) {
defer observe("get", "check_plan")(&err)
return s.inner.GetCheckPlan(planID)
}
func (s *instrumentedStorage) GetCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) (ret []*happydns.CheckerOptionsPositional, err error) {
defer observe("get", "check_config")(&err)
return s.inner.GetCheckerConfiguration(checkerName, userId, domainId, serviceId)
}
func (s *instrumentedStorage) GetDomain(domainid happydns.Identifier) (ret *happydns.Domain, err error) {
defer observe("get", "domain")(&err)
return s.inner.GetDomain(domainid)
}
func (s *instrumentedStorage) GetDomainByDN(user *happydns.User, fqdn string) (ret []*happydns.Domain, err error) {
defer observe("get", "domain")(&err)
return s.inner.GetDomainByDN(user, fqdn)
}
func (s *instrumentedStorage) GetEvaluation(evalID happydns.Identifier) (ret *happydns.CheckEvaluation, err error) {
defer observe("get", "check_evaluation")(&err)
return s.inner.GetEvaluation(evalID)
}
func (s *instrumentedStorage) GetExecution(execID happydns.Identifier) (ret *happydns.Execution, err error) {
defer observe("get", "execution")(&err)
return s.inner.GetExecution(execID)
}
func (s *instrumentedStorage) GetLastSchedulerRun() (ret time.Time, err error) {
defer observe("get", "scheduler_state")(&err)
return s.inner.GetLastSchedulerRun()
}
func (s *instrumentedStorage) GetLatestEvaluation(planID happydns.Identifier) (ret *happydns.CheckEvaluation, err error) {
defer observe("get", "check_evaluation")(&err)
return s.inner.GetLatestEvaluation(planID)
}
func (s *instrumentedStorage) GetProvider(prvdid happydns.Identifier) (ret *happydns.ProviderMessage, err error) {
defer observe("get", "provider")(&err)
return s.inner.GetProvider(prvdid)
}
func (s *instrumentedStorage) GetSession(sessionid string) (ret *happydns.Session, err error) {
defer observe("get", "session")(&err)
return s.inner.GetSession(sessionid)
}
func (s *instrumentedStorage) GetSnapshot(snapID happydns.Identifier) (ret *happydns.ObservationSnapshot, err error) {
defer observe("get", "observation_snapshot")(&err)
return s.inner.GetSnapshot(snapID)
}
func (s *instrumentedStorage) GetUser(userid happydns.Identifier) (ret *happydns.User, err error) {
defer observe("get", "user")(&err)
return s.inner.GetUser(userid)
}
func (s *instrumentedStorage) GetUserByEmail(email string) (ret *happydns.User, err error) {
defer observe("get", "user")(&err)
return s.inner.GetUserByEmail(email)
}
func (s *instrumentedStorage) GetZone(zoneid happydns.Identifier) (ret *happydns.ZoneMessage, err error) {
defer observe("get", "zone")(&err)
return s.inner.GetZone(zoneid)
}
func (s *instrumentedStorage) GetZoneMeta(zoneid happydns.Identifier) (ret *happydns.ZoneMeta, err error) {
defer observe("get", "zone")(&err)
return s.inner.GetZoneMeta(zoneid)
}
func (s *instrumentedStorage) InsightsRun() (err error) {
defer observe("run", "insight")(&err)
return s.inner.InsightsRun()
}
func (s *instrumentedStorage) LastInsightsRun() (ret *time.Time, ret2 happydns.Identifier, err error) {
defer observe("get", "insight")(&err)
return s.inner.LastInsightsRun()
}
func (s *instrumentedStorage) ListAllAuthUsers() (ret happydns.Iterator[happydns.UserAuth], err error) {
defer observe("list", "authuser")(&err)
return s.inner.ListAllAuthUsers()
}
func (s *instrumentedStorage) ListAllCachedObservations() (ret happydns.Iterator[happydns.ObservationCacheEntry], err error) {
defer observe("list", "observation_cache")(&err)
return s.inner.ListAllCachedObservations()
}
func (s *instrumentedStorage) ListAllCheckPlans() (ret happydns.Iterator[happydns.CheckPlan], err error) {
defer observe("list", "check_plan")(&err)
return s.inner.ListAllCheckPlans()
}
func (s *instrumentedStorage) ListAllCheckerConfigurations() (ret happydns.Iterator[happydns.CheckerOptionsPositional], err error) {
defer observe("list", "check_config")(&err)
return s.inner.ListAllCheckerConfigurations()
}
func (s *instrumentedStorage) ListAllDomainLogs() (ret happydns.Iterator[happydns.DomainLogWithDomainId], err error) {
defer observe("list", "domain_log")(&err)
return s.inner.ListAllDomainLogs()
}
func (s *instrumentedStorage) ListAllDomains() (ret happydns.Iterator[happydns.Domain], err error) {
defer observe("list", "domain")(&err)
return s.inner.ListAllDomains()
}
func (s *instrumentedStorage) ListAllEvaluations() (ret happydns.Iterator[happydns.CheckEvaluation], err error) {
defer observe("list", "check_evaluation")(&err)
return s.inner.ListAllEvaluations()
}
func (s *instrumentedStorage) ListAllExecutions() (ret happydns.Iterator[happydns.Execution], err error) {
defer observe("list", "execution")(&err)
return s.inner.ListAllExecutions()
}
func (s *instrumentedStorage) ListAllProviders() (ret happydns.Iterator[happydns.ProviderMessage], err error) {
defer observe("list", "provider")(&err)
return s.inner.ListAllProviders()
}
func (s *instrumentedStorage) ListAllSessions() (ret happydns.Iterator[happydns.Session], err error) {
defer observe("list", "session")(&err)
return s.inner.ListAllSessions()
}
func (s *instrumentedStorage) ListAllSnapshots() (ret happydns.Iterator[happydns.ObservationSnapshot], err error) {
defer observe("list", "observation_snapshot")(&err)
return s.inner.ListAllSnapshots()
}
func (s *instrumentedStorage) ListAllUsers() (ret happydns.Iterator[happydns.User], err error) {
defer observe("list", "user")(&err)
return s.inner.ListAllUsers()
}
func (s *instrumentedStorage) ListAllZones() (ret happydns.Iterator[happydns.ZoneMessage], err error) {
defer observe("list", "zone")(&err)
return s.inner.ListAllZones()
}
func (s *instrumentedStorage) ListAuthUserSessions(user *happydns.UserAuth) (ret []*happydns.Session, err error) {
defer observe("list", "session")(&err)
return s.inner.ListAuthUserSessions(user)
}
func (s *instrumentedStorage) ListCheckPlansByChecker(checkerID string) (ret []*happydns.CheckPlan, err error) {
defer observe("list", "check_plan")(&err)
return s.inner.ListCheckPlansByChecker(checkerID)
}
func (s *instrumentedStorage) ListCheckPlansByTarget(target happydns.CheckTarget) (ret []*happydns.CheckPlan, err error) {
defer observe("list", "check_plan")(&err)
return s.inner.ListCheckPlansByTarget(target)
}
func (s *instrumentedStorage) ListCheckPlansByUser(userId happydns.Identifier) (ret []*happydns.CheckPlan, err error) {
defer observe("list", "check_plan")(&err)
return s.inner.ListCheckPlansByUser(userId)
}
func (s *instrumentedStorage) ListCheckerConfiguration(checkerName string) (ret []*happydns.CheckerOptionsPositional, err error) {
defer observe("list", "check_config")(&err)
return s.inner.ListCheckerConfiguration(checkerName)
}
func (s *instrumentedStorage) ListDomainLogs(domain *happydns.Domain) (ret []*happydns.DomainLog, err error) {
defer observe("list", "domain_log")(&err)
return s.inner.ListDomainLogs(domain)
}
func (s *instrumentedStorage) ListDomains(user *happydns.User) (ret []*happydns.Domain, err error) {
defer observe("list", "domain")(&err)
return s.inner.ListDomains(user)
}
func (s *instrumentedStorage) ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) (ret []*happydns.CheckEvaluation, err error) {
defer observe("list", "check_evaluation")(&err)
return s.inner.ListEvaluationsByChecker(checkerID, target, limit)
}
func (s *instrumentedStorage) ListEvaluationsByPlan(planID happydns.Identifier) (ret []*happydns.CheckEvaluation, err error) {
defer observe("list", "check_evaluation")(&err)
return s.inner.ListEvaluationsByPlan(planID)
}
func (s *instrumentedStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int, filter func(*happydns.Execution) bool) (ret []*happydns.Execution, err error) {
defer observe("list", "execution")(&err)
return s.inner.ListExecutionsByChecker(checkerID, target, limit, filter)
}
func (s *instrumentedStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) (ret []*happydns.Execution, err error) {
defer observe("list", "execution")(&err)
return s.inner.ListExecutionsByDomain(domainId, limit, filter)
}
func (s *instrumentedStorage) ListExecutionsByPlan(planID happydns.Identifier) (ret []*happydns.Execution, err error) {
defer observe("list", "execution")(&err)
return s.inner.ListExecutionsByPlan(planID)
}
func (s *instrumentedStorage) ListExecutionsByUser(userId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) (ret []*happydns.Execution, err error) {
defer observe("list", "execution")(&err)
return s.inner.ListExecutionsByUser(userId, limit, filter)
}
func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.ProviderMessages, err error) {
defer observe("list", "provider")(&err)
return s.inner.ListProviders(user)
}
func (s *instrumentedStorage) ListUserSessions(userid happydns.Identifier) (ret []*happydns.Session, err error) {
defer observe("list", "session")(&err)
return s.inner.ListUserSessions(userid)
}
func (s *instrumentedStorage) MigrateSchema() error { return s.inner.MigrateSchema() }
func (s *instrumentedStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) (err error) {
defer observe("put", "observation_cache")(&err)
return s.inner.PutCachedObservation(target, key, entry)
}
func (s *instrumentedStorage) SchemaVersion() int { return s.inner.SchemaVersion() }
func (s *instrumentedStorage) SetLastSchedulerRun(t time.Time) (err error) {
defer observe("set", "scheduler_state")(&err)
return s.inner.SetLastSchedulerRun(t)
}
func (s *instrumentedStorage) TidyCheckPlanIndexes() (err error) {
defer observe("tidy", "check_plan")(&err)
return s.inner.TidyCheckPlanIndexes()
}
func (s *instrumentedStorage) TidyEvaluationIndexes() (err error) {
defer observe("tidy", "check_evaluation")(&err)
return s.inner.TidyEvaluationIndexes()
}
func (s *instrumentedStorage) TidyExecutionIndexes() (err error) {
defer observe("tidy", "execution")(&err)
return s.inner.TidyExecutionIndexes()
}
func (s *instrumentedStorage) UpdateAuthUser(user *happydns.UserAuth) (err error) {
defer observe("update", "authuser")(&err)
return s.inner.UpdateAuthUser(user)
}
func (s *instrumentedStorage) UpdateCheckPlan(plan *happydns.CheckPlan) (err error) {
defer observe("update", "check_plan")(&err)
return s.inner.UpdateCheckPlan(plan)
}
func (s *instrumentedStorage) UpdateCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier, opts happydns.CheckerOptions) (err error) {
defer observe("update", "check_config")(&err)
return s.inner.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, opts)
}
func (s *instrumentedStorage) UpdateDomain(domain *happydns.Domain) (err error) {
defer observe("update", "domain")(&err)
return s.inner.UpdateDomain(domain)
}
func (s *instrumentedStorage) UpdateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
defer observe("update", "domain_log")(&err)
return s.inner.UpdateDomainLog(domain, log)
}
func (s *instrumentedStorage) UpdateExecution(exec *happydns.Execution) (err error) {
defer observe("update", "execution")(&err)
return s.inner.UpdateExecution(exec)
}
func (s *instrumentedStorage) UpdateProvider(prvd *happydns.Provider) (err error) {
defer observe("update", "provider")(&err)
return s.inner.UpdateProvider(prvd)
}
func (s *instrumentedStorage) UpdateSession(session *happydns.Session) (err error) {
defer observe("update", "session")(&err)
return s.inner.UpdateSession(session)
}
func (s *instrumentedStorage) UpdateZone(zone *happydns.Zone) (err error) {
defer observe("update", "zone")(&err)
return s.inner.UpdateZone(zone)
}

238
internal/app/plugins.go Normal file
View file

@ -0,0 +1,238 @@
// 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/>.
//go:build linux || darwin || freebsd
package app
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"plugin"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/internal/checker"
)
// pluginSymbols is the minimal subset of *plugin.Plugin used by the loaders.
// It exists so that loaders can be unit-tested with a fake instead of
// requiring a real .so file built via `go build -buildmode=plugin`.
type pluginSymbols interface {
Lookup(symName string) (plugin.Symbol, error)
}
// pluginLoader attempts to find and register one specific kind of plugin
// symbol from an already-opened .so file.
//
// It returns (true, nil) when the symbol was found and registration
// succeeded, (true, err) when the symbol was found but something went wrong,
// and (false, nil) when the symbol simply isn't present in that file (which
// is not considered an error: a single .so may implement only a subset of
// the known plugin types).
type pluginLoader func(p pluginSymbols, fname string) (found bool, err error)
// safeCall invokes fn while recovering from any panic raised by plugin code.
// A panicking factory must not take the whole server down at startup; the
// recovered value is converted to an error so the caller can log/skip the
// offending plugin like any other failure.
func safeCall(symbol string, fname string, fn func() error) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("plugin %q panicked in %s: %v", fname, symbol, r)
}
}()
return fn()
}
// pluginLoaders is the authoritative list of plugin types that happyDomain
// knows about. To support a new plugin type, add a single entry here.
var pluginLoaders = []pluginLoader{
loadCheckerPlugin,
}
// loadCheckerPlugin handles the NewCheckerPlugin symbol exported by checkers
// built against checker-sdk-go (see ../../checker-dummy/README.md).
func loadCheckerPlugin(p pluginSymbols, fname string) (bool, error) {
sym, err := p.Lookup("NewCheckerPlugin")
if err != nil {
// Symbol not present in this .so, not an error.
return false, nil
}
factory, ok := sym.(func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error))
if !ok {
return true, fmt.Errorf("symbol NewCheckerPlugin has unexpected type %T", sym)
}
var (
def *sdk.CheckerDefinition
provider sdk.ObservationProvider
)
if err := safeCall("NewCheckerPlugin", fname, func() error {
var ferr error
def, provider, ferr = factory()
return ferr
}); err != nil {
return true, err
}
if def == nil {
return true, fmt.Errorf("NewCheckerPlugin returned a nil CheckerDefinition")
}
if provider == nil {
return true, fmt.Errorf("NewCheckerPlugin returned a nil ObservationProvider")
}
checker.RegisterObservationProvider(provider)
checker.RegisterExternalizableChecker(def)
log.Printf("Plugin %s (%s) loaded", def.ID, fname)
return true, nil
}
// checkPluginDirectoryPermissions refuses to load plugins from a directory
// that any non-owner can write to. Loading a .so file is arbitrary code
// execution as the happyDomain process, so a world- or group-writable
// plugin directory is treated as a fatal misconfiguration: any local user
// (or any process sharing the group) able to drop a file there could take
// over the server. Operators who genuinely need shared deployment should
// stage plugins elsewhere and rsync them into a directory owned and
// writable only by the happyDomain user.
func checkPluginDirectoryPermissions(directory string) error {
// Use Lstat to detect symlinks: a symlink could be silently redirected
// to an attacker-controlled directory, bypassing the permission check
// on the original path.
linfo, err := os.Lstat(directory)
if err != nil {
return fmt.Errorf("unable to stat plugins directory %q: %s", directory, err)
}
if linfo.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("plugins directory %q is a symbolic link; refusing to follow it", directory)
}
if !linfo.IsDir() {
return fmt.Errorf("plugins path %q is not a directory", directory)
}
mode := linfo.Mode().Perm()
if mode&0o022 != 0 {
return fmt.Errorf("plugins directory %q is group- or world-writable (mode %#o); refusing to load plugins from it", directory, mode)
}
return nil
}
// checkPluginFilePermissions refuses to load a .so file that is group- or
// world-writable. Even inside a properly locked-down directory, a writable
// plugin binary could be replaced by a malicious actor sharing the group.
// Symlinks are followed: the permission check applies to the resolved target,
// which allows the common pattern of symlinking to versioned binaries
// (e.g. checker-foo.so -> checker-foo-v1.2.so) for atomic upgrades.
// The directory-level symlink ban already prevents attackers from redirecting
// the scan root itself.
func checkPluginFilePermissions(path string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("unable to stat plugin file %q: %s", path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("plugin %q is not a regular file (or resolves to a non-regular file)", path)
}
mode := info.Mode().Perm()
if mode&0o022 != 0 {
return fmt.Errorf("plugin file %q is group- or world-writable (mode %#o)", path, mode)
}
return nil
}
// initPlugins scans each directory listed in cfg.PluginsDirectories and loads
// every .so file found as a Go plugin. A directory that cannot be read is a
// fatal configuration error; individual plugin failures are logged and
// skipped so that one bad .so does not prevent the others from loading.
func (a *App) initPlugins() error {
for _, directory := range a.cfg.PluginsDirectories {
if err := checkPluginDirectoryPermissions(directory); err != nil {
return err
}
files, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("unable to read plugins directory %q: %s", directory, err)
}
for _, file := range files {
if file.IsDir() {
continue
}
// Only attempt to load shared-object files.
if filepath.Ext(file.Name()) != ".so" {
continue
}
fname := filepath.Join(directory, file.Name())
if err := checkPluginFilePermissions(fname); err != nil {
log.Printf("Skipping plugin %q: %s", fname, err)
continue
}
if err := loadPlugin(fname); err != nil {
log.Printf("Unable to load plugin %q: %s", fname, err)
}
}
}
return nil
}
// loadPlugin opens the .so file at fname and runs every registered
// pluginLoader against it. A loader that does not find its symbol is silently
// skipped. If no loader recognises any symbol in the file a warning is
// logged, because the file might be a valid plugin for a future version of
// happyDomain. Loader errors for one plugin kind do not prevent the other
// kinds in the same .so from being attempted: a single .so is allowed to
// expose more than one plugin type, and a failure to register (e.g.) the
// service half should not silently drop the checker half. All loader errors
// encountered are joined and returned together.
func loadPlugin(fname string) error {
p, err := plugin.Open(fname)
if err != nil {
return err
}
var (
anyFound bool
errs []error
)
for _, loader := range pluginLoaders {
found, err := loader(p, fname)
if found {
anyFound = true
}
if err != nil {
errs = append(errs, err)
}
}
if !anyFound && len(errs) == 0 {
log.Printf("Warning: plugin %q exports no recognised symbols", fname)
}
return errors.Join(errs...)
}

View file

@ -0,0 +1,143 @@
// 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/>.
//go:build linux || darwin || freebsd
package app
import (
"context"
"errors"
"plugin"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// dummyCheckerProvider is a minimal ObservationProvider used by the tests
// below. It is intentionally trivial: the loader tests only care that
// registration succeeds, not what the provider actually collects.
type dummyCheckerProvider struct {
key happydns.ObservationKey
}
func (d *dummyCheckerProvider) Key() happydns.ObservationKey { return d.key }
func (d *dummyCheckerProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
return nil, nil
}
func newDummyCheckerFactory(id string) func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
return func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
def := &sdk.CheckerDefinition{
ID: id,
Name: "Dummy checker",
}
return def, &dummyCheckerProvider{key: happydns.ObservationKey("dummy-" + id)}, nil
}
}
func TestLoadCheckerPlugin_SymbolMissing(t *testing.T) {
found, err := loadCheckerPlugin(&fakeSymbols{}, "missing.so")
if found || err != nil {
t.Fatalf("expected (false, nil) when symbol is absent, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_WrongSymbolType(t *testing.T) {
fs := &fakeSymbols{syms: map[string]plugin.Symbol{
"NewCheckerPlugin": 42, // not a function
}}
found, err := loadCheckerPlugin(fs, "wrongtype.so")
if !found || err == nil || !strings.Contains(err.Error(), "unexpected type") {
t.Fatalf("expected wrong-type error, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_FactoryError(t *testing.T) {
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
return nil, nil, errors.New("boom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "factoryerr.so")
if !found || err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected factory error to propagate, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_NilDefinition(t *testing.T) {
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
return nil, &dummyCheckerProvider{key: "k"}, nil
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "nildef.so")
if !found || err == nil || !strings.Contains(err.Error(), "nil CheckerDefinition") {
t.Fatalf("expected nil-definition error, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_NilProvider(t *testing.T) {
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
return &sdk.CheckerDefinition{ID: "x"}, nil, nil
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "nilprov.so")
if !found || err == nil || !strings.Contains(err.Error(), "nil ObservationProvider") {
t.Fatalf("expected nil-provider error, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_FactoryPanics(t *testing.T) {
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
panic("kaboom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "panic.so")
if !found || err == nil {
t.Fatalf("expected panic to be converted to error, got (%v, %v)", found, err)
}
if !strings.Contains(err.Error(), "panicked") || !strings.Contains(err.Error(), "kaboom") {
t.Errorf("expected wrapped panic error, got %v", err)
}
}
func TestLoadCheckerPlugin_Success(t *testing.T) {
factory := newDummyCheckerFactory("dummy-success")
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "first.so")
if !found || err != nil {
t.Fatalf("expected success, got (%v, %v)", found, err)
}
if got := checker.FindChecker("dummy-success"); got == nil {
t.Errorf("expected checker %q to be registered", "dummy-success")
}
if got := sdk.FindObservationProvider(happydns.ObservationKey("dummy-dummy-success")); got == nil {
t.Errorf("expected observation provider %q to be registered", "dummy-dummy-success")
}
}

View file

@ -0,0 +1,37 @@
// 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/>.
//go:build !linux && !darwin && !freebsd
package app
import "log"
// initPlugins is a no-op on platforms where Go's plugin package is not
// supported (Windows, plan9, …). If the operator configured plugin
// directories anyway we log a clear warning rather than silently ignoring
// them, so the misconfiguration is visible at startup.
func (a *App) initPlugins() error {
if len(a.cfg.PluginsDirectories) > 0 {
log.Printf("Warning: plugin loading is not supported on this platform; ignoring %d configured plugin directories", len(a.cfg.PluginsDirectories))
}
return nil
}

View file

@ -0,0 +1,172 @@
// 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/>.
//go:build linux || darwin || freebsd
package app
import (
"fmt"
"os"
"path/filepath"
"plugin"
"testing"
)
// fakeSymbols is a pluginSymbols implementation backed by a static map. It
// lets the loader tests exercise their behaviour without having to compile a
// real .so file via `go build -buildmode=plugin`.
type fakeSymbols struct {
syms map[string]plugin.Symbol
}
func (f *fakeSymbols) Lookup(name string) (plugin.Symbol, error) {
if s, ok := f.syms[name]; ok {
return s, nil
}
return nil, fmt.Errorf("symbol %q not found", name)
}
// TestLoadPlugin_NoRecognisedSymbols verifies that when a .so file exports
// none of the known plugin symbols, every loader returns (false, nil), i.e.
// the file is silently skipped rather than reported as an error. loadPlugin
// itself logs a warning in that situation; we exercise the inner loop here
// because the outer call requires plugin.Open and a real .so file.
func TestLoadPlugin_NoRecognisedSymbols(t *testing.T) {
fs := &fakeSymbols{}
for _, loader := range pluginLoaders {
found, err := loader(fs, "empty.so")
if found || err != nil {
t.Fatalf("loader returned (%v, %v) for empty symbol set, expected (false, nil)", found, err)
}
}
}
func TestCheckPluginDirectoryPermissions(t *testing.T) {
dir := t.TempDir()
// A freshly-created TempDir is owner-only on every platform we run on,
// so this must be accepted.
if err := os.Chmod(dir, 0o750); err != nil {
t.Fatalf("chmod 0750: %v", err)
}
if err := checkPluginDirectoryPermissions(dir); err != nil {
t.Errorf("expected 0750 directory to be accepted, got %v", err)
}
// World-writable: must be refused.
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatalf("chmod 0777: %v", err)
}
if err := checkPluginDirectoryPermissions(dir); err == nil {
t.Errorf("expected 0777 directory to be refused")
}
// Group-writable: must also be refused.
if err := os.Chmod(dir, 0o770); err != nil {
t.Fatalf("chmod 0770: %v", err)
}
if err := checkPluginDirectoryPermissions(dir); err == nil {
t.Errorf("expected 0770 directory to be refused")
}
// Restore permissions so t.TempDir cleanup can remove the directory.
_ = os.Chmod(dir, 0o700)
// Non-existent path: must be refused.
if err := checkPluginDirectoryPermissions(filepath.Join(dir, "does-not-exist")); err == nil {
t.Errorf("expected missing directory to be refused")
}
// Symlink to a valid directory: must be refused.
target := t.TempDir()
link := filepath.Join(dir, "symlink-plugins")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
if err := checkPluginDirectoryPermissions(link); err == nil {
t.Errorf("expected symlink directory to be refused")
}
}
func TestCheckPluginFilePermissions(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "test.so")
if err := os.WriteFile(f, []byte("fake"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
// Owner-writable, not group/world-writable: accepted.
if err := checkPluginFilePermissions(f); err != nil {
t.Errorf("expected 0644 file to be accepted, got %v", err)
}
// Group-writable: refused.
if err := os.Chmod(f, 0o664); err != nil {
t.Fatalf("chmod: %v", err)
}
if err := checkPluginFilePermissions(f); err == nil {
t.Errorf("expected 0664 file to be refused")
}
// World-writable: refused.
if err := os.Chmod(f, 0o646); err != nil {
t.Fatalf("chmod: %v", err)
}
if err := checkPluginFilePermissions(f); err == nil {
t.Errorf("expected 0646 file to be refused")
}
// Non-existent: refused.
if err := checkPluginFilePermissions(filepath.Join(dir, "nope.so")); err == nil {
t.Errorf("expected missing file to be refused")
}
// Symlink to a safe regular file: accepted (we follow the link and
// check the target's permissions, not the link itself).
regular := filepath.Join(dir, "real.so")
if err := os.WriteFile(regular, []byte("real"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
link := filepath.Join(dir, "link.so")
if err := os.Symlink(regular, link); err != nil {
t.Fatalf("symlink: %v", err)
}
if err := checkPluginFilePermissions(link); err != nil {
t.Errorf("expected symlink to safe file to be accepted, got %v", err)
}
// Symlink to a writable target: refused.
writable := filepath.Join(dir, "writable.so")
if err := os.WriteFile(writable, []byte("bad"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Chmod(writable, 0o666); err != nil {
t.Fatalf("chmod: %v", err)
}
linkBad := filepath.Join(dir, "link-bad.so")
if err := os.Symlink(writable, linkBad); err != nil {
t.Fatalf("symlink: %v", err)
}
if err := checkPluginFilePermissions(linkBad); err == nil {
t.Errorf("expected symlink to writable file to be refused")
}
}

View file

@ -0,0 +1,51 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 (
"strings"
"git.happydns.org/happyDomain/model"
)
// WorstStatusAggregator aggregates check states by taking the worst status.
type WorstStatusAggregator struct{}
func (a WorstStatusAggregator) Aggregate(states []happydns.CheckState) happydns.CheckState {
if len(states) == 0 {
return happydns.CheckState{Status: happydns.StatusUnknown}
}
worst := states[0].Status
var messages []string
for _, s := range states {
if s.Status > worst {
worst = s.Status
}
if s.Message != "" {
messages = append(messages, s.Message)
}
}
return happydns.CheckState{
Status: worst,
Message: strings.Join(messages, "; "),
}
}

View file

@ -0,0 +1,117 @@
// 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 (
"testing"
"git.happydns.org/happyDomain/model"
)
func TestWorstStatusAggregator_Empty(t *testing.T) {
agg := WorstStatusAggregator{}
got := agg.Aggregate(nil)
if got.Status != happydns.StatusUnknown {
t.Errorf("Aggregate(nil) status = %v, want StatusUnknown", got.Status)
}
if got.Message != "" {
t.Errorf("Aggregate(nil) message = %q, want empty", got.Message)
}
}
func TestWorstStatusAggregator_Single(t *testing.T) {
agg := WorstStatusAggregator{}
got := agg.Aggregate([]happydns.CheckState{
{Status: happydns.StatusOK, Message: "all good"},
})
if got.Status != happydns.StatusOK {
t.Errorf("status = %v, want StatusOK", got.Status)
}
if got.Message != "all good" {
t.Errorf("message = %q, want %q", got.Message, "all good")
}
}
func TestWorstStatusAggregator_PicksWorst(t *testing.T) {
agg := WorstStatusAggregator{}
tests := []struct {
name string
states []happydns.CheckState
wantStat happydns.Status
}{
{
name: "ok and warn",
states: []happydns.CheckState{
{Status: happydns.StatusOK},
{Status: happydns.StatusWarn},
},
wantStat: happydns.StatusWarn,
},
{
name: "crit among ok and warn",
states: []happydns.CheckState{
{Status: happydns.StatusOK},
{Status: happydns.StatusCrit},
{Status: happydns.StatusWarn},
},
wantStat: happydns.StatusCrit,
},
{
name: "error is worst",
states: []happydns.CheckState{
{Status: happydns.StatusCrit},
{Status: happydns.StatusError},
{Status: happydns.StatusOK},
},
wantStat: happydns.StatusError,
},
{
name: "info and ok",
states: []happydns.CheckState{
{Status: happydns.StatusInfo},
{Status: happydns.StatusOK},
},
wantStat: happydns.StatusInfo,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := agg.Aggregate(tt.states)
if got.Status != tt.wantStat {
t.Errorf("status = %v, want %v", got.Status, tt.wantStat)
}
})
}
}
func TestWorstStatusAggregator_ConcatenatesMessages(t *testing.T) {
agg := WorstStatusAggregator{}
got := agg.Aggregate([]happydns.CheckState{
{Status: happydns.StatusOK, Message: "check A passed"},
{Status: happydns.StatusWarn, Message: ""},
{Status: happydns.StatusCrit, Message: "check C failed"},
})
want := "check A passed; check C failed"
if got.Message != want {
t.Errorf("message = %q, want %q", got.Message, want)
}
}

View file

@ -0,0 +1,323 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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/>.
// observation.go implements the observation subsystem, which is the data
// collection layer for the checker framework. An observation represents a
// piece of raw data gathered about a check target (e.g. DNS records, HTTP
// headers, TLS certificate details). Observations are identified by an
// ObservationKey and collected on demand by registered ObservationProviders.
//
// The ObservationContext provides lazy-loading, cached, thread-safe access to
// observations: the first checker that requests a given observation triggers
// its collection, and subsequent checkers reuse the cached result. This
// design decouples data collection from evaluation: checkers declare which
// observations they need, and the context ensures each is collected at most
// once per check run. Observations can also be persisted as snapshots and
// reused across runs when freshness requirements allow.
//
// Observation providers may optionally implement reporting interfaces
// (CheckerHTMLReporter, CheckerMetricsReporter) to produce human-readable
// reports or extract time-series metrics from collected data.
package checker
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/model"
)
// 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.
//
// Concurrency model: the outer mu protects only the cache/errors/inflight
// maps and is held for short critical sections. Provider collection runs
// *without* mu held, so two calls to Get for *different* keys can collect
// concurrently. Two calls for the *same* key are deduplicated: the first
// installs an inflight channel, runs the collection, then closes the
// channel; the others wait on it and read the cached result afterwards.
type ObservationContext struct {
target happydns.CheckTarget
opts happydns.CheckerOptions
cache map[happydns.ObservationKey]json.RawMessage
errors map[happydns.ObservationKey]error
inflight map[happydns.ObservationKey]chan struct{}
mu sync.Mutex
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.
// 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]json.RawMessage),
errors: make(map[happydns.ObservationKey]error),
inflight: make(map[happydns.ObservationKey]chan struct{}),
cacheLookup: cacheLookup,
freshness: freshness,
}
}
// 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) {
oc.mu.Lock()
defer oc.mu.Unlock()
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.
// Safe to call without holding oc.mu - it acquires the lock internally.
func (oc *ObservationContext) getProvider(key happydns.ObservationKey) happydns.ObservationProvider {
oc.mu.Lock()
override := oc.providerOverride
oc.mu.Unlock()
if override != nil {
if p, ok := override[key]; ok {
return p
}
}
return sdk.FindObservationProvider(key)
}
// Get collects observation data for the given key (lazily) and unmarshals it into dest.
// Thread-safe: concurrent calls for the same key are deduplicated; concurrent
// calls for different keys collect in parallel.
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
for {
oc.mu.Lock()
if raw, ok := oc.cache[key]; ok {
oc.mu.Unlock()
return json.Unmarshal(raw, dest)
}
if err, ok := oc.errors[key]; ok {
oc.mu.Unlock()
return err
}
if ch, ok := oc.inflight[key]; ok {
// Another goroutine is already collecting this key. Release
// the lock, wait for it to finish, then re-check the cache.
oc.mu.Unlock()
select {
case <-ch:
case <-ctx.Done():
return ctx.Err()
}
continue
}
// We are the leader for this key. Install the inflight channel
// before releasing the lock so concurrent callers wait on us.
ch := make(chan struct{})
oc.inflight[key] = ch
oc.mu.Unlock()
raw, collectErr := oc.collect(ctx, key)
// Collection errors are cached for the lifetime of this
// ObservationContext (i.e. a single execution run). This is
// intentional: within one run the same transient failure would
// keep recurring, and retrying would slow down the pipeline.
// A new execution creates a fresh context, giving the provider
// another chance.
oc.mu.Lock()
if collectErr != nil {
oc.errors[key] = collectErr
} else {
oc.cache[key] = raw
}
delete(oc.inflight, key)
close(ch)
oc.mu.Unlock()
if collectErr != nil {
return collectErr
}
return json.Unmarshal(raw, dest)
}
}
// collect runs the DB-cache lookup and provider collection for a single key
// without holding oc.mu, so collections for different keys can run in
// parallel. Callers are responsible for installing the result into the cache
// or errors map and signalling waiters.
func (oc *ObservationContext) collect(ctx context.Context, key happydns.ObservationKey) (json.RawMessage, error) {
if oc.cacheLookup != nil && oc.freshness > 0 {
if raw, collectedAt, err := oc.cacheLookup(oc.target, key); err == nil {
if time.Since(collectedAt) < oc.freshness {
return raw, nil
}
}
}
provider := oc.getProvider(key)
if provider == nil {
return nil, fmt.Errorf("no observation provider registered for key %q", key)
}
val, err := provider.Collect(ctx, oc.opts)
if err != nil {
return nil, err
}
raw, err := json.Marshal(val)
if err != nil {
return nil, fmt.Errorf("observation %q: marshal failed: %w", key, err)
}
return json.RawMessage(raw), nil
}
// Data returns all cached observation data as pre-serialized JSON.
func (oc *ObservationContext) Data() map[happydns.ObservationKey]json.RawMessage {
oc.mu.Lock()
defer oc.mu.Unlock()
data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache))
for k, v := range oc.cache {
data[k] = v
}
return data
}
// Provider registration is startup-only (see comments on the registries in
// internal/service/registry.go and internal/provider/registry.go), so the
// "any provider implements X reporter" question has a fixed answer for the
// process lifetime. We compute it once on first call and cache it.
var (
htmlReporterOnce sync.Once
htmlReporterCached bool
metricsReporterOnce sync.Once
metricsReporterCached bool
)
// HasHTMLReporter returns true if any registered observation provider implements CheckerHTMLReporter.
func HasHTMLReporter() bool {
htmlReporterOnce.Do(func() {
for _, p := range sdk.GetObservationProviders() {
if _, ok := p.(happydns.CheckerHTMLReporter); ok {
htmlReporterCached = true
return
}
}
})
return htmlReporterCached
}
// GetHTMLReport renders an HTML report for the given observation key and raw JSON data.
// Returns (html, true, nil) if the provider supports HTML reports, or ("", false, nil) if not.
func GetHTMLReport(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
return getHTMLReport(sdk.FindObservationProvider(key), key, raw)
}
// GetHTMLReportCtx is like GetHTMLReport but resolves the provider through
// the ObservationContext, respecting per-context overrides.
func (oc *ObservationContext) GetHTMLReportCtx(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
return getHTMLReport(oc.getProvider(key), key, raw)
}
func getHTMLReport(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
if provider == nil {
return "", false, fmt.Errorf("no observation provider registered for key %q", key)
}
hr, ok := provider.(happydns.CheckerHTMLReporter)
if !ok {
return "", false, nil
}
html, err := hr.GetHTMLReport(raw)
return html, true, err
}
// HasMetricsReporter returns true if any registered observation provider implements CheckerMetricsReporter.
func HasMetricsReporter() bool {
metricsReporterOnce.Do(func() {
for _, p := range sdk.GetObservationProviders() {
if _, ok := p.(happydns.CheckerMetricsReporter); ok {
metricsReporterCached = true
return
}
}
})
return metricsReporterCached
}
// GetMetrics extracts metrics for the given observation key and raw JSON data.
// Returns (metrics, true, nil) if the provider supports metrics, or (nil, false, nil) if not.
func GetMetrics(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
return getMetrics(sdk.FindObservationProvider(key), key, raw, collectedAt)
}
// GetMetricsCtx is like GetMetrics but resolves the provider through
// the ObservationContext, respecting per-context overrides.
func (oc *ObservationContext) GetMetricsCtx(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
return getMetrics(oc.getProvider(key), key, raw, collectedAt)
}
func getMetrics(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
if provider == nil {
return nil, false, fmt.Errorf("no observation provider registered for key %q", key)
}
mr, ok := provider.(happydns.CheckerMetricsReporter)
if !ok {
return nil, false, nil
}
metrics, err := mr.ExtractMetrics(raw, collectedAt)
return metrics, true, err
}
// GetAllMetrics extracts metrics from all observation keys in a snapshot.
func GetAllMetrics(snap *happydns.ObservationSnapshot) ([]happydns.CheckMetric, error) {
var allMetrics []happydns.CheckMetric
var errs []error
for key, raw := range snap.Data {
metrics, supported, err := GetMetrics(key, raw, snap.CollectedAt)
if err != nil {
errs = append(errs, fmt.Errorf("observation %q: %w", key, err))
continue
}
if !supported {
continue
}
allMetrics = append(allMetrics, metrics...)
}
return allMetrics, errors.Join(errs...)
}

View file

@ -0,0 +1,168 @@
// 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 (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"git.happydns.org/happyDomain/model"
)
// blockingProvider is an ObservationProvider whose Collect blocks on the
// release channel until the test signals it. It records how many concurrent
// Collect calls are in flight at any moment.
type blockingProvider struct {
key happydns.ObservationKey
release chan struct{}
calls int32
}
func (b *blockingProvider) Key() happydns.ObservationKey { return b.key }
func (b *blockingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
atomic.AddInt32(&b.calls, 1)
defer atomic.AddInt32(&b.calls, -1)
select {
case <-b.release:
return map[string]string{string(b.key): "ok"}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// TestObservationContext_ConcurrentDifferentKeys verifies that two Get calls
// for distinct observation keys can run their Collect concurrently, i.e.
// the per-context lock is not held across provider.Collect.
func TestObservationContext_ConcurrentDifferentKeys(t *testing.T) {
release := make(chan struct{})
defer close(release)
pa := &blockingProvider{key: happydns.ObservationKey("test-a"), release: release}
pb := &blockingProvider{key: happydns.ObservationKey("test-b"), release: release}
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
oc.SetProviderOverride(pa.key, pa)
oc.SetProviderOverride(pb.key, pb)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var wg sync.WaitGroup
results := make([]error, 2)
for i, key := range []happydns.ObservationKey{pa.key, pb.key} {
wg.Add(1)
go func(idx int, k happydns.ObservationKey) {
defer wg.Done()
var dst map[string]string
results[idx] = oc.Get(ctx, k, &dst)
}(i, key)
}
// Wait until both providers are blocked inside Collect simultaneously.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&pa.calls) == 1 && atomic.LoadInt32(&pb.calls) == 1 {
break
}
time.Sleep(5 * time.Millisecond)
}
if a, b := atomic.LoadInt32(&pa.calls), atomic.LoadInt32(&pb.calls); a != 1 || b != 1 {
t.Fatalf("expected both providers to be collecting in parallel, got a=%d b=%d", a, b)
}
// Release both Collects and wait for the Get calls to return.
release <- struct{}{}
release <- struct{}{}
wg.Wait()
for i, err := range results {
if err != nil {
t.Errorf("Get %d returned error: %v", i, err)
}
}
}
// TestObservationContext_DedupesSameKey verifies that concurrent Get calls
// for the *same* key only invoke provider.Collect once.
func TestObservationContext_DedupesSameKey(t *testing.T) {
release := make(chan struct{})
var collectCount int32
prov := &countingProvider{
key: happydns.ObservationKey("test-dedup"),
release: release,
count: &collectCount,
}
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
oc.SetProviderOverride(prov.key, prov)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
const N = 8
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
var dst map[string]string
if err := oc.Get(ctx, prov.key, &dst); err != nil {
t.Errorf("Get error: %v", err)
}
}()
}
// Wait for at least one collect to be in flight, then release it.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && atomic.LoadInt32(&collectCount) == 0 {
time.Sleep(5 * time.Millisecond)
}
close(release)
wg.Wait()
if got := atomic.LoadInt32(&collectCount); got != 1 {
t.Errorf("expected exactly 1 Collect call, got %d", got)
}
}
type countingProvider struct {
key happydns.ObservationKey
release chan struct{}
count *int32
}
func (c *countingProvider) Key() happydns.ObservationKey { return c.key }
func (c *countingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
atomic.AddInt32(c.count, 1)
select {
case <-c.release:
return map[string]string{"k": "v"}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}

View file

@ -0,0 +1,120 @@
// 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"
"time"
"git.happydns.org/happyDomain/model"
)
// httpClient is a shared client with a sensible timeout for remote checker
// endpoints. The per-request context can shorten this further.
var httpClient = &http.Client{
Timeout: 30 * time.Second,
}
// maxErrorBodySize is the maximum number of bytes read from an error response
// body to include in the error message.
const maxErrorBodySize = 4096
// maxResponseBodySize is the maximum number of bytes read from a successful
// response body. This prevents a misbehaving endpoint from causing OOM.
const maxResponseBodySize = 10 << 20 // 10 MiB
// 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 := httpClient.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(io.LimitReader(resp.Body, maxErrorBodySize))
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(io.LimitReader(resp.Body, maxResponseBodySize)).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

@ -0,0 +1,240 @@
// 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 (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.happydns.org/happyDomain/model"
)
func TestHTTPObservationProvider_Key(t *testing.T) {
p := NewHTTPObservationProvider("my_key", "http://example.com")
if got := p.Key(); got != "my_key" {
t.Errorf("Key() = %q, want %q", got, "my_key")
}
}
func TestHTTPObservationProvider_TrailingSlashTrimmed(t *testing.T) {
p := NewHTTPObservationProvider("k", "http://example.com/")
if p.endpoint != "http://example.com" {
t.Errorf("endpoint = %q, want trailing slash trimmed", p.endpoint)
}
}
func TestHTTPObservationProvider_CollectSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/collect" {
t.Errorf("expected /collect, got %s", r.URL.Path)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", ct)
}
// Verify request body is well-formed.
var req happydns.ExternalCollectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
if req.Key != "test_obs" {
t.Errorf("request Key = %q, want %q", req.Key, "test_obs")
}
if v, ok := req.Options["foo"]; !ok || v != "bar" {
t.Errorf("request Options[foo] = %v, want %q", v, "bar")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
Data: json.RawMessage(`{"value":42}`),
})
}))
defer srv.Close()
p := NewHTTPObservationProvider("test_obs", srv.URL)
opts := happydns.CheckerOptions{"foo": "bar"}
result, err := p.Collect(context.Background(), opts)
if err != nil {
t.Fatalf("Collect() returned error: %v", err)
}
raw, ok := result.(json.RawMessage)
if !ok {
t.Fatalf("expected json.RawMessage, got %T", result)
}
var data map[string]int
if err := json.Unmarshal(raw, &data); err != nil {
t.Fatalf("failed to unmarshal result: %v", err)
}
if data["value"] != 42 {
t.Errorf("value = %d, want 42", data["value"])
}
}
func TestHTTPObservationProvider_CollectRemoteError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
Error: "something went wrong",
})
}))
defer srv.Close()
p := NewHTTPObservationProvider("k", srv.URL)
_, err := p.Collect(context.Background(), nil)
if err == nil {
t.Fatal("expected error for remote error response")
}
if !strings.Contains(err.Error(), "something went wrong") {
t.Errorf("error = %q, want it to contain remote error message", err)
}
}
func TestHTTPObservationProvider_CollectEmptyData(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{})
}))
defer srv.Close()
p := NewHTTPObservationProvider("k", srv.URL)
_, err := p.Collect(context.Background(), nil)
if err == nil {
t.Fatal("expected error for empty data response")
}
if !strings.Contains(err.Error(), "empty data") {
t.Errorf("error = %q, want it to mention empty data", err)
}
}
func TestHTTPObservationProvider_CollectNon200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal failure", http.StatusInternalServerError)
}))
defer srv.Close()
p := NewHTTPObservationProvider("k", srv.URL)
_, err := p.Collect(context.Background(), nil)
if err == nil {
t.Fatal("expected error for non-200 status")
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("error = %q, want it to contain status code 500", err)
}
if !strings.Contains(err.Error(), "internal failure") {
t.Errorf("error = %q, want it to contain response body excerpt", err)
}
}
func TestHTTPObservationProvider_CollectInvalidJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, "not json")
}))
defer srv.Close()
p := NewHTTPObservationProvider("k", srv.URL)
_, err := p.Collect(context.Background(), nil)
if err == nil {
t.Fatal("expected error for invalid JSON response")
}
if !strings.Contains(err.Error(), "decode") {
t.Errorf("error = %q, want it to mention decode failure", err)
}
}
func TestHTTPObservationProvider_CollectContextCancelled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Block until the request context is cancelled.
<-r.Context().Done()
}))
defer srv.Close()
p := NewHTTPObservationProvider("k", srv.URL)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
_, err := p.Collect(ctx, nil)
if err == nil {
t.Fatal("expected error for cancelled context")
}
}
func TestHTTPObservationProvider_CollectConnectionRefused(t *testing.T) {
// Use a server that is immediately closed to simulate connection refused.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
endpoint := srv.URL
srv.Close()
p := NewHTTPObservationProvider("k", endpoint)
_, err := p.Collect(context.Background(), nil)
if err == nil {
t.Fatal("expected error for connection refused")
}
if !strings.Contains(err.Error(), "request failed") {
t.Errorf("error = %q, want it to mention request failure", err)
}
}
func TestHTTPObservationProvider_IntegrationWithObservationContext(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
Data: json.RawMessage(`{"temp":23.5}`),
})
}))
defer srv.Close()
key := happydns.ObservationKey("http_test_obs")
p := NewHTTPObservationProvider(key, srv.URL)
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
oc.SetProviderOverride(key, p)
var dest map[string]float64
if err := oc.Get(context.Background(), key, &dest); err != nil {
t.Fatalf("ObservationContext.Get() returned error: %v", err)
}
if dest["temp"] != 23.5 {
t.Errorf("temp = %v, want 23.5", dest["temp"])
}
// Second call should use the cached value, not hit the server again.
var dest2 map[string]float64
if err := oc.Get(context.Background(), key, &dest2); err != nil {
t.Fatalf("second Get() returned error: %v", err)
}
if dest2["temp"] != 23.5 {
t.Errorf("cached temp = %v, want 23.5", dest2["temp"])
}
}

View file

@ -0,0 +1,60 @@
// 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 (
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/model"
)
// The checker definition registry lives in the Apache-2.0 licensed
// checker-sdk-go module, so external plugins can register themselves
// without depending on AGPL code. These wrappers preserve the existing
// happyDomain call sites.
// RegisterChecker registers a checker definition globally.
func RegisterChecker(c *happydns.CheckerDefinition) {
sdk.RegisterChecker(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) {
sdk.RegisterExternalizableChecker(c)
}
// RegisterObservationProvider registers an observation provider globally.
func RegisterObservationProvider(p happydns.ObservationProvider) {
sdk.RegisterObservationProvider(p)
}
// GetCheckers returns all registered checker definitions.
func GetCheckers() map[string]*happydns.CheckerDefinition {
return sdk.GetCheckers()
}
// FindChecker returns the checker definition with the given ID, or nil.
func FindChecker(id string) *happydns.CheckerDefinition {
return sdk.FindChecker(id)
}

View file

@ -24,6 +24,8 @@ package config // import "git.happydns.org/happyDomain/config"
import (
"flag"
"fmt"
"runtime"
"time"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
@ -45,6 +47,12 @@ func declareFlags(o *happydns.Options) {
flag.Var(&JWTSecretKey{&o.JWTSecretKey}, "jwt-secret-key", "Secret key used to verify JWT authentication tokens (a random secret is used if undefined)")
flag.Var(&URL{&o.ExternalAuth}, "external-auth", "Base URL to use for login and registration (use embedded forms if left empty)")
flag.BoolVar(&o.OptOutInsights, "opt-out-insights", false, "Disable the anonymous usage statistics report. If you care about this project and don't participate in discussions, don't opt-out.")
flag.IntVar(&o.CheckerMaxConcurrency, "checker-max-concurrency", runtime.NumCPU(), "Maximum number of checker jobs that can run simultaneously")
flag.IntVar(&o.CheckerRetentionDays, "checker-retention-days", 365, "System-wide default retention horizon for check execution history (overridable per user)")
flag.DurationVar(&o.CheckerJanitorInterval, "checker-janitor-interval", 6*time.Hour, "How often the checker retention janitor runs")
flag.IntVar(&o.CheckerInactivityPauseDays, "checker-inactivity-pause-days", 90, "Pause checks for users that haven't logged in for this many days (0 disables, overridable per user)")
flag.IntVar(&o.CheckerMaxChecksPerDay, "checker-max-checks-per-day", 0, "System-wide default cap on scheduled checker executions per user per day; counter resets at 00:00 UTC and is in-memory only (0 = unlimited, overridable per user; see docs/checker-quotas.md)")
flag.BoolVar(&o.CheckerCountManualTriggers, "checker-count-manual-triggers", true, "When true (default), manual checker triggers count against UserQuota.MaxChecksPerDay and are refused with HTTP 429 once exhausted; when false, manual triggers bypass the quota entirely (see docs/checker-quotas.md)")
flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server")
flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user")
@ -60,6 +68,8 @@ func declareFlags(o *happydns.Options) {
flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (altcha, hcaptcha, recaptchav2, turnstile, or empty to disable)")
flag.IntVar(&o.CaptchaLoginThreshold, "captcha-login-threshold", 3, "Number of failed login attempts before captcha is required (0 = always require when provider configured)")
flag.Var(&stringSlice{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing checker plugins (.so files); may be repeated")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -25,8 +25,27 @@ import (
"encoding/base64"
"net/mail"
"net/url"
"strings"
)
// stringSlice is a flag.Value that accumulates string values across repeated
// invocations of the same flag (e.g. -plugins-directory a -plugins-directory b).
type stringSlice struct {
Values *[]string
}
func (s *stringSlice) String() string {
if s.Values == nil {
return ""
}
return strings.Join(*s.Values, ",")
}
func (s *stringSlice) Set(value string) error {
*s.Values = append(*s.Values, value)
return nil
}
type JWTSecretKey struct {
Secret *[]byte
}

View file

@ -90,7 +90,13 @@ func ValidateStructValues(data any) error {
}
v := reflect.Indirect(reflect.ValueOf(data))
if !v.IsValid() {
return nil
}
t := v.Type()
if t.Kind() != reflect.Struct {
return nil
}
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
@ -127,6 +133,87 @@ func ValidateStructValues(data any) error {
return nil
}
// ValidateMapValues validates a map[string]any against a slice of Field definitions.
// It checks required fields, choices constraints, basic type compatibility,
// and rejects unknown keys not declared in any field definition.
func ValidateMapValues(opts map[string]any, fields []happydns.Field) error {
known := make(map[string]*happydns.Field, len(fields))
for i := range fields {
known[fields[i].Id] = &fields[i]
}
// Reject unknown keys.
for k := range opts {
if _, ok := known[k]; !ok {
return fmt.Errorf("unknown option %q", k)
}
}
for _, f := range fields {
v, exists := opts[f.Id]
label := f.Label
if label == "" {
label = f.Id
}
// Required check.
if f.Required {
if !exists || v == nil {
return fmt.Errorf("field %q is required", label)
}
if s, ok := v.(string); ok && s == "" {
return fmt.Errorf("field %q is required", label)
}
}
if !exists || v == nil {
continue
}
// Choices check.
if len(f.Choices) > 0 {
s, ok := v.(string)
if !ok {
return fmt.Errorf("field %q: expected a string value for choices field", label)
}
if s != "" && !slices.Contains(f.Choices, s) {
return fmt.Errorf("field %q: value %q is not a valid choice (valid: %v)", label, s, f.Choices)
}
}
// Basic type check.
if f.Type != "" {
if err := checkMapValueType(f.Type, v, label); err != nil {
return err
}
}
}
return nil
}
// checkMapValueType performs a basic type compatibility check between a Field.Type
// string and the actual value from a map[string]any (JSON-decoded).
func checkMapValueType(fieldType string, value any, label string) error {
switch {
case strings.HasPrefix(fieldType, "string"):
if _, ok := value.(string); !ok {
return fmt.Errorf("field %q: expected string, got %T", label, value)
}
case strings.HasPrefix(fieldType, "int") || strings.HasPrefix(fieldType, "uint") || strings.HasPrefix(fieldType, "float"):
// JSON numbers decode as float64.
if _, ok := value.(float64); !ok {
return fmt.Errorf("field %q: expected number, got %T", label, value)
}
case fieldType == "bool":
if _, ok := value.(bool); !ok {
return fmt.Errorf("field %q: expected bool, got %T", label, value)
}
}
return nil
}
// GenStructFields generates corresponding SourceFields of the given Source.
func GenStructFields(data any) (fields []*happydns.Field) {
if data != nil {

View file

@ -0,0 +1,201 @@
// 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 forms
import (
"testing"
happydns "git.happydns.org/happyDomain/model"
)
func TestValidateMapValues_Required(t *testing.T) {
fields := []happydns.Field{
{Id: "name", Type: "string", Required: true, Label: "Name"},
}
// Missing required field.
if err := ValidateMapValues(map[string]any{}, fields); err == nil {
t.Fatal("expected error for missing required field")
}
// Nil value.
if err := ValidateMapValues(map[string]any{"name": nil}, fields); err == nil {
t.Fatal("expected error for nil required field")
}
// Empty string value.
if err := ValidateMapValues(map[string]any{"name": ""}, fields); err == nil {
t.Fatal("expected error for empty string required field")
}
// Valid value.
if err := ValidateMapValues(map[string]any{"name": "hello"}, fields); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateMapValues_Choices(t *testing.T) {
fields := []happydns.Field{
{Id: "color", Type: "string", Choices: []string{"red", "green", "blue"}},
}
if err := ValidateMapValues(map[string]any{"color": "red"}, fields); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := ValidateMapValues(map[string]any{"color": "yellow"}, fields); err == nil {
t.Fatal("expected error for invalid choice")
}
// Empty string is allowed (field not required).
if err := ValidateMapValues(map[string]any{"color": ""}, fields); err != nil {
t.Fatalf("unexpected error for empty choice: %v", err)
}
}
func TestValidateMapValues_TypeCheck(t *testing.T) {
fields := []happydns.Field{
{Id: "count", Type: "int"},
{Id: "label", Type: "string"},
{Id: "enabled", Type: "bool"},
}
// Valid types.
if err := ValidateMapValues(map[string]any{"count": float64(5), "label": "test", "enabled": true}, fields); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Wrong type for int field.
if err := ValidateMapValues(map[string]any{"count": "notanumber"}, fields); err == nil {
t.Fatal("expected error for wrong type on int field")
}
// Wrong type for string field.
if err := ValidateMapValues(map[string]any{"label": float64(42)}, fields); err == nil {
t.Fatal("expected error for wrong type on string field")
}
// Wrong type for bool field.
if err := ValidateMapValues(map[string]any{"enabled": "yes"}, fields); err == nil {
t.Fatal("expected error for wrong type on bool field")
}
}
func TestValidateMapValues_UnknownKeys(t *testing.T) {
fields := []happydns.Field{
{Id: "name", Type: "string"},
}
if err := ValidateMapValues(map[string]any{"name": "ok", "unknown": "bad"}, fields); err == nil {
t.Fatal("expected error for unknown key")
}
}
func TestValidateMapValues_EmptyFieldsAndOpts(t *testing.T) {
// No fields defined, empty options: valid.
if err := ValidateMapValues(map[string]any{}, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// No fields defined, but has options: rejected as unknown.
if err := ValidateMapValues(map[string]any{"x": 1}, nil); err == nil {
t.Fatal("expected error for unknown key with no fields")
}
}
func TestValidateMapValues_ChoicesNonString(t *testing.T) {
fields := []happydns.Field{
{Id: "mode", Type: "string", Choices: []string{"a", "b"}},
}
// Non-string value on a choices field.
if err := ValidateMapValues(map[string]any{"mode": float64(1)}, fields); err == nil {
t.Fatal("expected error for non-string choices value")
}
}
func TestValidateMapValues_RequiredNonString(t *testing.T) {
fields := []happydns.Field{
{Id: "count", Type: "int", Required: true, Label: "Count"},
}
// Missing required int field.
if err := ValidateMapValues(map[string]any{}, fields); err == nil {
t.Fatal("expected error for missing required int field")
}
// Nil value for required int field.
if err := ValidateMapValues(map[string]any{"count": nil}, fields); err == nil {
t.Fatal("expected error for nil required int field")
}
// Zero value passes (not treated as empty for non-string types).
if err := ValidateMapValues(map[string]any{"count": float64(0)}, fields); err != nil {
t.Fatalf("unexpected error for zero-value required int: %v", err)
}
// Valid non-zero value.
if err := ValidateMapValues(map[string]any{"count": float64(5)}, fields); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateMapValues_ChoicesWithTypeCheck(t *testing.T) {
fields := []happydns.Field{
{Id: "color", Type: "string", Choices: []string{"red", "green", "blue"}},
}
// Valid choice passes both choices and type check.
if err := ValidateMapValues(map[string]any{"color": "red"}, fields); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Invalid choice fails at choices check (type is correct).
if err := ValidateMapValues(map[string]any{"color": "yellow"}, fields); err == nil {
t.Fatal("expected error for invalid choice with type+choices field")
}
// Wrong type fails at choices check before reaching type check.
if err := ValidateMapValues(map[string]any{"color": float64(42)}, fields); err == nil {
t.Fatal("expected error for non-string value on choices+type field")
}
}
func TestValidateStructValues_NilPointer(t *testing.T) {
type S struct {
Name string `happydomain:"required"`
}
// Typed nil pointer must not panic.
if err := ValidateStructValues((*S)(nil)); err != nil {
t.Fatalf("expected nil error for typed nil pointer, got %v", err)
}
}
func TestValidateStructValues_NonStruct(t *testing.T) {
// Non-struct values must not panic.
if err := ValidateStructValues("hello"); err != nil {
t.Fatalf("expected nil error for non-struct, got %v", err)
}
if err := ValidateStructValues(42); err != nil {
t.Fatalf("expected nil error for non-struct, got %v", err)
}
}

View file

@ -0,0 +1,173 @@
// 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 metrics
import (
"errors"
"log"
"sync"
"github.com/prometheus/client_golang/prometheus"
)
// StatsProvider is the minimal interface required by StorageStatsCollector to
// count business entities. It is implemented by
// internal/storage.StatsProvider, which delegates to the backend's native
// Count* methods so each scrape runs O(prefix scan) rather than O(full decode).
type StatsProvider interface {
CountUsers() (int, error)
CountDomains() (int, error)
CountZones() (int, error)
CountProviders() (int, error)
}
// StorageStatsCollector is a Prometheus Collector that queries storage at each
// scrape to report accurate business-entity counts.
type StorageStatsCollector struct {
provider StatsProvider
usersDesc *prometheus.Desc
domainsDesc *prometheus.Desc
zonesDesc *prometheus.Desc
providersDesc *prometheus.Desc
// statsErrorsTotal counts failed Count* calls during a Prometheus scrape
// so silent storage failures remain visible (and alertable) instead of
// producing gaps in the gauge series.
statsErrorsTotal *prometheus.CounterVec
}
// NewStorageStatsCollector creates a new collector backed by the given
// StatsProvider and registers it (and its companion error counter) with the
// default Prometheus registry. Re-registration is tolerated, so calling this
// twice — for instance from tests — does not panic.
func NewStorageStatsCollector(p StatsProvider) *StorageStatsCollector {
c := &StorageStatsCollector{
provider: p,
statsErrorsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_storage_stats_errors_total",
Help: "Total number of errors encountered while collecting storage stats for the /metrics endpoint.",
}, []string{"entity"}),
usersDesc: prometheus.NewDesc(
"happydomain_registered_users",
"Current number of registered user accounts.",
nil, nil,
),
domainsDesc: prometheus.NewDesc(
"happydomain_domains",
"Current number of domains managed across all users.",
nil, nil,
),
zonesDesc: prometheus.NewDesc(
"happydomain_zones",
"Current number of zone snapshots stored.",
nil, nil,
),
providersDesc: prometheus.NewDesc(
"happydomain_providers",
"Current number of provider configurations across all users.",
nil, nil,
),
}
registerOrLog(c)
registerOrLog(c.statsErrorsTotal)
return c
}
// registerOrLog registers a collector with the default registry, tolerating
// "already registered" so test setups and repeated app initialisations are safe.
func registerOrLog(c prometheus.Collector) {
if err := prometheus.Register(c); err != nil {
var are prometheus.AlreadyRegisteredError
if errors.As(err, &are) {
return
}
log.Printf("metrics: failed to register collector: %v", err)
}
}
// Describe implements prometheus.Collector.
func (c *StorageStatsCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.usersDesc
ch <- c.domainsDesc
ch <- c.zonesDesc
ch <- c.providersDesc
}
// Collect implements prometheus.Collector. It queries storage live so the
// values always reflect the actual database state. Each backend call runs in
// its own goroutine to keep the scrape latency bounded by the slowest count
// rather than their sum.
func (c *StorageStatsCollector) Collect(ch chan<- prometheus.Metric) {
type job struct {
entity string
desc *prometheus.Desc
fn func() (int, error)
}
jobs := []job{
{"user", c.usersDesc, c.provider.CountUsers},
{"domain", c.domainsDesc, c.provider.CountDomains},
{"zone", c.zonesDesc, c.provider.CountZones},
{"provider", c.providersDesc, c.provider.CountProviders},
}
type result struct {
desc *prometheus.Desc
val float64
ok bool
}
results := make([]result, len(jobs))
var wg sync.WaitGroup
for i, j := range jobs {
wg.Add(1)
go func(i int, j job) {
defer wg.Done()
// A panic inside a backend Count* implementation must not
// crash the scrape goroutine: convert it into a stats error
// so the failure is visible via happydomain_storage_stats_errors_total
// instead of producing an unrecoverable process crash.
defer func() {
if r := recover(); r != nil {
c.statsErrorsTotal.WithLabelValues(j.entity).Inc()
log.Printf("metrics: panic while collecting %s count: %v", j.entity, r)
}
}()
n, err := j.fn()
if err != nil {
c.statsErrorsTotal.WithLabelValues(j.entity).Inc()
return
}
results[i] = result{desc: j.desc, val: float64(n), ok: true}
}(i, j)
}
wg.Wait()
for _, r := range results {
if !r.ok {
continue
}
ch <- prometheus.MustNewConstMetric(r.desc, prometheus.GaugeValue, r.val)
}
}

54
internal/metrics/http.go Normal file
View file

@ -0,0 +1,54 @@
// 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 metrics
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// HTTPMiddleware returns a Gin middleware that records HTTP request metrics.
// It uses c.FullPath() to get the route pattern (e.g. /api/domains/:domain)
// rather than the actual URL, avoiding high-cardinality labels.
func HTTPMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
HTTPRequestsInFlight.Inc()
c.Next()
HTTPRequestsInFlight.Dec()
path := c.FullPath()
if path == "" {
path = "unknown"
}
method := c.Request.Method
status := strconv.Itoa(c.Writer.Status())
duration := time.Since(start).Seconds()
HTTPRequestsTotal.WithLabelValues(method, path, status).Inc()
HTTPRequestDuration.WithLabelValues(method, path).Observe(duration)
}
}

137
internal/metrics/metrics.go Normal file
View file

@ -0,0 +1,137 @@
// 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 metrics
import (
"strconv"
"sync/atomic"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
// HTTP metrics
HTTPRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_http_requests_total",
Help: "Total number of HTTP requests.",
}, []string{"method", "path", "status"})
HTTPRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "happydomain_http_request_duration_seconds",
Help: "Duration of HTTP requests in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"method", "path"})
HTTPRequestsInFlight = promauto.NewGauge(prometheus.GaugeOpts{
Name: "happydomain_http_requests_in_flight",
Help: "Current number of HTTP requests being served.",
})
// Scheduler metrics
//
// schedulerQueueDepthFn is consulted at scrape time by the GaugeFunc
// registered below. The scheduler installs its accessor via
// RegisterSchedulerQueueDepth at construction, which avoids sprinkling
// gauge.Set calls across every queue mutation site.
schedulerQueueDepthFn atomic.Pointer[func() float64]
// SchedulerQueueDepth is kept as a package-level var (rather than the
// blank identifier) so it is discoverable via grep alongside the other
// metric vars and easy to reference from tests.
SchedulerQueueDepth = promauto.NewGaugeFunc(prometheus.GaugeOpts{
Name: "happydomain_scheduler_queue_depth",
Help: "Number of items currently in the check scheduler queue.",
}, func() float64 {
if fn := schedulerQueueDepthFn.Load(); fn != nil {
return (*fn)()
}
return 0
})
SchedulerActiveWorkers = promauto.NewGauge(prometheus.GaugeOpts{
Name: "happydomain_scheduler_active_workers",
Help: "Number of check scheduler workers currently executing a check.",
})
SchedulerChecksTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_scheduler_checks_total",
Help: "Total number of checks executed by the scheduler.",
}, []string{"checker", "status"})
SchedulerCheckDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "happydomain_scheduler_check_duration_seconds",
Help: "Duration of individual check executions in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"checker"})
// DNS provider API metrics
ProviderAPICallsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_provider_api_calls_total",
Help: "Total number of DNS provider API calls.",
}, []string{"provider", "operation", "status"})
ProviderAPIDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "happydomain_provider_api_duration_seconds",
Help: "Duration of DNS provider API calls in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"provider", "operation"})
// Storage metrics
StorageOperationsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_storage_operations_total",
Help: "Total number of storage operations.",
}, []string{"operation", "entity", "status"})
StorageOperationDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "happydomain_storage_operation_duration_seconds",
Help: "Duration of storage operations in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"operation", "entity"})
// Build info. Always 1; the metadata is carried in the labels so that
// dashboards and alerts can group/diff across deployments.
BuildInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "happydomain_build_info",
Help: "Build information about the running happyDomain instance. Always 1; metadata is in the labels.",
}, []string{"version", "revision", "dirty", "build_date"})
)
// SetBuildInfo records the application build metadata in the build info
// metric. Call this once during application startup. buildDate should be
// formatted as RFC3339 (UTC) and may be empty if unknown.
func SetBuildInfo(version, revision, buildDate string, dirty bool) {
BuildInfo.WithLabelValues(version, revision, strconv.FormatBool(dirty), buildDate).Set(1)
}
// RegisterSchedulerQueueDepth installs the accessor used at scrape time to
// report the current scheduler queue depth. The function is invoked from the
// Prometheus scrape goroutine, so it must be safe to call concurrently with
// queue mutations and must not block for long. Passing nil unregisters the
// accessor (the gauge will then report 0).
func RegisterSchedulerQueueDepth(fn func() float64) {
if fn == nil {
schedulerQueueDepthFn.Store(nil)
return
}
schedulerQueueDepthFn.Store(&fn)
}

View file

@ -0,0 +1,272 @@
// 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 metrics
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
)
func init() {
gin.SetMode(gin.TestMode)
}
// --- HTTPMiddleware -------------------------------------------------------
func TestHTTPMiddleware_RecordsRouteTemplateNotRawPath(t *testing.T) {
// Reset to keep assertions independent from any other test in the package.
HTTPRequestsTotal.Reset()
HTTPRequestDuration.Reset()
r := gin.New()
r.Use(HTTPMiddleware())
r.GET("/api/domains/:domain", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/api/domains/example.com", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Route template — not the raw URL — must be used as the path label,
// otherwise cardinality explodes with one series per domain name.
if got := testutil.ToFloat64(HTTPRequestsTotal.WithLabelValues("GET", "/api/domains/:domain", "200")); got != 1 {
t.Fatalf("expected 1 request recorded for route template, got %v", got)
}
if got := testutil.CollectAndCount(HTTPRequestsTotal); got != 1 {
t.Fatalf("expected exactly one series, got %d (cardinality leak?)", got)
}
}
func TestHTTPMiddleware_UnmatchedRouteUsesUnknownLabel(t *testing.T) {
HTTPRequestsTotal.Reset()
HTTPRequestDuration.Reset()
r := gin.New()
r.Use(HTTPMiddleware())
req := httptest.NewRequest(http.MethodGet, "/no/such/route", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if got := testutil.ToFloat64(HTTPRequestsTotal.WithLabelValues("GET", "unknown", "404")); got != 1 {
t.Fatalf("expected 1 request recorded under 'unknown' path, got %v", got)
}
}
func TestHTTPMiddleware_InFlightBalanced(t *testing.T) {
HTTPRequestsInFlight.Set(0)
r := gin.New()
r.Use(HTTPMiddleware())
r.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
for range 5 {
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
r.ServeHTTP(httptest.NewRecorder(), req)
}
if got := testutil.ToFloat64(HTTPRequestsInFlight); got != 0 {
t.Fatalf("in-flight gauge should return to 0 after requests complete, got %v", got)
}
}
// --- StorageStatsCollector ------------------------------------------------
type fakeStatsProvider struct {
users, domains, zones, providers int
usersErr, domainsErr error
zonesPanic bool
}
func (f *fakeStatsProvider) CountUsers() (int, error) { return f.users, f.usersErr }
func (f *fakeStatsProvider) CountDomains() (int, error) { return f.domains, f.domainsErr }
func (f *fakeStatsProvider) CountZones() (int, error) {
if f.zonesPanic {
panic("boom")
}
return f.zones, nil
}
func (f *fakeStatsProvider) CountProviders() (int, error) { return f.providers, nil }
// collectorFor builds a StorageStatsCollector against a private registry so
// that tests can run in parallel without sharing state with the default
// registry or with each other.
func collectorFor(p StatsProvider) *StorageStatsCollector {
return &StorageStatsCollector{
provider: p,
statsErrorsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_storage_stats_errors_total",
Help: "test",
}, []string{"entity"}),
usersDesc: prometheus.NewDesc(
"happydomain_registered_users", "users", nil, nil),
domainsDesc: prometheus.NewDesc(
"happydomain_domains", "domains", nil, nil),
zonesDesc: prometheus.NewDesc(
"happydomain_zones", "zones", nil, nil),
providersDesc: prometheus.NewDesc(
"happydomain_providers", "providers", nil, nil),
}
}
func TestStorageStatsCollector_HappyPath(t *testing.T) {
c := collectorFor(&fakeStatsProvider{users: 3, domains: 7, zones: 11, providers: 2})
if got := testutil.CollectAndCount(c); got != 4 {
t.Fatalf("expected 4 metrics, got %d", got)
}
}
func TestStorageStatsCollector_ErrorSkipsMetricAndIncrementsErrorCounter(t *testing.T) {
c := collectorFor(&fakeStatsProvider{
users: 3,
domainsErr: errors.New("db down"),
zones: 1, providers: 1,
})
// 4 jobs, 1 errors out → 3 metrics emitted.
if got := testutil.CollectAndCount(c); got != 3 {
t.Fatalf("expected 3 metrics when one count fails, got %d", got)
}
if got := testutil.ToFloat64(c.statsErrorsTotal.WithLabelValues("domain")); got != 1 {
t.Fatalf("expected stats error counter for 'domain' to be 1, got %v", got)
}
}
func TestStorageStatsCollector_PanicIsRecovered(t *testing.T) {
c := collectorFor(&fakeStatsProvider{users: 1, domains: 1, providers: 1, zonesPanic: true})
// Must not crash the test process; panicking job is dropped, others succeed.
got := testutil.CollectAndCount(c)
if got != 3 {
t.Fatalf("expected 3 metrics when zones panics, got %d", got)
}
if v := testutil.ToFloat64(c.statsErrorsTotal.WithLabelValues("zone")); v != 1 {
t.Fatalf("expected zone stats error counter to be 1, got %v", v)
}
}
// --- Scheduler queue depth gauge -----------------------------------------
func TestRegisterSchedulerQueueDepth(t *testing.T) {
t.Cleanup(func() { RegisterSchedulerQueueDepth(nil) })
RegisterSchedulerQueueDepth(func() float64 { return 42 })
// The gauge func is registered against the default registry by promauto.
// Gather and look for our specific metric.
mfs, err := prometheus.DefaultGatherer.Gather()
if err != nil {
t.Fatalf("gather: %v", err)
}
var found bool
for _, mf := range mfs {
if mf.GetName() != "happydomain_scheduler_queue_depth" {
continue
}
found = true
if v := mf.GetMetric()[0].GetGauge().GetValue(); v != 42 {
t.Fatalf("expected queue depth 42, got %v", v)
}
}
if !found {
t.Fatal("happydomain_scheduler_queue_depth not registered")
}
// nil clears the accessor → gauge falls back to 0.
RegisterSchedulerQueueDepth(nil)
mfs, _ = prometheus.DefaultGatherer.Gather()
for _, mf := range mfs {
if mf.GetName() != "happydomain_scheduler_queue_depth" {
continue
}
if v := mf.GetMetric()[0].GetGauge().GetValue(); v != 0 {
t.Fatalf("expected queue depth 0 after clearing accessor, got %v", v)
}
}
}
// --- SetBuildInfo --------------------------------------------------------
func TestSetBuildInfo(t *testing.T) {
BuildInfo.Reset()
SetBuildInfo("1.2.3-test", "abcdef0", "2026-04-08T00:00:00Z", true)
if got := testutil.ToFloat64(BuildInfo.WithLabelValues("1.2.3-test", "abcdef0", "true", "2026-04-08T00:00:00Z")); got != 1 {
t.Fatalf("expected build_info{...}=1, got %v", got)
}
}
// --- /metrics endpoint exposition format ---------------------------------
// TestMetricsEndpointParses guards against the whole exposition pipeline
// emitting something that an actual Prometheus scraper would reject.
func TestMetricsEndpointParses(t *testing.T) {
// Drive at least one observation through every metric family touched by
// instrumentation so the endpoint isn't trivially empty.
HTTPRequestsTotal.WithLabelValues("GET", "/x", "200").Inc()
StorageOperationsTotal.WithLabelValues("get", "user", "success").Inc()
SchedulerChecksTotal.WithLabelValues("dns", "success").Inc()
ProviderAPICallsTotal.WithLabelValues("dummy", "list", "success").Inc()
SetBuildInfo("test", "deadbee", "2026-04-08T00:00:00Z", false)
srv := httptest.NewServer(promhttp.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatalf("GET /metrics: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
parser := expfmt.NewTextParser(model.LegacyValidation)
mfs, err := parser.TextToMetricFamilies(resp.Body)
if err != nil {
t.Fatalf("invalid prometheus exposition format: %v", err)
}
// Sanity-check a few of the metrics we expect to find.
for _, name := range []string{
"happydomain_http_requests_total",
"happydomain_storage_operations_total",
"happydomain_scheduler_checks_total",
"happydomain_provider_api_calls_total",
"happydomain_build_info",
} {
if _, ok := mfs[name]; !ok {
t.Errorf("expected metric %q in /metrics output", name)
}
}
}

View file

@ -75,11 +75,11 @@ func (s *SessionStore) New(r *http.Request, name string) (*sessions.Session, err
if _, ok := r.Header["Authorization"]; ok && len(r.Header["Authorization"]) > 0 {
if flds := strings.Fields(r.Header["Authorization"][0]); len(flds) == 2 && flds[0] == "Bearer" {
if isValidSessionID(flds[1]) {
if IsValidSessionID(flds[1]) {
session.ID = flds[1]
}
} else if user, _, ok := r.BasicAuth(); ok {
if isValidSessionID(user) {
if IsValidSessionID(user) {
session.ID = user
}
}
@ -242,10 +242,10 @@ func (s *SessionStore) save(session *sessions.Session, ua string) error {
return s.storage.UpdateSession(mysession)
}
// isValidSessionID returns true if s looks like a session ID generated by
// NewSessionID: base32 standard alphabet ([A-Z2-7]), exactly 103 characters.
func isValidSessionID(s string) bool {
if len(s) != 103 {
// IsValidSessionID returns true if s looks like a session ID generated by
// NewSessionID: base32 standard alphabet ([A-Z2-7]), exactly SessionIDLen characters.
func IsValidSessionID(s string) bool {
if len(s) != sessionUC.SessionIDLen {
return false
}
for _, c := range s {

View file

@ -0,0 +1,70 @@
// 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 session_test
import (
"strings"
"testing"
"git.happydns.org/happyDomain/internal/session"
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
)
func Test_IsValidSessionID_RoundTrip(t *testing.T) {
// A freshly-generated session ID must always be considered valid.
for range 32 {
id := sessionUC.NewSessionID()
if !session.IsValidSessionID(id) {
t.Fatalf("NewSessionID() produced %q which IsValidSessionID rejected", id)
}
}
}
func Test_IsValidSessionID_Rejects(t *testing.T) {
valid := sessionUC.NewSessionID()
cases := []struct {
name string
in string
}{
{"empty", ""},
{"one char short", valid[:len(valid)-1]},
{"one char long", valid + "A"},
{"all lowercase", strings.ToLower(valid)},
{"with base32 padding", strings.Repeat("A", sessionUC.SessionIDLen-1) + "="},
{"digit 0 (not in base32 alphabet)", strings.Repeat("A", sessionUC.SessionIDLen-1) + "0"},
{"digit 1 (not in base32 alphabet)", strings.Repeat("A", sessionUC.SessionIDLen-1) + "1"},
{"digit 8 (not in base32 alphabet)", strings.Repeat("A", sessionUC.SessionIDLen-1) + "8"},
{"digit 9 (not in base32 alphabet)", strings.Repeat("A", sessionUC.SessionIDLen-1) + "9"},
{"embedded space", strings.Repeat("A", sessionUC.SessionIDLen-1) + " "},
{"non-ASCII", strings.Repeat("A", sessionUC.SessionIDLen-1) + "é"},
{"looks like a JWT", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.sig"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if session.IsValidSessionID(tc.in) {
t.Errorf("IsValidSessionID(%q) = true, want false", tc.in)
}
})
}
}

View file

@ -23,6 +23,7 @@ package storage // import "git.happydns.org/happyDomain/internal/storage"
import (
"git.happydns.org/happyDomain/internal/usecase/authuser"
"git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/internal/usecase/domain"
"git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/insight"
@ -40,6 +41,13 @@ type ProviderAndDomainStorage interface {
type Storage interface {
authuser.AuthUserStorage
checker.CheckPlanStorage
checker.CheckerOptionsStorage
checker.CheckEvaluationStorage
checker.ExecutionStorage
checker.ObservationCacheStorage
checker.ObservationSnapshotStorage
checker.SchedulerStateStorage
domain.DomainStorage
domainlog.DomainLogStorage
insight.InsightStorage

View file

@ -0,0 +1,227 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 (
"errors"
"fmt"
"log"
"strings"
"git.happydns.org/happyDomain/model"
)
func (s *KVStorage) ListEvaluationsByPlan(planID happydns.Identifier) ([]*happydns.CheckEvaluation, error) {
return listByIndex(s, fmt.Sprintf("chckeval-plan|%s|", planID.String()), s.GetEvaluation)
}
func (s *KVStorage) ListAllEvaluations() (happydns.Iterator[happydns.CheckEvaluation], error) {
iter := s.db.Search("chckeval|")
return NewKVIterator[happydns.CheckEvaluation](s.db, iter), nil
}
func (s *KVStorage) GetEvaluation(evalID happydns.Identifier) (*happydns.CheckEvaluation, error) {
eval := &happydns.CheckEvaluation{}
err := s.db.Get(fmt.Sprintf("chckeval|%s", evalID.String()), eval)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrCheckEvaluationNotFound
}
return eval, err
}
func (s *KVStorage) GetLatestEvaluation(planID happydns.Identifier) (*happydns.CheckEvaluation, error) {
evals, err := s.ListEvaluationsByPlan(planID)
if err != nil {
return nil, err
}
if len(evals) == 0 {
return nil, happydns.ErrCheckEvaluationNotFound
}
latest := evals[0]
for _, e := range evals[1:] {
if e.EvaluatedAt.After(latest.EvaluatedAt) {
latest = e
}
}
return latest, nil
}
func (s *KVStorage) ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.CheckEvaluation, error) {
return listByIndexSorted(
s,
fmt.Sprintf("chckeval-chkr|%s|%s|", checkerID, target.String()),
s.GetEvaluation,
func(a, b *happydns.CheckEvaluation) bool { return a.EvaluatedAt.After(b.EvaluatedAt) },
limit,
nil,
)
}
func (s *KVStorage) CreateEvaluation(eval *happydns.CheckEvaluation) error {
key, id, err := s.db.FindIdentifierKey("chckeval|")
if err != nil {
return err
}
eval.Id = id
// Store the primary record.
if err := s.db.Put(key, eval); err != nil {
return err
}
// Store secondary index by plan if applicable.
if eval.PlanID != nil {
indexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String())
if err := s.db.Put(indexKey, true); err != nil {
return err
}
}
// Store secondary index by checker+target.
checkerIndexKey := fmt.Sprintf("chckeval-chkr|%s|%s|%s", eval.CheckerID, eval.Target.String(), eval.Id.String())
if err := s.db.Put(checkerIndexKey, true); err != nil {
return err
}
return nil
}
func (s *KVStorage) DeleteEvaluation(evalID happydns.Identifier) error {
// Load first to find plan ID for index cleanup.
eval, err := s.GetEvaluation(evalID)
if err != nil {
return err
}
if eval.PlanID != nil {
indexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String())
if err := s.db.Delete(indexKey); err != nil {
log.Printf("DeleteEvaluation: failed to delete plan index %s: %v\n", indexKey, err)
}
}
// Clean up checker+target index.
checkerIndexKey := fmt.Sprintf("chckeval-chkr|%s|%s|%s", eval.CheckerID, eval.Target.String(), eval.Id.String())
if err := s.db.Delete(checkerIndexKey); err != nil {
log.Printf("DeleteEvaluation: failed to delete checker index %s: %v\n", checkerIndexKey, err)
}
return s.db.Delete(fmt.Sprintf("chckeval|%s", evalID.String()))
}
func (s *KVStorage) DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) error {
prefix := fmt.Sprintf("chckeval-chkr|%s|%s|", checkerID, target.String())
iter := s.db.Search(prefix)
defer iter.Release()
for iter.Next() {
evalId, err := lastKeySegment(iter.Key())
if err != nil {
continue
}
eval, err := s.GetEvaluation(evalId)
if err != nil {
// Primary record already gone; just clean up this index entry
// and attempt to clean up the plan index (best-effort scan).
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
s.deleteEvalPlanIndexByEvalID(evalId)
continue
}
// Delete plan index if applicable.
if eval.PlanID != nil {
planIndexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String())
if err := s.db.Delete(planIndexKey); err != nil {
log.Printf("DeleteEvaluationsByChecker: failed to delete plan index %s: %v\n", planIndexKey, err)
}
}
// Delete primary record.
if err := s.db.Delete(fmt.Sprintf("chckeval|%s", eval.Id.String())); err != nil {
log.Printf("DeleteEvaluationsByChecker: failed to delete primary record %s: %v\n", eval.Id.String(), err)
}
// Delete this checker index entry.
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}
// deleteEvalPlanIndexByEvalID scans plan indexes to remove any entry for the
// given evaluation ID. Used when the primary record is already gone and we
// don't know which plan it belonged to.
func (s *KVStorage) deleteEvalPlanIndexByEvalID(evalId happydns.Identifier) {
suffix := "|" + evalId.String()
iter := s.db.Search("chckeval-plan|")
defer iter.Release()
for iter.Next() {
if strings.HasSuffix(iter.Key(), suffix) {
if err := s.db.Delete(iter.Key()); err != nil {
log.Printf("deleteEvalPlanIndexByEvalID: failed to delete %s: %v\n", iter.Key(), err)
}
}
}
}
func (s *KVStorage) evalExists(id happydns.Identifier) bool {
_, err := s.GetEvaluation(id)
return err == nil
}
func (s *KVStorage) TidyEvaluationIndexes() error {
// Tidy chckeval-plan|{planId}|{evalId} indexes.
s.tidyTwoPartIndex("chckeval-plan|", "evaluation plan", func(id happydns.Identifier) bool {
_, err := s.GetCheckPlan(id)
return err == nil
}, s.evalExists)
// Tidy chckeval-chkr|{checkerID}|{target}|{evalId} indexes.
s.tidyLastSegmentIndex("chckeval-chkr|", "evaluation checker", s.evalExists)
return nil
}
func (s *KVStorage) ClearEvaluations() error {
// Delete secondary indexes (chckeval-plan|..., chckeval-chkr|...).
if err := s.clearByPrefix("chckeval-"); err != nil {
return err
}
// Delete primary records (chckeval|...).
iter, err := s.ListAllEvaluations()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,222 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 (
"errors"
"fmt"
"log"
"strings"
"git.happydns.org/happyDomain/model"
)
func planTargetIndexKey(target happydns.CheckTarget, planId string) string {
return fmt.Sprintf("chckpln-tgt|%s|%s", target.String(), planId)
}
func planCheckerIndexKey(checkerID string, planId string) string {
return fmt.Sprintf("chckpln-chkr|%s|%s", checkerID, planId)
}
func planUserIndexKey(userId string, planId string) string {
return fmt.Sprintf("chckpln-user|%s|%s", userId, planId)
}
func (s *KVStorage) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) {
iter := s.db.Search("chckpln|")
return NewKVIterator[happydns.CheckPlan](s.db, iter), nil
}
func (s *KVStorage) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) {
return listByIndex(s, fmt.Sprintf("chckpln-tgt|%s|", target.String()), s.GetCheckPlan)
}
func (s *KVStorage) ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) {
return listByIndex(s, fmt.Sprintf("chckpln-chkr|%s|", checkerID), s.GetCheckPlan)
}
func (s *KVStorage) ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) {
return listByIndex(s, fmt.Sprintf("chckpln-user|%s|", userId.String()), s.GetCheckPlan)
}
func (s *KVStorage) GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) {
plan := &happydns.CheckPlan{}
err := s.db.Get(fmt.Sprintf("chckpln|%s", planID.String()), plan)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrCheckPlanNotFound
}
return plan, err
}
func (s *KVStorage) CreateCheckPlan(plan *happydns.CheckPlan) error {
key, id, err := s.db.FindIdentifierKey("chckpln|")
if err != nil {
return err
}
plan.Id = id
if err := s.db.Put(key, plan); err != nil {
return err
}
return s.putCheckPlanIndexes(plan)
}
func (s *KVStorage) UpdateCheckPlan(plan *happydns.CheckPlan) error {
old, err := s.GetCheckPlan(plan.Id)
if err != nil {
return err
}
if err := s.db.Put(fmt.Sprintf("chckpln|%s", plan.Id.String()), plan); err != nil {
return err
}
// Clean up stale target index if target changed.
oldTargetKey := planTargetIndexKey(old.Target, old.Id.String())
newTargetKey := planTargetIndexKey(plan.Target, plan.Id.String())
if oldTargetKey != newTargetKey {
if err := s.db.Delete(oldTargetKey); err != nil {
log.Printf("UpdateCheckPlan: failed to delete stale target index %s: %v\n", oldTargetKey, err)
}
}
// Clean up stale checker index if checker changed.
oldCheckerKey := planCheckerIndexKey(old.CheckerID, old.Id.String())
newCheckerKey := planCheckerIndexKey(plan.CheckerID, plan.Id.String())
if oldCheckerKey != newCheckerKey {
if err := s.db.Delete(oldCheckerKey); err != nil {
log.Printf("UpdateCheckPlan: failed to delete stale checker index %s: %v\n", oldCheckerKey, err)
}
}
// Clean up stale user index if user changed.
if old.Target.UserId != "" && old.Target.UserId != plan.Target.UserId {
if err := s.db.Delete(planUserIndexKey(old.Target.UserId, old.Id.String())); err != nil {
log.Printf("UpdateCheckPlan: failed to delete stale user index for user %s: %v\n", old.Target.UserId, err)
}
}
return s.putCheckPlanIndexes(plan)
}
func (s *KVStorage) putCheckPlanIndexes(plan *happydns.CheckPlan) error {
if err := s.db.Put(planTargetIndexKey(plan.Target, plan.Id.String()), true); err != nil {
return err
}
if err := s.db.Put(planCheckerIndexKey(plan.CheckerID, plan.Id.String()), true); err != nil {
return err
}
if plan.Target.UserId != "" {
if err := s.db.Put(planUserIndexKey(plan.Target.UserId, plan.Id.String()), true); err != nil {
return err
}
}
return nil
}
func (s *KVStorage) DeleteCheckPlan(planID happydns.Identifier) error {
plan, err := s.GetCheckPlan(planID)
if err != nil {
return err
}
if err := s.db.Delete(planTargetIndexKey(plan.Target, planID.String())); err != nil {
log.Printf("DeleteCheckPlan: failed to delete target index: %v\n", err)
}
if err := s.db.Delete(planCheckerIndexKey(plan.CheckerID, planID.String())); err != nil {
log.Printf("DeleteCheckPlan: failed to delete checker index: %v\n", err)
}
if plan.Target.UserId != "" {
if err := s.db.Delete(planUserIndexKey(plan.Target.UserId, planID.String())); err != nil {
log.Printf("DeleteCheckPlan: failed to delete user index for user %s: %v\n", plan.Target.UserId, err)
}
}
return s.db.Delete(fmt.Sprintf("chckpln|%s", planID.String()))
}
// deleteCheckPlanSecondaryIndexesByPlanID scans all plan index prefixes to
// remove any entry for the given plan ID. Used when the primary record is
// already gone and we don't know which target/checker/user it belonged to.
func (s *KVStorage) deleteCheckPlanSecondaryIndexesByPlanID(planId happydns.Identifier) {
suffix := "|" + planId.String()
for _, prefix := range []string{"chckpln-tgt|", "chckpln-chkr|", "chckpln-user|"} {
iter := s.db.Search(prefix)
for iter.Next() {
if strings.HasSuffix(iter.Key(), suffix) {
if err := s.db.Delete(iter.Key()); err != nil {
log.Printf("deleteCheckPlanSecondaryIndexesByPlanID: failed to delete %s: %v\n", iter.Key(), err)
}
}
}
iter.Release()
}
}
func (s *KVStorage) checkPlanExists(id happydns.Identifier) bool {
_, err := s.GetCheckPlan(id)
return err == nil
}
func (s *KVStorage) TidyCheckPlanIndexes() error {
// Tidy chckpln-tgt|{target}|{planId} indexes.
s.tidyLastSegmentIndex("chckpln-tgt|", "plan target", s.checkPlanExists)
// Tidy chckpln-chkr|{checkerID}|{planId} indexes.
s.tidyLastSegmentIndex("chckpln-chkr|", "plan checker", s.checkPlanExists)
// Tidy chckpln-user|{userId}|{planId} indexes.
s.tidyTwoPartIndex("chckpln-user|", "plan user", func(id happydns.Identifier) bool {
_, err := s.GetUser(id)
return err == nil
}, s.checkPlanExists)
return nil
}
func (s *KVStorage) ClearCheckPlans() error {
// Delete secondary indexes.
if err := s.clearByPrefix("chckpln-"); err != nil {
return err
}
// Delete primary records.
iter, err := s.ListAllCheckPlans()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,175 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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"
"log"
"strings"
"git.happydns.org/happyDomain/model"
)
// checkerOptionsKey builds the positional KV key for checker options.
// Format: chckrcfg|{checkerName}|{userId}|{domainId}|{serviceId}
func checkerOptionsKey(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) string {
return fmt.Sprintf("chckrcfg|%s|%s|%s|%s", checkerName,
happydns.FormatIdentifier(userId), happydns.FormatIdentifier(domainId), happydns.FormatIdentifier(serviceId))
}
// parseCheckerOptionsKey extracts the positional components from a KV key.
func parseCheckerOptionsKey(key string) (checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
trimmed := strings.TrimPrefix(key, "chckrcfg|")
parts := strings.SplitN(trimmed, "|", 4)
if len(parts) < 4 {
return trimmed, nil, nil, nil
}
checkerName = parts[0]
if parts[1] != "" {
if id, err := happydns.NewIdentifierFromString(parts[1]); err == nil {
userId = &id
}
}
if parts[2] != "" {
if id, err := happydns.NewIdentifierFromString(parts[2]); err == nil {
domainId = &id
}
}
if parts[3] != "" {
if id, err := happydns.NewIdentifierFromString(parts[3]); err == nil {
serviceId = &id
}
}
return
}
func (s *KVStorage) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptionsPositional], error) {
iter := s.db.Search("chckrcfg|")
return &checkerOptionsIterator{KVIterator: NewKVIterator[happydns.CheckerOptions](s.db, iter)}, nil
}
// checkerOptionsIterator wraps KVIterator[CheckerOptions] and enriches each
// item with positional fields parsed from the storage key.
type checkerOptionsIterator struct {
*KVIterator[happydns.CheckerOptions]
}
func (it *checkerOptionsIterator) Item() *happydns.CheckerOptionsPositional {
opts := it.KVIterator.Item()
if opts == nil {
return nil
}
cn, uid, did, sid := parseCheckerOptionsKey(it.Key())
return &happydns.CheckerOptionsPositional{
CheckName: cn,
UserId: uid,
DomainId: did,
ServiceId: sid,
Options: *opts,
}
}
func (s *KVStorage) ListCheckerConfiguration(checkerName string) ([]*happydns.CheckerOptionsPositional, error) {
prefix := fmt.Sprintf("chckrcfg|%s|", checkerName)
iter := s.db.Search(prefix)
defer iter.Release()
var results []*happydns.CheckerOptionsPositional
for iter.Next() {
var opts happydns.CheckerOptions
if err := s.db.DecodeData(iter.Value(), &opts); err != nil {
log.Printf("ListCheckerConfiguration: error decoding checker config at key %q: %s", iter.Key(), err)
continue
}
cn, uid, did, sid := parseCheckerOptionsKey(iter.Key())
results = append(results, &happydns.CheckerOptionsPositional{
CheckName: cn,
UserId: uid,
DomainId: did,
ServiceId: sid,
Options: opts,
})
}
return results, nil
}
func (s *KVStorage) GetCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error) {
var results []*happydns.CheckerOptionsPositional
// Try each scope level from admin up to the requested specificity.
scopes := []struct {
uid, did, sid *happydns.Identifier
}{
{nil, nil, nil},
{userId, nil, nil},
{userId, domainId, nil},
{userId, domainId, serviceId},
}
for _, sc := range scopes {
// Skip levels that require identifiers not provided.
if (sc.uid != nil && userId == nil) || (sc.did != nil && domainId == nil) || (sc.sid != nil && serviceId == nil) {
continue
}
key := checkerOptionsKey(checkerName, sc.uid, sc.did, sc.sid)
var opts happydns.CheckerOptions
if err := s.db.Get(key, &opts); err == nil {
results = append(results, &happydns.CheckerOptionsPositional{
CheckName: checkerName,
UserId: sc.uid,
DomainId: sc.did,
ServiceId: sc.sid,
Options: opts,
})
}
}
return results, nil
}
func (s *KVStorage) UpdateCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier, opts happydns.CheckerOptions) error {
key := checkerOptionsKey(checkerName, userId, domainId, serviceId)
return s.db.Put(key, opts)
}
func (s *KVStorage) DeleteCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) error {
key := checkerOptionsKey(checkerName, userId, domainId, serviceId)
return s.db.Delete(key)
}
func (s *KVStorage) ClearCheckerConfigurations() error {
iter, err := s.ListAllCheckerConfigurations()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}

View file

@ -34,6 +34,10 @@ func (s *KVStorage) ListAllDomains() (happydns.Iterator[happydns.Domain], error)
return NewKVIterator[happydns.Domain](s.db, iter), nil
}
func (s *KVStorage) CountDomains() (int, error) {
return s.countByPrefix("domain-")
}
func (s *KVStorage) ListDomains(u *happydns.User) (domains []*happydns.Domain, err error) {
iter := s.db.Search("domain-")
defer iter.Release()

View file

@ -0,0 +1,355 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 (
"errors"
"fmt"
"log"
"strings"
"git.happydns.org/happyDomain/model"
)
func executionUserIndexKey(userId string, execId string) string {
return fmt.Sprintf("chckexec-user|%s|%s", userId, execId)
}
func executionDomainIndexKey(domainId string, execId string) string {
return fmt.Sprintf("chckexec-domain|%s|%s", domainId, execId)
}
func (s *KVStorage) ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error) {
return listByIndex(s, fmt.Sprintf("chckexec-plan|%s|", planID.String()), s.GetExecution)
}
// listRecentExecutions scans a prefix, decodes executions, sorts by most
// recent first, applies an optional filter predicate, and then applies a limit.
func (s *KVStorage) listRecentExecutions(prefix string, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
return listByIndexSorted(
s,
prefix,
s.GetExecution,
func(a, b *happydns.Execution) bool { return a.StartedAt.After(b.StartedAt) },
limit,
filter,
)
}
func (s *KVStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
return s.listRecentExecutions(fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String()), limit, filter)
}
func (s *KVStorage) ListExecutionsByUser(userId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
return s.listRecentExecutions(fmt.Sprintf("chckexec-user|%s|", userId.String()), limit, filter)
}
func (s *KVStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
return s.listRecentExecutions(fmt.Sprintf("chckexec-domain|%s|", domainId.String()), limit, filter)
}
func (s *KVStorage) ListAllExecutions() (happydns.Iterator[happydns.Execution], error) {
iter := s.db.Search("chckexec|")
return NewKVIterator[happydns.Execution](s.db, iter), nil
}
func (s *KVStorage) GetExecution(execID happydns.Identifier) (*happydns.Execution, error) {
exec := &happydns.Execution{}
err := s.db.Get(fmt.Sprintf("chckexec|%s", execID.String()), exec)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrExecutionNotFound
}
return exec, err
}
func (s *KVStorage) CreateExecution(exec *happydns.Execution) error {
key, id, err := s.db.FindIdentifierKey("chckexec|")
if err != nil {
return err
}
exec.Id = id
if err := s.db.Put(key, exec); err != nil {
return err
}
// Secondary index by plan.
if exec.PlanID != nil {
indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
if err := s.db.Put(indexKey, true); err != nil {
return err
}
}
// Secondary index by checker+target.
checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), exec.Id.String())
if err := s.db.Put(checkerIndexKey, true); err != nil {
return err
}
// Secondary index by user.
if exec.Target.UserId != "" {
if err := s.db.Put(executionUserIndexKey(exec.Target.UserId, exec.Id.String()), true); err != nil {
return err
}
}
// Secondary index by domain.
if exec.Target.DomainId != "" {
if err := s.db.Put(executionDomainIndexKey(exec.Target.DomainId, exec.Id.String()), true); err != nil {
return err
}
}
return nil
}
func (s *KVStorage) UpdateExecution(exec *happydns.Execution) error {
// Load the old record so we can detect changed index keys.
old, err := s.GetExecution(exec.Id)
if err != nil {
return err
}
if err := s.db.Put(fmt.Sprintf("chckexec|%s", exec.Id.String()), exec); err != nil {
return err
}
// Clean up stale plan index if PlanID changed.
if old.PlanID != nil {
oldPlanKey := fmt.Sprintf("chckexec-plan|%s|%s", old.PlanID.String(), exec.Id.String())
newPlanKey := ""
if exec.PlanID != nil {
newPlanKey = fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
}
if oldPlanKey != newPlanKey {
if err := s.db.Delete(oldPlanKey); err != nil {
log.Printf("UpdateExecution: failed to delete stale plan index %s: %v\n", oldPlanKey, err)
}
}
}
// Update secondary index by plan if applicable.
if exec.PlanID != nil {
indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
if err := s.db.Put(indexKey, true); err != nil {
return err
}
}
// Clean up stale checker+target index if CheckerID or Target changed.
oldCheckerKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", old.CheckerID, old.Target.String(), exec.Id.String())
newCheckerKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), exec.Id.String())
if oldCheckerKey != newCheckerKey {
if err := s.db.Delete(oldCheckerKey); err != nil {
log.Printf("UpdateExecution: failed to delete stale checker index %s: %v\n", oldCheckerKey, err)
}
}
// Update secondary index by checker+target.
if err := s.db.Put(newCheckerKey, true); err != nil {
return err
}
// Clean up stale user index if UserId changed.
if old.Target.UserId != "" && old.Target.UserId != exec.Target.UserId {
if err := s.db.Delete(executionUserIndexKey(old.Target.UserId, exec.Id.String())); err != nil {
log.Printf("UpdateExecution: failed to delete stale user index for user %s: %v\n", old.Target.UserId, err)
}
}
// Update secondary index by user.
if exec.Target.UserId != "" {
if err := s.db.Put(executionUserIndexKey(exec.Target.UserId, exec.Id.String()), true); err != nil {
return err
}
}
// Clean up stale domain index if DomainId changed.
if old.Target.DomainId != "" && old.Target.DomainId != exec.Target.DomainId {
if err := s.db.Delete(executionDomainIndexKey(old.Target.DomainId, exec.Id.String())); err != nil {
log.Printf("UpdateExecution: failed to delete stale domain index for domain %s: %v\n", old.Target.DomainId, err)
}
}
// Update secondary index by domain.
if exec.Target.DomainId != "" {
if err := s.db.Put(executionDomainIndexKey(exec.Target.DomainId, exec.Id.String()), true); err != nil {
return err
}
}
return nil
}
func (s *KVStorage) DeleteExecution(execID happydns.Identifier) error {
exec, err := s.GetExecution(execID)
if err != nil {
return err
}
if exec.PlanID != nil {
indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), execID.String())
if err := s.db.Delete(indexKey); err != nil {
log.Printf("DeleteExecution: failed to delete plan index %s: %v\n", indexKey, err)
}
}
checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), execID.String())
if err := s.db.Delete(checkerIndexKey); err != nil {
log.Printf("DeleteExecution: failed to delete checker index %s: %v\n", checkerIndexKey, err)
}
if exec.Target.UserId != "" {
if err := s.db.Delete(executionUserIndexKey(exec.Target.UserId, execID.String())); err != nil {
log.Printf("DeleteExecution: failed to delete user index for user %s: %v\n", exec.Target.UserId, err)
}
}
if exec.Target.DomainId != "" {
if err := s.db.Delete(executionDomainIndexKey(exec.Target.DomainId, execID.String())); err != nil {
log.Printf("DeleteExecution: failed to delete domain index for domain %s: %v\n", exec.Target.DomainId, err)
}
}
return s.db.Delete(fmt.Sprintf("chckexec|%s", execID.String()))
}
func (s *KVStorage) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error {
prefix := fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String())
iter := s.db.Search(prefix)
defer iter.Release()
for iter.Next() {
execId, err := lastKeySegment(iter.Key())
if err != nil {
continue
}
exec, err := s.GetExecution(execId)
if err != nil {
// Primary record already gone; just clean up this index entry
// and attempt to clean up other indexes (best-effort scan).
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
s.deleteExecSecondaryIndexesByExecID(execId)
continue
}
if exec.PlanID != nil {
planIndexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
if err := s.db.Delete(planIndexKey); err != nil {
log.Printf("DeleteExecutionsByChecker: failed to delete plan index %s: %v\n", planIndexKey, err)
}
}
if exec.Target.UserId != "" {
if err := s.db.Delete(executionUserIndexKey(exec.Target.UserId, exec.Id.String())); err != nil {
log.Printf("DeleteExecutionsByChecker: failed to delete user index for user %s: %v\n", exec.Target.UserId, err)
}
}
if exec.Target.DomainId != "" {
if err := s.db.Delete(executionDomainIndexKey(exec.Target.DomainId, exec.Id.String())); err != nil {
log.Printf("DeleteExecutionsByChecker: failed to delete domain index for domain %s: %v\n", exec.Target.DomainId, err)
}
}
if err := s.db.Delete(fmt.Sprintf("chckexec|%s", exec.Id.String())); err != nil {
log.Printf("DeleteExecutionsByChecker: failed to delete primary record %s: %v\n", exec.Id.String(), err)
}
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}
// deleteExecSecondaryIndexesByExecID scans plan, user and domain indexes to
// remove any entry for the given execution ID. Used when the primary record is
// already gone and we don't know which plan/user/domain it belonged to.
func (s *KVStorage) deleteExecSecondaryIndexesByExecID(execId happydns.Identifier) {
suffix := "|" + execId.String()
for _, prefix := range []string{"chckexec-plan|", "chckexec-user|", "chckexec-domain|"} {
iter := s.db.Search(prefix)
for iter.Next() {
if strings.HasSuffix(iter.Key(), suffix) {
if err := s.db.Delete(iter.Key()); err != nil {
log.Printf("deleteExecSecondaryIndexesByExecID: failed to delete %s: %v\n", iter.Key(), err)
}
}
}
iter.Release()
}
}
func (s *KVStorage) execExists(id happydns.Identifier) bool {
_, err := s.GetExecution(id)
return err == nil
}
func (s *KVStorage) TidyExecutionIndexes() error {
// Tidy chckexec-plan|{planId}|{execId} indexes.
s.tidyTwoPartIndex("chckexec-plan|", "execution plan", func(id happydns.Identifier) bool {
_, err := s.GetCheckPlan(id)
return err == nil
}, s.execExists)
// Tidy chckexec-chkr|{checkerID}|{target}|{execId} indexes.
s.tidyLastSegmentIndex("chckexec-chkr|", "execution checker", s.execExists)
// Tidy chckexec-user|{userId}|{execId} indexes.
s.tidyTwoPartIndex("chckexec-user|", "execution user", func(id happydns.Identifier) bool {
_, err := s.GetUser(id)
return err == nil
}, s.execExists)
// Tidy chckexec-domain|{domainId}|{execId} indexes.
s.tidyTwoPartIndex("chckexec-domain|", "execution domain", func(id happydns.Identifier) bool {
_, err := s.GetDomain(id)
return err == nil
}, s.execExists)
return nil
}
func (s *KVStorage) ClearExecutions() error {
// Delete secondary indexes (chckexec-plan|..., chckexec-chkr|..., chckexec-user|..., chckexec-domain|...).
if err := s.clearByPrefix("chckexec-"); err != nil {
return err
}
// Delete primary records (chckexec|...).
iter, err := s.ListAllExecutions()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,50 @@
// 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) ListAllCachedObservations() (happydns.Iterator[happydns.ObservationCacheEntry], error) {
iter := s.db.Search("obscache|")
return NewKVIterator[happydns.ObservationCacheEntry](s.db, iter), nil
}
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

@ -0,0 +1,71 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 (
"errors"
"fmt"
"git.happydns.org/happyDomain/model"
)
func (s *KVStorage) ListAllSnapshots() (happydns.Iterator[happydns.ObservationSnapshot], error) {
iter := s.db.Search("chcksnap|")
return NewKVIterator[happydns.ObservationSnapshot](s.db, iter), nil
}
func (s *KVStorage) GetSnapshot(snapID happydns.Identifier) (*happydns.ObservationSnapshot, error) {
snap := &happydns.ObservationSnapshot{}
err := s.db.Get(fmt.Sprintf("chcksnap|%s", snapID.String()), snap)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrSnapshotNotFound
}
return snap, err
}
func (s *KVStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) error {
key, id, err := s.db.FindIdentifierKey("chcksnap|")
if err != nil {
return err
}
snap.Id = id
return s.db.Put(key, snap)
}
func (s *KVStorage) DeleteSnapshot(snapID happydns.Identifier) error {
return s.db.Delete(fmt.Sprintf("chcksnap|%s", snapID.String()))
}
func (s *KVStorage) ClearSnapshots() error {
iter, err := s.ListAllSnapshots()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}

View file

@ -34,6 +34,10 @@ func (s *KVStorage) ListAllProviders() (happydns.Iterator[happydns.ProviderMessa
return NewKVIterator[happydns.ProviderMessage](s.db, iter), nil
}
func (s *KVStorage) CountProviders() (int, error) {
return s.countByPrefix("provider-")
}
func (s *KVStorage) getProviderMeta(id happydns.Identifier) (*happydns.ProviderMessage, error) {
srcMsg := &happydns.ProviderMessage{}
err := s.db.Get(id.String(), srcMsg)

View file

@ -0,0 +1,44 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 (
"errors"
"time"
"git.happydns.org/happyDomain/model"
)
const schedulerLastRunKey = "scheduler-lastrun"
func (s *KVStorage) GetLastSchedulerRun() (time.Time, error) {
var t time.Time
err := s.db.Get(schedulerLastRunKey, &t)
if errors.Is(err, happydns.ErrNotFound) {
return time.Time{}, nil
}
return t, err
}
func (s *KVStorage) SetLastSchedulerRun(t time.Time) error {
return s.db.Put(schedulerLastRunKey, t)
}

View file

@ -22,7 +22,13 @@
package database
import (
"fmt"
"log"
"sort"
"strings"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
)
type KVStorage struct {
@ -38,3 +44,159 @@ func NewKVDatabase(impl storage.KVStorage) (storage.Storage, error) {
func (s *KVStorage) Close() error {
return s.db.Close()
}
// lastKeySegment extracts the identifier after the last "|" in a KV key.
func lastKeySegment(key string) (happydns.Identifier, error) {
i := strings.LastIndex(key, "|")
if i < 0 {
return happydns.Identifier{}, fmt.Errorf("key %q has no pipe separator", key)
}
return happydns.NewIdentifierFromString(key[i+1:])
}
// listByIndex scans a secondary index prefix, resolves each entity by its
// last key segment, and returns the collected results.
func listByIndex[T any](s *KVStorage, prefix string, getEntity func(happydns.Identifier) (*T, error)) ([]*T, error) {
iter := s.db.Search(prefix)
defer iter.Release()
var results []*T
for iter.Next() {
id, err := lastKeySegment(iter.Key())
if err != nil {
continue
}
entity, err := getEntity(id)
if err != nil {
continue
}
results = append(results, entity)
}
return results, nil
}
// listByIndexSorted is like listByIndex but sorts results, applies an optional
// filter predicate, and then applies a limit. The limit counts only items that
// pass the filter; passing nil for filter disables filtering.
func listByIndexSorted[T any](s *KVStorage, prefix string, getEntity func(happydns.Identifier) (*T, error), less func(*T, *T) bool, limit int, filter func(*T) bool) ([]*T, error) {
results, err := listByIndex(s, prefix, getEntity)
if err != nil {
return nil, err
}
sort.Slice(results, func(i, j int) bool {
return less(results[i], results[j])
})
if filter == nil {
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
filtered := results[:0]
for _, r := range results {
if filter(r) {
filtered = append(filtered, r)
if limit > 0 && len(filtered) >= limit {
break
}
}
}
return filtered, nil
}
// tidyTwoPartIndex removes stale secondary index entries of the form
// prefix{ownerId}|{entityId}. If validateOwner is non-nil, entries whose
// owner ID fails validation are also removed.
func (s *KVStorage) tidyTwoPartIndex(prefix, label string, validateOwner func(happydns.Identifier) bool, entityExists func(happydns.Identifier) bool) {
iter := s.db.Search(prefix)
defer iter.Release()
for iter.Next() {
key := iter.Key()
rest := strings.TrimPrefix(key, prefix)
parts := strings.SplitN(rest, "|", 2)
if len(parts) != 2 {
_ = s.db.Delete(key)
continue
}
ownerId, err := happydns.NewIdentifierFromString(parts[0])
if err != nil {
_ = s.db.Delete(key)
continue
}
entityId, err := happydns.NewIdentifierFromString(parts[1])
if err != nil {
_ = s.db.Delete(key)
continue
}
if validateOwner != nil && !validateOwner(ownerId) {
log.Printf("Deleting stale %s index (%s %s not found): %s\n", label, label, parts[0], key)
_ = s.db.Delete(key)
continue
}
if !entityExists(entityId) {
log.Printf("Deleting stale %s index (entity %s not found): %s\n", label, parts[1], key)
_ = s.db.Delete(key)
}
}
}
// tidyLastSegmentIndex removes stale index entries where the entity ID is the
// last "|"-separated segment. Used for multi-part indexes like
// prefix{checkerID}|{target}|{entityId}.
func (s *KVStorage) tidyLastSegmentIndex(prefix, label string, entityExists func(happydns.Identifier) bool) {
iter := s.db.Search(prefix)
defer iter.Release()
for iter.Next() {
key := iter.Key()
lastPipe := strings.LastIndex(key, "|")
if lastPipe < 0 {
_ = s.db.Delete(key)
continue
}
idStr := key[lastPipe+1:]
id, err := happydns.NewIdentifierFromString(idStr)
if err != nil {
_ = s.db.Delete(key)
continue
}
if !entityExists(id) {
log.Printf("Deleting stale %s index (entity %s not found): %s\n", label, idStr, key)
_ = s.db.Delete(key)
}
}
}
// clearByPrefix deletes all KV entries matching the given prefix.
func (s *KVStorage) clearByPrefix(prefix string) error {
iter := s.db.Search(prefix)
defer iter.Release()
for iter.Next() {
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}
// countByPrefix counts the number of keys matching the given prefix without
// decoding their values. It is the foundation of the Count* methods exposed
// to observability code.
func (s *KVStorage) countByPrefix(prefix string) (int, error) {
iter := s.db.Search(prefix)
defer iter.Release()
n := 0
for iter.Next() {
n++
}
return n, iter.Err()
}

View file

@ -33,6 +33,10 @@ func (s *KVStorage) ListAllUsers() (happydns.Iterator[happydns.User], error) {
return NewKVIterator[happydns.User](s.db, iter), nil
}
func (s *KVStorage) CountUsers() (int, error) {
return s.countByPrefix("user-")
}
func (s *KVStorage) getUser(key string) (*happydns.User, error) {
u := &happydns.User{}
err := s.db.Get(key, &u)

View file

@ -33,6 +33,10 @@ func (s *KVStorage) ListAllZones() (happydns.Iterator[happydns.ZoneMessage], err
return NewKVIterator[happydns.ZoneMessage](s.db, iter), nil
}
func (s *KVStorage) CountZones() (int, error) {
return s.countByPrefix("domain.zone-")
}
func (s *KVStorage) GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error) {
z := &happydns.ZoneMessage{}
err := s.db.Get(fmt.Sprintf("domain.zone-%s", id.String()), &z)

View file

@ -0,0 +1,40 @@
// 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 storage
// StatsProvider implements metrics.StatsProvider using a Storage. It delegates
// to the storage backend's native Count* methods so each Prometheus scrape
// runs at most one cheap key-prefix scan per entity instead of decoding every
// record.
type StatsProvider struct {
store Storage
}
// NewStatsProvider creates a StatsProvider backed by the given Storage.
func NewStatsProvider(s Storage) *StatsProvider {
return &StatsProvider{store: s}
}
func (p *StatsProvider) CountUsers() (int, error) { return p.store.CountUsers() }
func (p *StatsProvider) CountDomains() (int, error) { return p.store.CountDomains() }
func (p *StatsProvider) CountZones() (int, error) { return p.store.CountZones() }
func (p *StatsProvider) CountProviders() (int, error) { return p.store.CountProviders() }

View file

@ -61,12 +61,13 @@ func (lu *loginUsecase) CompleteAuthentication(uinfo happydns.UserInfo) (*happyd
return nil, fmt.Errorf("unable to create user account: %w", err)
}
} else if (uinfo.GetEmail() != "" && user.Email != uinfo.GetEmail()) || time.Since(user.LastSeen) > time.Hour*12 {
if uinfo.GetEmail() != "" {
user.Email = uinfo.GetEmail()
}
user.LastSeen = time.Now()
err = lu.store.CreateOrUpdateUser(user)
email := uinfo.GetEmail()
user, err = lu.userService.UpdateUser(user.Id, func(u *happydns.User) {
if email != "" {
u.Email = email
}
u.LastSeen = time.Now()
})
if err != nil {
return nil, fmt.Errorf("has a correct JWT, user has been found, but an error occured when trying to update the user's information: %w", err)
}

View file

@ -0,0 +1,135 @@
// 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 (
"fmt"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// targetMatchesResource verifies that every non-empty field in scope
// matches the corresponding field in resource. Returns false if any
// scope-specified field does not match, indicating the resource belongs
// to a different user/domain/service than the caller's scope.
func targetMatchesResource(scope, resource happydns.CheckTarget) bool {
if scope.UserId != "" && scope.UserId != resource.UserId {
return false
}
if scope.DomainId != "" && scope.DomainId != resource.DomainId {
return false
}
if scope.ServiceId != "" && scope.ServiceId != resource.ServiceId {
return false
}
return true
}
// CheckPlanUsecase handles business logic for check plans.
type CheckPlanUsecase struct {
store CheckPlanStorage
}
// NewCheckPlanUsecase creates a new CheckPlanUsecase.
func NewCheckPlanUsecase(store CheckPlanStorage) *CheckPlanUsecase {
return &CheckPlanUsecase{store: store}
}
// ListCheckPlansByTarget returns all check plans matching the given target.
func (u *CheckPlanUsecase) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) {
return u.store.ListCheckPlansByTarget(target)
}
// ListCheckPlansByTargetAndChecker returns all check plans matching both the
// given target and the given checkerID, filtering in a single pass to avoid
// fetching then discarding unrelated plans.
func (u *CheckPlanUsecase) ListCheckPlansByTargetAndChecker(target happydns.CheckTarget, checkerID string) ([]*happydns.CheckPlan, error) {
plans, err := u.store.ListCheckPlansByTarget(target)
if err != nil {
return nil, err
}
filtered := plans[:0]
for _, p := range plans {
if p.CheckerID == checkerID {
filtered = append(filtered, p)
}
}
return filtered, nil
}
// CreateCheckPlan validates that the checker exists and persists the plan.
func (u *CheckPlanUsecase) CreateCheckPlan(plan *happydns.CheckPlan) error {
if checkerPkg.FindChecker(plan.CheckerID) == nil {
return fmt.Errorf("checker %q not found", plan.CheckerID)
}
return u.store.CreateCheckPlan(plan)
}
// GetCheckPlan retrieves a check plan by ID and verifies it belongs to the given scope.
func (u *CheckPlanUsecase) GetCheckPlan(scope happydns.CheckTarget, planID happydns.Identifier) (*happydns.CheckPlan, error) {
plan, err := u.store.GetCheckPlan(planID)
if err != nil {
return nil, err
}
if !targetMatchesResource(scope, plan.Target) {
return nil, happydns.ErrCheckPlanNotFound
}
return plan, nil
}
// UpdateCheckPlan fetches the existing plan, verifies scope ownership,
// validates the checker exists, preserves Id and Target (immutable),
// and persists the merged result.
func (u *CheckPlanUsecase) UpdateCheckPlan(scope happydns.CheckTarget, planID happydns.Identifier, updated *happydns.CheckPlan) (*happydns.CheckPlan, error) {
existing, err := u.store.GetCheckPlan(planID)
if err != nil {
return nil, err
}
if !targetMatchesResource(scope, existing.Target) {
return nil, happydns.ErrCheckPlanNotFound
}
if checkerPkg.FindChecker(updated.CheckerID) == nil {
return nil, fmt.Errorf("checker %q not found", updated.CheckerID)
}
updated.Id = existing.Id
updated.Target = existing.Target
if err := u.store.UpdateCheckPlan(updated); err != nil {
return nil, err
}
return updated, nil
}
// DeleteCheckPlan deletes a check plan by ID after verifying scope ownership.
func (u *CheckPlanUsecase) DeleteCheckPlan(scope happydns.CheckTarget, planID happydns.Identifier) error {
plan, err := u.store.GetCheckPlan(planID)
if err != nil {
return err
}
if !targetMatchesResource(scope, plan.Target) {
return happydns.ErrCheckPlanNotFound
}
return u.store.DeleteCheckPlan(planID)
}

View file

@ -0,0 +1,387 @@
// 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_test
import (
"testing"
"git.happydns.org/happyDomain/internal/checker"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
func setupPlanUC(t *testing.T) (*checkerUC.CheckPlanUsecase, *planStore) {
t.Helper()
// Register a checker so CreateCheckPlan validation passes.
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "plan_test_checker",
Name: "Plan Test Checker",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_a", status: happydns.StatusOK},
},
})
store := newPlanStore()
uc := checkerUC.NewCheckPlanUsecase(store)
return uc, store
}
func TestCheckPlanUsecase_CreateAndGet(t *testing.T) {
uc, _ := setupPlanUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
plan := &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Target: target,
}
if err := uc.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
if plan.Id.IsEmpty() {
t.Fatal("expected plan to get an ID assigned")
}
got, err := uc.GetCheckPlan(target, plan.Id)
if err != nil {
t.Fatalf("GetCheckPlan() error: %v", err)
}
if got.CheckerID != "plan_test_checker" {
t.Errorf("expected CheckerID plan_test_checker, got %s", got.CheckerID)
}
}
func TestCheckPlanUsecase_CreateUnknownChecker(t *testing.T) {
uc, _ := setupPlanUC(t)
plan := &happydns.CheckPlan{
CheckerID: "nonexistent_checker",
}
if err := uc.CreateCheckPlan(plan); err == nil {
t.Fatal("expected error for unknown checker")
}
}
func TestCheckPlanUsecase_ListByTarget(t *testing.T) {
uc, _ := setupPlanUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
plan := &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Target: target,
}
if err := uc.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
plans, err := uc.ListCheckPlansByTarget(target)
if err != nil {
t.Fatalf("ListCheckPlansByTarget() error: %v", err)
}
if len(plans) != 1 {
t.Errorf("expected 1 plan, got %d", len(plans))
}
// Different target should return empty.
uid2, _ := happydns.NewRandomIdentifier()
other := happydns.CheckTarget{UserId: uid2.String()}
plans2, err := uc.ListCheckPlansByTarget(other)
if err != nil {
t.Fatalf("ListCheckPlansByTarget() error: %v", err)
}
if len(plans2) != 0 {
t.Errorf("expected 0 plans for different target, got %d", len(plans2))
}
}
func TestCheckPlanUsecase_ListByTargetAndChecker(t *testing.T) {
uc, _ := setupPlanUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
// Create a plan for plan_test_checker.
plan := &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Target: target,
}
if err := uc.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
// Query for the matching checker - should return the plan.
plans, err := uc.ListCheckPlansByTargetAndChecker(target, "plan_test_checker")
if err != nil {
t.Fatalf("ListCheckPlansByTargetAndChecker() error: %v", err)
}
if len(plans) != 1 {
t.Errorf("expected 1 plan, got %d", len(plans))
}
// Query for a different checker on the same target - should return nothing.
plans2, err := uc.ListCheckPlansByTargetAndChecker(target, "other_checker")
if err != nil {
t.Fatalf("ListCheckPlansByTargetAndChecker() error: %v", err)
}
if len(plans2) != 0 {
t.Errorf("expected 0 plans for different checker, got %d", len(plans2))
}
}
func TestCheckPlanUsecase_UpdatePreservesIdAndTarget(t *testing.T) {
uc, _ := setupPlanUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
plan := &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Target: target,
}
if err := uc.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
origID := plan.Id
// Update with different target and ID; they should be preserved.
uid2, _ := happydns.NewRandomIdentifier()
fakeID, _ := happydns.NewRandomIdentifier()
updated := &happydns.CheckPlan{
Id: fakeID,
CheckerID: "plan_test_checker",
Target: happydns.CheckTarget{UserId: uid2.String()},
Enabled: map[string]bool{"rule_a": false},
}
result, err := uc.UpdateCheckPlan(target, origID, updated)
if err != nil {
t.Fatalf("UpdateCheckPlan() error: %v", err)
}
if !result.Id.Equals(origID) {
t.Errorf("expected Id to be preserved as %s, got %s", origID, result.Id)
}
if result.Target.String() != target.String() {
t.Errorf("expected Target to be preserved")
}
if result.Enabled["rule_a"] != false {
t.Errorf("expected Enabled to be updated")
}
}
func TestCheckPlanUsecase_UpdateScopeMismatch(t *testing.T) {
uc, _ := setupPlanUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
plan := &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Target: target,
}
if err := uc.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
// Update with a different user scope should fail.
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
_, err := uc.UpdateCheckPlan(wrongScope, plan.Id, &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Enabled: map[string]bool{"rule_a": false},
})
if err == nil {
t.Fatal("expected error when scope doesn't match plan target")
}
// Verify the original plan is unchanged.
got, err := uc.GetCheckPlan(target, plan.Id)
if err != nil {
t.Fatalf("GetCheckPlan() error: %v", err)
}
if got.Enabled != nil {
t.Errorf("expected original plan to be unchanged, got Enabled=%v", got.Enabled)
}
}
func TestCheckPlanUsecase_GetScopeMismatch(t *testing.T) {
uc, _ := setupPlanUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
plan := &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Target: target,
}
if err := uc.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
// Get with a different user scope should fail.
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
_, err := uc.GetCheckPlan(wrongScope, plan.Id)
if err == nil {
t.Fatal("expected error when scope doesn't match plan target")
}
}
func TestCheckPlanUsecase_DeleteScopeMismatch(t *testing.T) {
uc, _ := setupPlanUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
plan := &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Target: target,
}
if err := uc.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
// Delete with a different user scope should fail.
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
if err := uc.DeleteCheckPlan(wrongScope, plan.Id); err == nil {
t.Fatal("expected error when scope doesn't match plan target")
}
// Verify the plan still exists.
_, err := uc.GetCheckPlan(target, plan.Id)
if err != nil {
t.Fatalf("plan should still exist after failed delete: %v", err)
}
}
func TestCheckPlanUsecase_UpdateNotFound(t *testing.T) {
uc, _ := setupPlanUC(t)
fakeID, _ := happydns.NewRandomIdentifier()
_, err := uc.UpdateCheckPlan(happydns.CheckTarget{}, fakeID, &happydns.CheckPlan{})
if err == nil {
t.Fatal("expected error for nonexistent plan")
}
}
func TestCheckPlanUsecase_Delete(t *testing.T) {
uc, _ := setupPlanUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String()}
plan := &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Target: target,
}
if err := uc.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
if err := uc.DeleteCheckPlan(target, plan.Id); err != nil {
t.Fatalf("DeleteCheckPlan() error: %v", err)
}
_, err := uc.GetCheckPlan(target, plan.Id)
if err == nil {
t.Fatal("expected error after deletion")
}
}
// --- planStore: minimal in-memory CheckPlanStorage ---
type planStore struct {
plans map[string]*happydns.CheckPlan
}
func newPlanStore() *planStore {
return &planStore{plans: make(map[string]*happydns.CheckPlan)}
}
func (s *planStore) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) {
return nil, nil
}
func (s *planStore) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) {
var result []*happydns.CheckPlan
for _, p := range s.plans {
if p.Target.String() == target.String() {
result = append(result, p)
}
}
return result, nil
}
func (s *planStore) ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) {
return nil, nil
}
func (s *planStore) ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) {
return nil, nil
}
func (s *planStore) GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) {
p, ok := s.plans[planID.String()]
if !ok {
return nil, happydns.ErrCheckPlanNotFound
}
return p, nil
}
func (s *planStore) CreateCheckPlan(plan *happydns.CheckPlan) error {
id, _ := happydns.NewRandomIdentifier()
plan.Id = id
s.plans[plan.Id.String()] = plan
return nil
}
func (s *planStore) UpdateCheckPlan(plan *happydns.CheckPlan) error {
s.plans[plan.Id.String()] = plan
return nil
}
func (s *planStore) DeleteCheckPlan(planID happydns.Identifier) error {
delete(s.plans, planID.String())
return nil
}
func (s *planStore) TidyCheckPlanIndexes() error { return nil }
func (s *planStore) ClearCheckPlans() error {
s.plans = make(map[string]*happydns.CheckPlan)
return nil
}

View file

@ -0,0 +1,403 @@
// 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"
"log"
"slices"
"time"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// worstStatusMaxExecs is the maximum number of executions fetched when
// computing worst-status aggregations. It prevents unbounded memory usage
// on long-lived accounts while being generous enough for any realistic
// scenario.
const worstStatusMaxExecs = 10000
// CheckStatusUsecase handles aggregation of checker statuses and evaluation/execution queries.
type CheckStatusUsecase struct {
planStore CheckPlanStorage
evalStore CheckEvaluationStorage
execStore ExecutionStorage
snapStore ObservationSnapshotStorage
}
// NewCheckStatusUsecase creates a new CheckStatusUsecase.
func NewCheckStatusUsecase(planStore CheckPlanStorage, evalStore CheckEvaluationStorage, execStore ExecutionStorage, snapStore ObservationSnapshotStorage) *CheckStatusUsecase {
return &CheckStatusUsecase{
planStore: planStore,
evalStore: evalStore,
execStore: execStore,
snapStore: snapStore,
}
}
// BudgetChecker exposes the per-user daily quota to the API layer.
//
// RateLimiterFor hands out a snapshot predicate that reports whether a
// scheduled execution of a given interval would be denied by the user's
// daily budget — either because the hard MaxChecksPerDay cap is reached,
// or because interval-aware throttling is skipping short-interval jobs to
// protect rarer ones. The snapshot is resolved once per user so callers
// can evaluate many intervals without repeated lookups.
//
// AllowWithInterval and IncrementUsage let the manual-trigger endpoint
// enforce the same quota as the scheduler: check before creating the
// execution, increment on success. All three are implemented by
// *UserGater.
type BudgetChecker interface {
RateLimiterFor(userID string) func(time.Duration) bool
AllowWithInterval(target happydns.CheckTarget, interval time.Duration) bool
IncrementUsage(target happydns.CheckTarget)
}
// ListPlannedExecutions returns synthetic Execution records for upcoming scheduled jobs.
// Returns nil if provider is nil. When budgetChecker is non-nil, each planned
// execution is marked with status ExecutionRateLimited instead of
// ExecutionPending if a job of that interval would be denied by the user's
// daily budget at the moment of the call.
func ListPlannedExecutions(provider PlannedJobProvider, budgetChecker BudgetChecker, checkerID string, target happydns.CheckTarget) []*happydns.Execution {
if provider == nil {
return nil
}
jobs := provider.GetPlannedJobsForChecker(checkerID, target)
result := make([]*happydns.Execution, 0, len(jobs))
// Resolve the user's rate-limit predicate once, so the loop below does
// not reacquire the UserGater's budget lock for every planned job.
isLimited := func(time.Duration) bool { return false }
if budgetChecker != nil {
isLimited = budgetChecker.RateLimiterFor(target.UserId)
}
for _, job := range jobs {
status := happydns.ExecutionPending
if isLimited(job.Interval) {
status = happydns.ExecutionRateLimited
}
exec := &happydns.Execution{
CheckerID: job.CheckerID,
PlanID: job.PlanID,
Target: job.Target,
Trigger: happydns.TriggerInfo{Type: happydns.TriggerSchedule},
StartedAt: job.NextRun,
Status: status,
}
result = append(result, exec)
}
return result
}
// ListCheckerStatuses aggregates checkers, plans, and latest evaluations into a status list.
func (u *CheckStatusUsecase) ListCheckerStatuses(target happydns.CheckTarget) ([]happydns.CheckerStatus, error) {
checkers := checkerPkg.GetCheckers()
plans, err := u.planStore.ListCheckPlansByTarget(target)
if err != nil {
return nil, err
}
planByChecker := make(map[string]*happydns.CheckPlan)
for _, p := range plans {
planByChecker[p.CheckerID] = p
}
var result []happydns.CheckerStatus
for _, def := range checkers {
switch target.Scope() {
case happydns.CheckScopeDomain:
if !def.Availability.ApplyToDomain {
continue
}
case happydns.CheckScopeService:
if !def.Availability.ApplyToService {
continue
}
if len(def.Availability.LimitToServices) > 0 && target.ServiceType != "" {
if !slices.Contains(def.Availability.LimitToServices, target.ServiceType) {
continue
}
}
}
status := happydns.CheckerStatus{
CheckerDefinition: def,
Plan: planByChecker[def.ID],
Enabled: true,
}
enabledRules := make(map[string]bool, len(def.Rules))
for _, rule := range def.Rules {
enabledRules[rule.Name()] = true
}
if status.Plan != nil {
status.Enabled = !status.Plan.IsFullyDisabled()
for ruleName := range enabledRules {
enabledRules[ruleName] = status.Plan.IsRuleEnabled(ruleName)
}
}
status.EnabledRules = enabledRules
execs, err := u.execStore.ListExecutionsByChecker(def.ID, target, 1, nil)
if err != nil {
log.Printf("ListCheckerStatuses: failed to fetch latest execution for checker %s: %v", def.ID, err)
} else if len(execs) > 0 {
status.LatestExecution = execs[0]
}
result = append(result, status)
}
if result == nil {
result = []happydns.CheckerStatus{}
}
return result, nil
}
// GetExecution returns a specific execution by ID after verifying scope ownership.
func (u *CheckStatusUsecase) GetExecution(scope happydns.CheckTarget, execID happydns.Identifier) (*happydns.Execution, error) {
exec, err := u.execStore.GetExecution(execID)
if err != nil {
return nil, err
}
if !targetMatchesResource(scope, exec.Target) {
return nil, happydns.ErrExecutionNotFound
}
return exec, nil
}
// ListExecutionsByChecker returns executions for a checker on a target, up to limit.
func (u *CheckStatusUsecase) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) {
return u.execStore.ListExecutionsByChecker(checkerID, target, limit, nil)
}
// GetObservationsByExecution returns the observation snapshot for an execution after verifying scope.
func (u *CheckStatusUsecase) GetObservationsByExecution(scope happydns.CheckTarget, execID happydns.Identifier) (*happydns.ObservationSnapshot, error) {
exec, err := u.execStore.GetExecution(execID)
if err != nil {
return nil, err
}
if !targetMatchesResource(scope, exec.Target) {
return nil, happydns.ErrExecutionNotFound
}
return u.snapshotForExecution(exec)
}
// DeleteExecution deletes an execution record by ID after verifying scope ownership.
func (u *CheckStatusUsecase) DeleteExecution(scope happydns.CheckTarget, execID happydns.Identifier) error {
exec, err := u.execStore.GetExecution(execID)
if err != nil {
return err
}
if !targetMatchesResource(scope, exec.Target) {
return happydns.ErrExecutionNotFound
}
return u.execStore.DeleteExecution(execID)
}
// DeleteExecutionsByChecker deletes all executions for a checker on a target.
func (u *CheckStatusUsecase) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error {
return u.execStore.DeleteExecutionsByChecker(checkerID, target)
}
// worstStatuses groups executions by a key extracted via keyFn, keeps only
// the latest execution per (key, checker) pair, and returns the worst status
// per key.
func worstStatuses(execs []*happydns.Execution, keyFn func(*happydns.Execution) string) map[string]*happydns.Status {
type groupKey struct {
key string
checker string
}
latest := map[groupKey]*happydns.Execution{}
for _, exec := range execs {
k := keyFn(exec)
if k == "" || exec.Status != happydns.ExecutionDone {
continue
}
gk := groupKey{key: k, checker: exec.CheckerID}
if prev, ok := latest[gk]; !ok || exec.StartedAt.After(prev.StartedAt) {
latest[gk] = exec
}
}
worst := map[string]*happydns.Status{}
for gk, exec := range latest {
s := exec.Result.Status
if s == happydns.StatusUnknown {
continue
}
if prev, ok := worst[gk.key]; !ok || s > *prev {
worst[gk.key] = &s
}
}
if len(worst) == 0 {
return nil
}
return worst
}
// GetWorstDomainStatuses fetches all executions for a user and returns the worst
// (most critical) status per domain. It keeps only the latest execution per
// (domain, checker) pair and reports the worst status among them.
func (u *CheckStatusUsecase) GetWorstDomainStatuses(userId happydns.Identifier) (map[string]*happydns.Status, error) {
execs, err := u.execStore.ListExecutionsByUser(userId, worstStatusMaxExecs, nil)
if err != nil {
return nil, err
}
return worstStatuses(execs, func(e *happydns.Execution) string {
return e.Target.DomainId
}), nil
}
// GetWorstServiceStatuses returns the worst check status for each service in the zone.
// It fetches all executions for the domain in a single query, then aggregates
// the worst status per service in memory.
func (u *CheckStatusUsecase) GetWorstServiceStatuses(userId happydns.Identifier, domainId happydns.Identifier) (map[string]*happydns.Status, error) {
execs, err := u.execStore.ListExecutionsByDomain(domainId, worstStatusMaxExecs, nil)
if err != nil {
return nil, err
}
return worstStatuses(execs, func(e *happydns.Execution) string {
return e.Target.ServiceId
}), nil
}
// GetResultsByExecution returns the evaluation (with per-rule states) for an execution after verifying scope.
func (u *CheckStatusUsecase) GetResultsByExecution(scope happydns.CheckTarget, execID happydns.Identifier) (*happydns.CheckEvaluation, error) {
exec, err := u.execStore.GetExecution(execID)
if err != nil {
return nil, err
}
if !targetMatchesResource(scope, exec.Target) {
return nil, happydns.ErrExecutionNotFound
}
if exec.EvaluationID == nil {
return nil, happydns.ErrCheckEvaluationNotFound
}
return u.evalStore.GetEvaluation(*exec.EvaluationID)
}
// snapshotForExecution returns the observation snapshot associated with an execution.
func (u *CheckStatusUsecase) snapshotForExecution(exec *happydns.Execution) (*happydns.ObservationSnapshot, error) {
if exec.EvaluationID == nil {
return nil, happydns.ErrCheckEvaluationNotFound
}
eval, err := u.evalStore.GetEvaluation(*exec.EvaluationID)
if err != nil {
return nil, err
}
return u.snapStore.GetSnapshot(eval.SnapshotID)
}
// extractMetricsFromExecution extracts metrics from a single execution's snapshot.
func (u *CheckStatusUsecase) extractMetricsFromExecution(exec *happydns.Execution) ([]happydns.CheckMetric, error) {
if exec.Status != happydns.ExecutionDone || exec.EvaluationID == nil {
return nil, nil
}
snap, err := u.snapshotForExecution(exec)
if err != nil {
return nil, err
}
return checkerPkg.GetAllMetrics(snap)
}
// extractMetricsFromExecutions extracts metrics from a list of executions.
func (u *CheckStatusUsecase) extractMetricsFromExecutions(execs []*happydns.Execution) ([]happydns.CheckMetric, error) {
var allMetrics []happydns.CheckMetric
for _, exec := range execs {
metrics, err := u.extractMetricsFromExecution(exec)
if err != nil {
log.Printf("extractMetricsFromExecutions: exec %s: %v", exec.Id.String(), err)
continue
}
allMetrics = append(allMetrics, metrics...)
}
return allMetrics, nil
}
// doneExecution is a filter predicate for ListExecutionsBy* that keeps only
// executions that have completed successfully and can produce metrics.
func doneExecution(e *happydns.Execution) bool {
return e.Status == happydns.ExecutionDone && e.EvaluationID != nil
}
// GetMetricsByExecution extracts metrics from a single execution's snapshot after verifying scope.
func (u *CheckStatusUsecase) GetMetricsByExecution(scope happydns.CheckTarget, execID happydns.Identifier) ([]happydns.CheckMetric, error) {
exec, err := u.execStore.GetExecution(execID)
if err != nil {
return nil, err
}
if !targetMatchesResource(scope, exec.Target) {
return nil, happydns.ErrExecutionNotFound
}
return u.extractMetricsFromExecution(exec)
}
// GetMetricsByChecker extracts metrics from recent executions of a checker on a target.
func (u *CheckStatusUsecase) GetMetricsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]happydns.CheckMetric, error) {
execs, err := u.execStore.ListExecutionsByChecker(checkerID, target, limit, doneExecution)
if err != nil {
return nil, err
}
return u.extractMetricsFromExecutions(execs)
}
// GetMetricsByUser extracts metrics from recent executions for a user across all checkers.
func (u *CheckStatusUsecase) GetMetricsByUser(userId happydns.Identifier, limit int) ([]happydns.CheckMetric, error) {
execs, err := u.execStore.ListExecutionsByUser(userId, limit, doneExecution)
if err != nil {
return nil, err
}
return u.extractMetricsFromExecutions(execs)
}
// GetMetricsByDomain extracts metrics from recent executions for a domain (including services).
func (u *CheckStatusUsecase) GetMetricsByDomain(domainId happydns.Identifier, limit int) ([]happydns.CheckMetric, error) {
execs, err := u.execStore.ListExecutionsByDomain(domainId, limit, doneExecution)
if err != nil {
return nil, err
}
return u.extractMetricsFromExecutions(execs)
}
// GetSnapshotByExecution returns the raw observation data for a single key from an execution after verifying scope.
func (u *CheckStatusUsecase) GetSnapshotByExecution(scope happydns.CheckTarget, execID happydns.Identifier, obsKey string) (json.RawMessage, error) {
snap, err := u.GetObservationsByExecution(scope, execID)
if err != nil {
return nil, err
}
raw, ok := snap.Data[obsKey]
if !ok {
return nil, happydns.ErrSnapshotNotFound
}
return raw, nil
}

View file

@ -0,0 +1,992 @@
// 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_test
import (
"encoding/json"
"testing"
"time"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/storage/inmemory"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
func setupStatusUC(t *testing.T) (*checkerUC.CheckStatusUsecase, *planStore, storage.Storage) {
t.Helper()
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "status_test_checker",
Name: "Status Test Checker",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_x", status: happydns.StatusOK},
&testCheckRule{name: "rule_y", status: happydns.StatusWarn},
},
})
ps := newPlanStore()
ms, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
uc := checkerUC.NewCheckStatusUsecase(ps, ms, ms, ms)
return uc, ps, ms
}
func TestCheckStatusUsecase_ListCheckerStatuses(t *testing.T) {
uc, _, _ := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
statuses, err := uc.ListCheckerStatuses(target)
if err != nil {
t.Fatalf("ListCheckerStatuses() error: %v", err)
}
if len(statuses) == 0 {
t.Fatal("expected at least one checker status")
}
// All should be enabled by default (no plans).
for _, s := range statuses {
if !s.Enabled {
t.Errorf("expected checker %s to be enabled by default", s.ID)
}
}
}
func TestCheckStatusUsecase_ListCheckerStatuses_WithPlan(t *testing.T) {
uc, ps, _ := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
// Create a plan that fully disables the checker.
plan := &happydns.CheckPlan{
CheckerID: "status_test_checker",
Target: target,
Enabled: map[string]bool{"rule_x": false, "rule_y": false},
}
if err := ps.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
statuses, err := uc.ListCheckerStatuses(target)
if err != nil {
t.Fatalf("ListCheckerStatuses() error: %v", err)
}
found := false
for _, s := range statuses {
if s.ID == "status_test_checker" {
found = true
if s.Enabled {
t.Error("expected status_test_checker to be disabled when all rules are off")
}
if s.Plan == nil {
t.Error("expected Plan to be set")
}
if s.EnabledRules["rule_x"] {
t.Error("expected rule_x to be disabled")
}
if s.EnabledRules["rule_y"] {
t.Error("expected rule_y to be disabled")
}
}
}
if !found {
t.Error("status_test_checker not found in statuses")
}
}
func TestCheckStatusUsecase_ListCheckerStatuses_WithEvaluation(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
// Create an execution for the checker.
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK, Message: "all good"},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
statuses, err := uc.ListCheckerStatuses(target)
if err != nil {
t.Fatalf("ListCheckerStatuses() error: %v", err)
}
for _, s := range statuses {
if s.ID == "status_test_checker" {
if s.LatestExecution == nil {
t.Error("expected LatestExecution to be set")
} else if s.LatestExecution.Result.Status != happydns.StatusOK {
t.Errorf("expected latest execution result status OK, got %s", s.LatestExecution.Result.Status)
}
}
}
}
func TestCheckStatusUsecase_GetExecution(t *testing.T) {
uc, _, ms := setupStatusUC(t)
exec := &happydns.Execution{
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
got, err := uc.GetExecution(happydns.CheckTarget{}, exec.Id)
if err != nil {
t.Fatalf("GetExecution() error: %v", err)
}
if got.Status != happydns.ExecutionDone {
t.Errorf("expected status Done, got %d", got.Status)
}
}
func TestCheckStatusUsecase_GetExecutionNotFound(t *testing.T) {
uc, _, _ := setupStatusUC(t)
fakeID, _ := happydns.NewRandomIdentifier()
_, err := uc.GetExecution(happydns.CheckTarget{}, fakeID)
if err == nil {
t.Fatal("expected error for nonexistent execution")
}
}
func TestCheckStatusUsecase_GetExecution_ScopeMismatch(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
// Access with a different user scope should fail.
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
_, err := uc.GetExecution(wrongScope, exec.Id)
if err == nil {
t.Fatal("expected error when scope doesn't match execution target")
}
}
func TestCheckStatusUsecase_DeleteExecution(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
if err := uc.DeleteExecution(target, exec.Id); err != nil {
t.Fatalf("DeleteExecution() error: %v", err)
}
_, err := uc.GetExecution(target, exec.Id)
if err == nil {
t.Fatal("expected error after deletion")
}
}
func TestCheckStatusUsecase_DeleteExecution_ScopeMismatch(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
// Delete with wrong scope should fail.
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
if err := uc.DeleteExecution(wrongScope, exec.Id); err == nil {
t.Fatal("expected error when scope doesn't match")
}
// Original should still exist.
_, err := uc.GetExecution(target, exec.Id)
if err != nil {
t.Fatalf("execution should still exist after failed delete: %v", err)
}
}
func TestCheckStatusUsecase_DeleteExecutionsByChecker(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
for i := 0; i < 3; i++ {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
if err := uc.DeleteExecutionsByChecker("status_test_checker", target); err != nil {
t.Fatalf("DeleteExecutionsByChecker() error: %v", err)
}
execs, err := uc.ListExecutionsByChecker("status_test_checker", target, 0)
if err != nil {
t.Fatalf("ListExecutionsByChecker() error: %v", err)
}
if len(execs) != 0 {
t.Errorf("expected 0 executions after bulk delete, got %d", len(execs))
}
}
func TestCheckStatusUsecase_ListExecutionsByChecker(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
for i := 0; i < 5; i++ {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
execs, err := uc.ListExecutionsByChecker("status_test_checker", target, 3)
if err != nil {
t.Fatalf("ListExecutionsByChecker() error: %v", err)
}
if len(execs) > 3 {
t.Errorf("expected at most 3 executions with limit, got %d", len(execs))
}
}
func TestCheckStatusUsecase_GetWorstDomainStatuses(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did1, _ := happydns.NewRandomIdentifier()
did2, _ := happydns.NewRandomIdentifier()
// Domain 1: one OK and one WARN execution.
for _, status := range []happydns.Status{happydns.StatusOK, happydns.StatusWarn} {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did1.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: status},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
// Domain 2: only OK.
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did2.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
worst, err := uc.GetWorstDomainStatuses(uid)
if err != nil {
t.Fatalf("GetWorstDomainStatuses() error: %v", err)
}
// Domain 1 should have worst status WARN.
if s, ok := worst[did1.String()]; !ok {
t.Error("expected domain 1 in results")
} else if *s != happydns.StatusWarn {
t.Errorf("expected worst status WARN for domain 1, got %v", *s)
}
// Domain 2 should have worst status OK.
if s, ok := worst[did2.String()]; !ok {
t.Error("expected domain 2 in results")
} else if *s != happydns.StatusOK {
t.Errorf("expected worst status OK for domain 2, got %v", *s)
}
}
func TestCheckStatusUsecase_GetWorstServiceStatuses(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
sid1, _ := happydns.NewRandomIdentifier()
sid2, _ := happydns.NewRandomIdentifier()
// Service 1: CRIT execution.
exec1 := &happydns.Execution{
CheckerID: "status_test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid1.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusCrit},
}
if err := ms.CreateExecution(exec1); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
// Service 2: OK execution.
exec2 := &happydns.Execution{
CheckerID: "status_test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid2.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK},
}
if err := ms.CreateExecution(exec2); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
worst, err := uc.GetWorstServiceStatuses(uid, did)
if err != nil {
t.Fatalf("GetWorstServiceStatuses() error: %v", err)
}
if s, ok := worst[sid1.String()]; !ok {
t.Error("expected service 1 in results")
} else if *s != happydns.StatusCrit {
t.Errorf("expected CRIT for service 1, got %v", *s)
}
if s, ok := worst[sid2.String()]; !ok {
t.Error("expected service 2 in results")
} else if *s != happydns.StatusOK {
t.Errorf("expected OK for service 2, got %v", *s)
}
}
func TestCheckStatusUsecase_GetWorstServiceStatuses_Empty(t *testing.T) {
uc, _, _ := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
result, err := uc.GetWorstServiceStatuses(uid, did)
if err != nil {
t.Fatalf("GetWorstServiceStatuses() error: %v", err)
}
if result != nil {
t.Errorf("expected nil for empty results, got %v", result)
}
}
func TestCheckStatusUsecase_GetResultsByExecution(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
// Create evaluation.
eval := &happydns.CheckEvaluation{
CheckerID: "status_test_checker",
Target: target,
States: []happydns.CheckState{{Status: happydns.StatusOK, Code: "test"}},
}
if err := ms.CreateEvaluation(eval); err != nil {
t.Fatalf("CreateEvaluation() error: %v", err)
}
// Create execution referencing the evaluation.
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
EvaluationID: &eval.Id,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
got, err := uc.GetResultsByExecution(target, exec.Id)
if err != nil {
t.Fatalf("GetResultsByExecution() error: %v", err)
}
if len(got.States) != 1 {
t.Errorf("expected 1 state, got %d", len(got.States))
}
}
func TestCheckStatusUsecase_GetResultsByExecution_NoEvaluation(t *testing.T) {
uc, _, ms := setupStatusUC(t)
target := happydns.CheckTarget{}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionPending,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
_, err := uc.GetResultsByExecution(target, exec.Id)
if err == nil {
t.Fatal("expected error for execution without evaluation")
}
}
func TestCheckStatusUsecase_ListPlannedExecutions(t *testing.T) {
// Test with nil provider.
result := checkerUC.ListPlannedExecutions(nil, nil, "checker", happydns.CheckTarget{})
if result != nil {
t.Errorf("expected nil for nil provider, got %v", result)
}
}
// fakePlannedProvider is a stub PlannedJobProvider that returns a fixed list
// of scheduler jobs, used to test ListPlannedExecutions independently of the
// scheduler.
type fakePlannedProvider struct {
jobs []*checkerUC.SchedulerJob
}
func (f *fakePlannedProvider) GetPlannedJobsForChecker(checkerID string, target happydns.CheckTarget) []*checkerUC.SchedulerJob {
return f.jobs
}
// fakeBudgetChecker is a stub BudgetChecker. Its verdict can be set as a
// blanket value (limited=true denies every call) or selectively denied for
// intervals shorter than denyBelow, mirroring how UserGater throttles
// short-interval jobs while still allowing longer ones.
type fakeBudgetChecker struct {
limited bool
denyBelow time.Duration
calls int // number of RateLimiterFor invocations, for fanout assertions
}
func (f *fakeBudgetChecker) RateLimiterFor(userID string) func(time.Duration) bool {
f.calls++
return func(interval time.Duration) bool {
if f.limited {
return true
}
if f.denyBelow > 0 && interval > 0 && interval < f.denyBelow {
return true
}
return false
}
}
// AllowWithInterval / IncrementUsage are present so fakeBudgetChecker
// satisfies BudgetChecker; ListPlannedExecutions never calls them, so the
// bodies are intentionally minimal.
func (f *fakeBudgetChecker) AllowWithInterval(_ happydns.CheckTarget, _ time.Duration) bool {
return !f.limited
}
func (f *fakeBudgetChecker) IncrementUsage(_ happydns.CheckTarget) {}
func TestCheckStatusUsecase_ListPlannedExecutions_StatusPending(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
now := time.Now()
provider := &fakePlannedProvider{jobs: []*checkerUC.SchedulerJob{
{CheckerID: "c1", Target: target, Interval: time.Hour, NextRun: now.Add(time.Hour)},
{CheckerID: "c1", Target: target, Interval: 2 * time.Hour, NextRun: now.Add(2 * time.Hour)},
}}
// Nil budget checker -> all entries should be pending.
result := checkerUC.ListPlannedExecutions(provider, nil, "c1", target)
if len(result) != 2 {
t.Fatalf("expected 2 planned executions, got %d", len(result))
}
for i, exec := range result {
if exec.Status != happydns.ExecutionPending {
t.Errorf("result[%d].Status = %v; want ExecutionPending", i, exec.Status)
}
if exec.Trigger.Type != happydns.TriggerSchedule {
t.Errorf("result[%d].Trigger.Type = %v; want TriggerSchedule", i, exec.Trigger.Type)
}
}
// Budget checker reporting "not rate-limited" -> still pending.
result = checkerUC.ListPlannedExecutions(provider, &fakeBudgetChecker{}, "c1", target)
for i, exec := range result {
if exec.Status != happydns.ExecutionPending {
t.Errorf("result[%d].Status = %v; want ExecutionPending when budget OK", i, exec.Status)
}
}
}
func TestCheckStatusUsecase_ListPlannedExecutions_StatusRateLimited(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
now := time.Now()
provider := &fakePlannedProvider{jobs: []*checkerUC.SchedulerJob{
{CheckerID: "c1", Target: target, Interval: time.Hour, NextRun: now.Add(time.Hour)},
{CheckerID: "c1", Target: target, Interval: 2 * time.Hour, NextRun: now.Add(2 * time.Hour)},
}}
result := checkerUC.ListPlannedExecutions(provider, &fakeBudgetChecker{limited: true}, "c1", target)
if len(result) != 2 {
t.Fatalf("expected 2 planned executions, got %d", len(result))
}
for i, exec := range result {
if exec.Status != happydns.ExecutionRateLimited {
t.Errorf("result[%d].Status = %v; want ExecutionRateLimited", i, exec.Status)
}
}
}
func TestCheckStatusUsecase_ListPlannedExecutions_MixedByInterval(t *testing.T) {
// When throttling is interval-aware, ListPlannedExecutions should flag
// short-interval jobs as rate-limited while leaving longer ones pending.
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
now := time.Now()
provider := &fakePlannedProvider{jobs: []*checkerUC.SchedulerJob{
{CheckerID: "c1", Target: target, Interval: time.Minute, NextRun: now.Add(time.Minute)},
{CheckerID: "c1", Target: target, Interval: 6 * time.Hour, NextRun: now.Add(6 * time.Hour)},
}}
// Throttle anything shorter than 4h (mirrors UserGater's cutoff).
result := checkerUC.ListPlannedExecutions(provider, &fakeBudgetChecker{denyBelow: 4 * time.Hour}, "c1", target)
if len(result) != 2 {
t.Fatalf("expected 2 planned executions, got %d", len(result))
}
if result[0].Status != happydns.ExecutionRateLimited {
t.Errorf("result[0].Status = %v; want ExecutionRateLimited for 1-minute interval", result[0].Status)
}
if result[1].Status != happydns.ExecutionPending {
t.Errorf("result[1].Status = %v; want ExecutionPending for 6-hour interval", result[1].Status)
}
}
func TestCheckStatusUsecase_ListPlannedExecutions_EmptyJobs(t *testing.T) {
// Even when rate-limited, an empty provider should produce an empty,
// non-nil result (matching the prior behaviour of always returning a
// slice when provider is non-nil).
provider := &fakePlannedProvider{jobs: nil}
result := checkerUC.ListPlannedExecutions(provider, &fakeBudgetChecker{limited: true}, "c1", happydns.CheckTarget{})
if result == nil {
t.Fatal("expected non-nil (empty) slice, got nil")
}
if len(result) != 0 {
t.Errorf("expected 0 planned executions, got %d", len(result))
}
}
func TestCheckStatusUsecase_ListPlannedExecutions_SnapshotsBudgetOnce(t *testing.T) {
// RateLimiterFor must be invoked exactly once per call regardless of
// how many planned jobs are returned — this is the whole point of the
// closure-based snapshot API (one budget lookup amortised over N jobs).
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
now := time.Now()
provider := &fakePlannedProvider{jobs: []*checkerUC.SchedulerJob{
{CheckerID: "c1", Target: target, Interval: time.Minute, NextRun: now},
{CheckerID: "c1", Target: target, Interval: time.Hour, NextRun: now},
{CheckerID: "c1", Target: target, Interval: 6 * time.Hour, NextRun: now},
{CheckerID: "c1", Target: target, Interval: 24 * time.Hour, NextRun: now},
}}
bc := &fakeBudgetChecker{denyBelow: 4 * time.Hour}
_ = checkerUC.ListPlannedExecutions(provider, bc, "c1", target)
if bc.calls != 1 {
t.Errorf("RateLimiterFor called %d times; want 1 (one snapshot per call)", bc.calls)
}
}
func TestCheckStatusUsecase_GetObservationsByExecution(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
// Create snapshot.
snap := &happydns.ObservationSnapshot{
Target: target,
CollectedAt: time.Now(),
}
if err := ms.CreateSnapshot(snap); err != nil {
t.Fatalf("CreateSnapshot() error: %v", err)
}
// Create evaluation referencing the snapshot.
eval := &happydns.CheckEvaluation{
CheckerID: "status_test_checker",
Target: target,
SnapshotID: snap.Id,
}
if err := ms.CreateEvaluation(eval); err != nil {
t.Fatalf("CreateEvaluation() error: %v", err)
}
// Create execution referencing the evaluation.
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
EvaluationID: &eval.Id,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
got, err := uc.GetObservationsByExecution(target, exec.Id)
if err != nil {
t.Fatalf("GetObservationsByExecution() error: %v", err)
}
if !got.Id.Equals(snap.Id) {
t.Errorf("expected snapshot ID %s, got %s", snap.Id, got.Id)
}
}
func TestCheckStatusUsecase_GetObservationsByExecution_ScopeMismatch(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
_, err := uc.GetObservationsByExecution(wrongScope, exec.Id)
if err == nil {
t.Fatal("expected error when scope doesn't match")
}
}
// --- Metrics extraction tests ---
func TestCheckStatusUsecase_ExtractMetricsFromExecution_NilEvaluation(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
EvaluationID: nil,
StartedAt: time.Now(),
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
metrics, err := uc.GetMetricsByExecution(target, exec.Id)
if err != nil {
t.Fatalf("GetMetricsByExecution() error: %v", err)
}
if len(metrics) != 0 {
t.Errorf("expected empty metrics for nil evaluation, got %d", len(metrics))
}
}
func TestCheckStatusUsecase_ExtractMetricsFromExecution_NotDone(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionPending,
StartedAt: time.Now(),
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
metrics, err := uc.GetMetricsByExecution(target, exec.Id)
if err != nil {
t.Fatalf("GetMetricsByExecution() error: %v", err)
}
if len(metrics) != 0 {
t.Errorf("expected empty metrics for pending execution, got %d", len(metrics))
}
}
func TestCheckStatusUsecase_GetMetricsByChecker_Empty(t *testing.T) {
uc, _, _ := setupStatusUC(t)
target := happydns.CheckTarget{UserId: "nonexistent", DomainId: "d1"}
metrics, err := uc.GetMetricsByChecker("status_test_checker", target, 100)
if err != nil {
t.Fatalf("GetMetricsByChecker() error: %v", err)
}
if len(metrics) != 0 {
t.Errorf("expected empty metrics for checker with no executions, got %d", len(metrics))
}
}
func TestCheckStatusUsecase_GetMetricsByUser(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
for i := 0; i < 3; i++ {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
metrics, err := uc.GetMetricsByUser(uid, 100)
if err != nil {
t.Fatalf("GetMetricsByUser() error: %v", err)
}
// Without observation providers registered in tests, metrics will be empty,
// but the call must succeed without error.
_ = metrics
}
func TestCheckStatusUsecase_GetMetricsByDomain(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
for i := 0; i < 3; i++ {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
metrics, err := uc.GetMetricsByDomain(did, 100)
if err != nil {
t.Fatalf("GetMetricsByDomain() error: %v", err)
}
_ = metrics
}
func TestCheckStatusUsecase_GetMetricsByUser_LimitApplied(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
for i := 0; i < 5; i++ {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
// Call with limit=2; underlying list should be limited.
metrics, err := uc.GetMetricsByUser(uid, 2)
if err != nil {
t.Fatalf("GetMetricsByUser(limit=2) error: %v", err)
}
_ = metrics
}
func TestCheckStatusUsecase_GetSnapshotByExecution(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
// Create snapshot with observation data.
snap := &happydns.ObservationSnapshot{
Target: target,
CollectedAt: time.Now(),
Data: map[happydns.ObservationKey]json.RawMessage{
"dns_records": json.RawMessage(`{"records":["A 1.2.3.4"]}`),
},
}
if err := ms.CreateSnapshot(snap); err != nil {
t.Fatalf("CreateSnapshot() error: %v", err)
}
eval := &happydns.CheckEvaluation{
CheckerID: "status_test_checker",
Target: target,
SnapshotID: snap.Id,
}
if err := ms.CreateEvaluation(eval); err != nil {
t.Fatalf("CreateEvaluation() error: %v", err)
}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
EvaluationID: &eval.Id,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
raw, err := uc.GetSnapshotByExecution(target, exec.Id, "dns_records")
if err != nil {
t.Fatalf("GetSnapshotByExecution() error: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("failed to unmarshal observation data: %v", err)
}
if _, ok := parsed["records"]; !ok {
t.Error("expected 'records' key in observation data")
}
}
func TestCheckStatusUsecase_GetSnapshotByExecution_KeyNotFound(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
snap := &happydns.ObservationSnapshot{
Target: target,
CollectedAt: time.Now(),
Data: map[happydns.ObservationKey]json.RawMessage{},
}
if err := ms.CreateSnapshot(snap); err != nil {
t.Fatalf("CreateSnapshot() error: %v", err)
}
eval := &happydns.CheckEvaluation{
CheckerID: "status_test_checker",
Target: target,
SnapshotID: snap.Id,
}
if err := ms.CreateEvaluation(eval); err != nil {
t.Fatalf("CreateEvaluation() error: %v", err)
}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
EvaluationID: &eval.Id,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
_, err := uc.GetSnapshotByExecution(target, exec.Id, "nonexistent_key")
if err == nil {
t.Fatal("expected error for nonexistent observation key")
}
}
func TestCheckStatusUsecase_GetSnapshotByExecution_ScopeMismatch(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
_, err := uc.GetSnapshotByExecution(wrongScope, exec.Id, "any_key")
if err == nil {
t.Fatal("expected error when scope doesn't match")
}
}

View file

@ -0,0 +1,241 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 (
"context"
"encoding/json"
"fmt"
"log"
"time"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// checkerEngine implements the happydns.CheckerEngine interface.
type checkerEngine struct {
optionsUC *CheckerOptionsUsecase
evalStore CheckEvaluationStorage
execStore ExecutionStorage
snapStore ObservationSnapshotStorage
cacheStore ObservationCacheStorage
}
// NewCheckerEngine creates a new CheckerEngine implementation.
func NewCheckerEngine(
optionsUC *CheckerOptionsUsecase,
evalStore CheckEvaluationStorage,
execStore ExecutionStorage,
snapStore ObservationSnapshotStorage,
cacheStore ObservationCacheStorage,
) happydns.CheckerEngine {
return &checkerEngine{
optionsUC: optionsUC,
evalStore: evalStore,
execStore: execStore,
snapStore: snapStore,
cacheStore: cacheStore,
}
}
// CreateExecution validates the checker and creates a pending Execution record.
func (e *checkerEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) {
if checkerPkg.FindChecker(checkerID) == nil {
return nil, fmt.Errorf("%w: %s", happydns.ErrCheckerNotFound, checkerID)
}
// Determine trigger info.
trigger := happydns.TriggerInfo{Type: happydns.TriggerManual}
var planID *happydns.Identifier
if plan != nil {
planID = &plan.Id
trigger.PlanID = planID
trigger.Type = happydns.TriggerSchedule
}
// Create execution record.
exec := &happydns.Execution{
CheckerID: checkerID,
PlanID: planID,
Target: target,
Trigger: trigger,
StartedAt: time.Now(),
Status: happydns.ExecutionPending,
}
if err := e.execStore.CreateExecution(exec); err != nil {
return nil, fmt.Errorf("creating execution: %w", err)
}
return exec, nil
}
// RunExecution takes an existing execution and runs the checker pipeline.
func (e *checkerEngine) RunExecution(ctx context.Context, exec *happydns.Execution, plan *happydns.CheckPlan, runOpts happydns.CheckerOptions) (*happydns.CheckEvaluation, error) {
log.Printf("CheckerEngine: running checker %s on %s", exec.CheckerID, exec.Target.String())
def := checkerPkg.FindChecker(exec.CheckerID)
if def == nil {
endTime := time.Now()
exec.Status = happydns.ExecutionFailed
exec.EndedAt = &endTime
exec.Error = fmt.Sprintf("checker not found: %s", exec.CheckerID)
if err := e.execStore.UpdateExecution(exec); err != nil {
log.Printf("CheckerEngine: failed to update execution: %v", err)
}
return nil, fmt.Errorf("%w: %s", happydns.ErrCheckerNotFound, exec.CheckerID)
}
// Mark as running.
exec.Status = happydns.ExecutionRunning
if err := e.execStore.UpdateExecution(exec); err != nil {
log.Printf("CheckerEngine: failed to update execution: %v", err)
}
// Run the pipeline and handle failure.
result, eval, err := e.runPipeline(ctx, def, exec.Target, plan, exec.PlanID, runOpts)
if err != nil {
log.Printf("CheckerEngine: checker %s on %s failed: %v", exec.CheckerID, exec.Target.String(), err)
endTime := time.Now()
exec.Status = happydns.ExecutionFailed
exec.EndedAt = &endTime
exec.Error = err.Error()
if err := e.execStore.UpdateExecution(exec); err != nil {
log.Printf("CheckerEngine: failed to update execution: %v", err)
}
return nil, err
}
// Mark as done.
endTime := time.Now()
exec.Status = happydns.ExecutionDone
exec.EndedAt = &endTime
exec.Result = result
exec.EvaluationID = &eval.Id
if err := e.execStore.UpdateExecution(exec); err != nil {
log.Printf("CheckerEngine: failed to update execution: %v", err)
}
return eval, nil
}
func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDefinition, target happydns.CheckTarget, plan *happydns.CheckPlan, planID *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckState, *happydns.CheckEvaluation, error) {
// Resolve options (stored + run + auto-fill).
mergedOpts, err := e.optionsUC.BuildMergedCheckerOptionsWithAutoFill(def.ID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), runOpts)
if err != nil {
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, 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))
for _, rule := range def.Rules {
if plan != nil && !plan.IsRuleEnabled(rule.Name()) {
continue
}
state := rule.Evaluate(ctx, obsCtx, mergedOpts)
if state.Code == "" {
state.Code = rule.Name()
}
states = append(states, state)
}
// Aggregate results.
aggregator := def.Aggregator
if aggregator == nil {
aggregator = checkerPkg.WorstStatusAggregator{}
}
result := aggregator.Aggregate(states)
// Persist observation snapshot.
snap := &happydns.ObservationSnapshot{
Target: target,
CollectedAt: time.Now(),
Data: obsCtx.Data(),
}
if err := e.snapStore.CreateSnapshot(snap); err != nil {
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 {
if err := e.cacheStore.PutCachedObservation(target, key, &happydns.ObservationCacheEntry{
SnapshotID: snap.Id,
CollectedAt: snap.CollectedAt,
}); err != nil {
log.Printf("warning: failed to cache observation %q for target %s: %v", key, target.String(), err)
}
}
}
// Persist evaluation.
eval := &happydns.CheckEvaluation{
PlanID: planID,
CheckerID: def.ID,
Target: target,
SnapshotID: snap.Id,
EvaluatedAt: time.Now(),
States: states,
}
if err := e.evalStore.CreateEvaluation(eval); err != nil {
return happydns.CheckState{}, nil, fmt.Errorf("creating evaluation: %w", err)
}
return result, eval, nil
}

View file

@ -0,0 +1,586 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/internal/storage/inmemory"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
// testObservationProvider returns static test data.
type testObservationProvider struct{}
func (p *testObservationProvider) Key() happydns.ObservationKey {
return "test_obs"
}
func (p *testObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
return map[string]any{"value": 42}, nil
}
// testCheckRule produces a state based on observations.
type testCheckRule struct {
name string
status happydns.Status
}
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 {
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}
}
func TestCheckerEngine_RunOK(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
// Register test provider and checker.
checker.RegisterObservationProvider(&testObservationProvider{})
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "test_checker",
Name: "Test Checker",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_ok", status: happydns.StatusOK},
},
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
exec, err := engine.CreateExecution("test_checker", target, nil)
if err != nil {
t.Fatalf("CreateExecution() returned error: %v", err)
}
eval, err := engine.RunExecution(context.Background(), exec, nil, nil)
if err != nil {
t.Fatalf("RunExecution() returned error: %v", err)
}
if eval == nil {
t.Fatal("RunExecution() returned nil evaluation")
}
if exec.Result.Status != happydns.StatusOK {
t.Errorf("expected status OK, got %s", exec.Result.Status)
}
if len(eval.States) != 1 {
t.Errorf("expected 1 state, got %d", len(eval.States))
}
// Verify execution was persisted.
execs, err := store.ListExecutionsByChecker("test_checker", target, 0, nil)
if err != nil {
t.Fatalf("ListExecutionsByChecker() returned error: %v", err)
}
if len(execs) != 1 {
t.Errorf("expected 1 execution, got %d", len(execs))
}
// Verify the execution ended as Done.
for _, ex := range execs {
if ex.Status != happydns.ExecutionDone {
t.Errorf("expected execution status Done, got %d", ex.Status)
}
}
}
func TestCheckerEngine_RunWarn(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "test_checker_warn",
Name: "Test Checker Warn",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_ok", status: happydns.StatusOK},
&testCheckRule{name: "rule_warn", status: happydns.StatusWarn},
},
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
exec, err := engine.CreateExecution("test_checker_warn", target, nil)
if err != nil {
t.Fatalf("CreateExecution() returned error: %v", err)
}
eval, err := engine.RunExecution(context.Background(), exec, nil, nil)
if err != nil {
t.Fatalf("RunExecution() returned error: %v", err)
}
// Worst status aggregation: WARN should win over OK.
if exec.Result.Status != happydns.StatusWarn {
t.Errorf("expected aggregated status WARN, got %s", exec.Result.Status)
}
if len(eval.States) != 2 {
t.Errorf("expected 2 states, got %d", len(eval.States))
}
}
func TestCheckerEngine_RunPerRuleDisable(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "test_checker_per_rule",
Name: "Test Checker Per Rule",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_a", status: happydns.StatusOK},
&testCheckRule{name: "rule_b", status: happydns.StatusWarn},
&testCheckRule{name: "rule_c", status: happydns.StatusCrit},
},
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
// Disable rule_b and rule_c, only rule_a should run.
plan := &happydns.CheckPlan{
CheckerID: "test_checker_per_rule",
Target: target,
Enabled: map[string]bool{
"rule_a": true,
"rule_b": false,
"rule_c": false,
},
}
exec, err := engine.CreateExecution("test_checker_per_rule", target, plan)
if err != nil {
t.Fatalf("CreateExecution() returned error: %v", err)
}
eval, err := engine.RunExecution(context.Background(), exec, plan, nil)
if err != nil {
t.Fatalf("RunExecution() returned error: %v", err)
}
if len(eval.States) != 1 {
t.Fatalf("expected 1 state (only rule_a), got %d", len(eval.States))
}
if exec.Result.Status != happydns.StatusOK {
t.Errorf("expected status OK (only rule_a active), got %s", exec.Result.Status)
}
if eval.States[0].Code != "rule_a" {
t.Errorf("expected rule_a state, got code %s", eval.States[0].Code)
}
}
func TestCheckPlan_IsFullyDisabled(t *testing.T) {
// Nil map = not disabled.
p := &happydns.CheckPlan{}
if p.IsFullyDisabled() {
t.Error("nil map should not be fully disabled")
}
// All false = disabled.
p.Enabled = map[string]bool{"a": false, "b": false}
if !p.IsFullyDisabled() {
t.Error("all-false map should be fully disabled")
}
// Mixed = not disabled.
p.Enabled = map[string]bool{"a": true, "b": false}
if p.IsFullyDisabled() {
t.Error("mixed map should not be fully disabled")
}
}
func TestCheckPlan_IsRuleEnabled(t *testing.T) {
// Nil map = all enabled.
p := &happydns.CheckPlan{}
if !p.IsRuleEnabled("any") {
t.Error("nil map should enable all rules")
}
// Missing key = enabled.
p.Enabled = map[string]bool{"a": false}
if !p.IsRuleEnabled("b") {
t.Error("missing key should be enabled")
}
// Explicit false = disabled.
if p.IsRuleEnabled("a") {
t.Error("explicit false should be disabled")
}
// Explicit true = enabled.
p.Enabled["c"] = true
if !p.IsRuleEnabled("c") {
t.Error("explicit true should be enabled")
}
}
func TestCheckerEngine_RunNotFound(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String()}
_, err = engine.CreateExecution("nonexistent_checker", target, nil)
if err == nil {
t.Fatal("expected error for nonexistent checker")
}
}
func TestCheckerEngine_RunWithScheduledTrigger(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "test_checker_sched",
Name: "Test Checker Scheduled",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_sched", status: happydns.StatusOK},
},
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
planID, _ := happydns.NewRandomIdentifier()
plan := &happydns.CheckPlan{
Id: planID,
CheckerID: "test_checker_sched",
Target: target,
}
exec, err := engine.CreateExecution("test_checker_sched", target, plan)
if err != nil {
t.Fatalf("CreateExecution() returned error: %v", err)
}
// Verify the trigger is set to Schedule when plan is provided.
if exec.Trigger.Type != happydns.TriggerSchedule {
t.Errorf("expected TriggerSchedule, got %v", exec.Trigger.Type)
}
if exec.PlanID == nil || !exec.PlanID.Equals(planID) {
t.Errorf("expected PlanID %s, got %v", planID, exec.PlanID)
}
eval, err := engine.RunExecution(context.Background(), exec, plan, nil)
if err != nil {
t.Fatalf("RunExecution() returned error: %v", err)
}
if eval == nil {
t.Fatal("expected non-nil evaluation")
}
}
func TestCheckerEngine_RunExecution_CheckerDisappeared(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "test_checker_disappear",
Name: "Test Checker Disappear",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_d", status: happydns.StatusOK},
},
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String()}
exec, err := engine.CreateExecution("test_checker_disappear", target, nil)
if err != nil {
t.Fatalf("CreateExecution() returned error: %v", err)
}
// Simulate the checker being unregistered between Create and Run
// by using a fake checker ID on the execution.
exec.CheckerID = "vanished_checker"
_, err = engine.RunExecution(context.Background(), exec, nil, nil)
if err == nil {
t.Fatal("expected error when checker has disappeared")
}
// The execution should be marked as failed.
persisted, err := store.GetExecution(exec.Id)
if err != nil {
t.Fatalf("GetExecution() returned error: %v", err)
}
if persisted.Status != happydns.ExecutionFailed {
t.Errorf("expected execution status Failed, got %d", persisted.Status)
}
}
func TestCheckerEngine_RunPopulatesObservationCache(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
checker.RegisterObservationProvider(&testObservationProvider{})
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "test_checker_cache",
Name: "Test Checker Cache",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_cache", status: happydns.StatusOK},
},
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
exec, err := engine.CreateExecution("test_checker_cache", target, nil)
if err != nil {
t.Fatalf("CreateExecution() returned error: %v", err)
}
_, err = engine.RunExecution(context.Background(), exec, nil, nil)
if err != nil {
t.Fatalf("RunExecution() returned error: %v", err)
}
// Verify observation cache was populated for the "test_obs" key.
entry, err := store.GetCachedObservation(target, "test_obs")
if err != nil {
t.Fatalf("GetCachedObservation() returned error: %v", err)
}
if entry.SnapshotID.IsEmpty() {
t.Error("expected non-empty snapshot ID in cache entry")
}
if entry.CollectedAt.IsZero() {
t.Error("expected non-zero CollectedAt in cache entry")
}
// Verify the cached snapshot actually exists and contains the data.
snap, err := store.GetSnapshot(entry.SnapshotID)
if err != nil {
t.Fatalf("GetSnapshot() returned error: %v", err)
}
if _, ok := snap.Data["test_obs"]; !ok {
t.Error("expected 'test_obs' key in snapshot data")
}
}
func TestCheckerEngine_RunWithEndpointOverride(t *testing.T) {
// Start a fake remote checker that responds to POST /collect.
var gotRequest happydns.ExternalCollectRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/collect" {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
Data: json.RawMessage(`{"value":99}`),
})
}))
defer srv.Close()
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
const checkerID = "test_checker_endpoint"
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: checkerID,
Name: "Test Checker Endpoint",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []happydns.ObservationKey{"test_obs"},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_endpoint", status: happydns.StatusOK},
},
})
// Store admin-level configuration with the endpoint pointing to our test server.
if err := store.UpdateCheckerConfiguration(checkerID, nil, nil, nil, happydns.CheckerOptions{
"endpoint": srv.URL,
}); err != nil {
t.Fatalf("UpdateCheckerConfiguration() returned error: %v", err)
}
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
exec, err := engine.CreateExecution(checkerID, target, nil)
if err != nil {
t.Fatalf("CreateExecution() returned error: %v", err)
}
eval, err := engine.RunExecution(context.Background(), exec, nil, nil)
if err != nil {
t.Fatalf("RunExecution() returned error: %v", err)
}
if eval == nil {
t.Fatal("RunExecution() returned nil evaluation")
}
// The engine should have delegated to the HTTP endpoint.
if gotRequest.Key != "test_obs" {
t.Errorf("remote received Key = %q, want %q", gotRequest.Key, "test_obs")
}
if exec.Result.Status != happydns.StatusOK {
t.Errorf("expected status OK, got %s", exec.Result.Status)
}
}
func TestCheckerEngine_RunWithEndpointOverride_RemoteFailure(t *testing.T) {
// Start a remote checker that always returns an error.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
Error: "remote collector is down",
})
}))
defer srv.Close()
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
const checkerID = "test_checker_endpoint_fail"
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: checkerID,
Name: "Test Checker Endpoint Fail",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []happydns.ObservationKey{"test_obs"},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_endpoint_fail", status: happydns.StatusOK},
},
})
if err := store.UpdateCheckerConfiguration(checkerID, nil, nil, nil, happydns.CheckerOptions{
"endpoint": srv.URL,
}); err != nil {
t.Fatalf("UpdateCheckerConfiguration() returned error: %v", err)
}
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
exec, err := engine.CreateExecution(checkerID, target, nil)
if err != nil {
t.Fatalf("CreateExecution() returned error: %v", err)
}
eval, err := engine.RunExecution(context.Background(), exec, nil, nil)
if err != nil {
t.Fatalf("RunExecution() returned error: %v", err)
}
// The rule should report an error state because observation collection failed.
if exec.Result.Status != happydns.StatusError {
t.Errorf("expected status Error, got %s", exec.Result.Status)
}
if len(eval.States) != 1 {
t.Fatalf("expected 1 state, got %d", len(eval.States))
}
}

View file

@ -0,0 +1,642 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 (
"fmt"
"maps"
"sync"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/internal/forms"
"git.happydns.org/happyDomain/model"
)
// fieldMetaCache caches the result of computeFieldMeta per CheckerDefinition.
// Checker definitions are immutable after init-time registration, so the cache
// never needs invalidation.
var fieldMetaCache sync.Map // *happydns.CheckerDefinition -> checkerFieldMeta
// isEmptyValue returns true if v is nil or an empty string.
func isEmptyValue(v any) bool {
if v == nil {
return true
}
if s, ok := v.(string); ok && s == "" {
return true
}
return false
}
// identifiersEqual returns true when both identifiers are nil or point to the same value.
func identifiersEqual(a, b *happydns.Identifier) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return a.Equals(*b)
}
// getScopedOptions returns options stored exactly at the requested scope level,
// without merging parent scopes.
func (u *CheckerOptionsUsecase) getScopedOptions(
checkerName string,
userId *happydns.Identifier,
domainId *happydns.Identifier,
serviceId *happydns.Identifier,
) (happydns.CheckerOptions, error) {
positionals, err := u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId)
if err != nil {
return make(happydns.CheckerOptions), err
}
for _, p := range positionals {
if identifiersEqual(p.UserId, userId) && identifiersEqual(p.DomainId, domainId) && identifiersEqual(p.ServiceId, serviceId) {
if p.Options != nil {
return p.Options, nil
}
return make(happydns.CheckerOptions), nil
}
}
return make(happydns.CheckerOptions), nil
}
// CheckerOptionsUsecase handles the resolution and persistence of checker options.
type CheckerOptionsUsecase struct {
store CheckerOptionsStorage
autoFillStore CheckAutoFillStorage
}
// NewCheckerOptionsUsecase creates a new CheckerOptionsUsecase.
func NewCheckerOptionsUsecase(store CheckerOptionsStorage, autoFillStore CheckAutoFillStorage) *CheckerOptionsUsecase {
return &CheckerOptionsUsecase{store: store, autoFillStore: autoFillStore}
}
// GetCheckerOptionsPositional returns the raw positional options from all scope levels,
// ordered from least to most specific (admin < user < domain < service).
func (u *CheckerOptionsUsecase) GetCheckerOptionsPositional(
checkerName string,
userId *happydns.Identifier,
domainId *happydns.Identifier,
serviceId *happydns.Identifier,
) ([]*happydns.CheckerOptionsPositional, error) {
return u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId)
}
// GetAutoFillOptions resolves auto-fill values for a checker and target,
// returning only the auto-filled key/value pairs.
func (u *CheckerOptionsUsecase) GetAutoFillOptions(
checkerName string,
target happydns.CheckTarget,
) (happydns.CheckerOptions, error) {
result, err := u.resolveAutoFill(checkerName, target)
if err != nil {
return nil, err
}
if len(result) == 0 {
return nil, nil
}
return result, nil
}
// GetCheckerOptions retrieves and merges options from all applicable levels
// (admin < user < domain < service), returning the merged result.
func (u *CheckerOptionsUsecase) GetCheckerOptions(
checkerName string,
userId *happydns.Identifier,
domainId *happydns.Identifier,
serviceId *happydns.Identifier,
) (happydns.CheckerOptions, error) {
positionals, err := u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId)
if err != nil {
return nil, err
}
// Determine which fields are NoOverride.
var noOverrideIds map[string]bool
if def := checkerPkg.FindChecker(checkerName); def != nil {
noOverrideIds = computeFieldMeta(def).noOverrideIds
}
merged := make(happydns.CheckerOptions)
// positionals are returned in order of increasing specificity.
for _, p := range positionals {
for k, v := range p.Options {
// If the key is NoOverride and already set by a less specific scope, skip it.
if noOverrideIds[k] {
if _, exists := merged[k]; exists {
continue
}
}
merged[k] = v
}
}
return merged, nil
}
// BuildMergedCheckerOptions merges stored options with runtime overrides.
// RunOpts are applied last and win over all stored levels.
func BuildMergedCheckerOptions(storedOpts happydns.CheckerOptions, runOpts happydns.CheckerOptions) happydns.CheckerOptions {
result := make(happydns.CheckerOptions)
maps.Copy(result, storedOpts)
maps.Copy(result, runOpts)
return result
}
// SetCheckerOptions persists options at the given positional level (full replace).
// Keys with nil or empty-string values are excluded from the stored map.
// Auto-fill keys are also stripped since they are system-provided at runtime.
func (u *CheckerOptionsUsecase) SetCheckerOptions(
checkerName string,
userId *happydns.Identifier,
domainId *happydns.Identifier,
serviceId *happydns.Identifier,
opts happydns.CheckerOptions,
) error {
// Determine which field IDs are auto-filled or NoOverride for this checker.
var autoFillIds map[string]string
var noOverrideScopes map[string]happydns.CheckScopeType
if def := checkerPkg.FindChecker(checkerName); def != nil {
meta := computeFieldMeta(def)
autoFillIds = meta.autoFillIds
noOverrideScopes = meta.noOverrideScopes
}
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
filtered := make(happydns.CheckerOptions, len(opts))
for k, v := range opts {
if isEmptyValue(v) || autoFillIds[k] != "" {
continue
}
// Defense-in-depth: strip NoOverride fields at scopes below their definition.
if defScope, ok := noOverrideScopes[k]; ok && currentScope > defScope {
continue
}
filtered[k] = v
}
return u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, filtered)
}
// MergeCheckerOptions computes the result of merging newOpts into the existing
// options at the given scope level WITHOUT persisting it. This allows callers to
// validate the merged result before committing it to storage.
// Keys with nil or empty-string values are removed from the merged map.
func (u *CheckerOptionsUsecase) MergeCheckerOptions(
checkerName string,
userId *happydns.Identifier,
domainId *happydns.Identifier,
serviceId *happydns.Identifier,
newOpts happydns.CheckerOptions,
) (happydns.CheckerOptions, error) {
existing, err := u.getScopedOptions(checkerName, userId, domainId, serviceId)
if err != nil {
return nil, err
}
// Determine NoOverride scopes for defense-in-depth stripping.
var noOverrideScopes map[string]happydns.CheckScopeType
if def := checkerPkg.FindChecker(checkerName); def != nil {
noOverrideScopes = computeFieldMeta(def).noOverrideScopes
}
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
for k, v := range newOpts {
// Defense-in-depth: skip NoOverride fields at scopes below their definition.
if defScope, ok := noOverrideScopes[k]; ok && currentScope > defScope {
continue
}
if isEmptyValue(v) {
delete(existing, k)
} else {
existing[k] = v
}
}
return existing, nil
}
// AddCheckerOptions merges new options into existing ones at the given scope level
// and persists the result. Keys with nil or empty-string values are deleted from the
// scope rather than stored.
func (u *CheckerOptionsUsecase) AddCheckerOptions(
checkerName string,
userId *happydns.Identifier,
domainId *happydns.Identifier,
serviceId *happydns.Identifier,
newOpts happydns.CheckerOptions,
) (happydns.CheckerOptions, error) {
merged, err := u.MergeCheckerOptions(checkerName, userId, domainId, serviceId, newOpts)
if err != nil {
return nil, err
}
if err := u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, merged); err != nil {
return nil, err
}
return merged, nil
}
// GetCheckerOption returns a single option value from the merged options.
func (u *CheckerOptionsUsecase) GetCheckerOption(
checkerName string,
userId *happydns.Identifier,
domainId *happydns.Identifier,
serviceId *happydns.Identifier,
optName string,
) (any, error) {
opts, err := u.GetCheckerOptions(checkerName, userId, domainId, serviceId)
if err != nil {
return nil, err
}
return opts[optName], nil
}
// scopeFromIdentifiers determines the CheckScopeType based on which identifiers are set.
func scopeFromIdentifiers(userId, domainId, serviceId *happydns.Identifier) happydns.CheckScopeType {
if serviceId != nil {
return happydns.CheckScopeService
}
if domainId != nil {
return happydns.CheckScopeDomain
}
if userId != nil {
return happydns.CheckScopeUser
}
return happydns.CheckScopeAdmin
}
// collectFieldsForScope returns the fields from a CheckerOptionsDocumentation
// that are valid at the given scope level. RunOpts are never included for
// persisted scopes.
func collectFieldsForScope(doc happydns.CheckerOptionsDocumentation, scope happydns.CheckScopeType) []happydns.CheckerOptionDocumentation {
var fields []happydns.CheckerOptionDocumentation
switch scope {
case happydns.CheckScopeAdmin:
fields = append(fields, doc.AdminOpts...)
case happydns.CheckScopeUser:
fields = append(fields, doc.UserOpts...)
case happydns.CheckScopeDomain, happydns.CheckScopeZone:
fields = append(fields, doc.DomainOpts...)
case happydns.CheckScopeService:
fields = append(fields, doc.ServiceOpts...)
}
return fields
}
// ValidateOptions validates checker options against the checker's field definitions
// for the given scope level, and any OptionsValidator interface implemented by rules.
// When withRunOpts is true, RunOpts fields are also included so that required run-time
// options are enforced (used at trigger time). For persisted scopes, pass false.
func (u *CheckerOptionsUsecase) ValidateOptions(
checkerName string,
userId *happydns.Identifier,
domainId *happydns.Identifier,
serviceId *happydns.Identifier,
opts happydns.CheckerOptions,
withRunOpts bool,
) error {
def := checkerPkg.FindChecker(checkerName)
if def == nil {
return fmt.Errorf("checker %q not found", checkerName)
}
scope := scopeFromIdentifiers(userId, domainId, serviceId)
// Collect fields for this scope from the checker definition.
// When withRunOpts is true (trigger time), also include all persisted-scope
// fields so that options already stored at a different scope level (e.g.
// admin-level options merged into the final opts map) are not rejected as
// unknown.
var allFields []happydns.CheckerOptionDocumentation
if withRunOpts {
allFields = append(allFields, def.Options.AdminOpts...)
allFields = append(allFields, def.Options.UserOpts...)
allFields = append(allFields, def.Options.DomainOpts...)
allFields = append(allFields, def.Options.ServiceOpts...)
allFields = append(allFields, def.Options.RunOpts...)
} else {
allFields = collectFieldsForScope(def.Options, scope)
}
// Collect fields from rules that declare their own options at this scope.
for _, rule := range def.Rules {
if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok {
ruleDoc := rwo.Options()
if withRunOpts {
allFields = append(allFields, ruleDoc.AdminOpts...)
allFields = append(allFields, ruleDoc.UserOpts...)
allFields = append(allFields, ruleDoc.DomainOpts...)
allFields = append(allFields, ruleDoc.ServiceOpts...)
allFields = append(allFields, ruleDoc.RunOpts...)
} else {
allFields = append(allFields, collectFieldsForScope(ruleDoc, scope)...)
}
}
}
// Filter out auto-fill fields: they are system-provided at runtime
// and should not be validated against user input.
autoFillIds := computeFieldMeta(def).autoFillIds
var validatableFields []happydns.CheckerOptionDocumentation
for _, f := range allFields {
if _, isAutoFill := autoFillIds[f.Id]; !isAutoFill {
validatableFields = append(validatableFields, f)
}
}
// Validate against field definitions. ValidateMapValues lives in the
// forms package and works with happydns.Field; CheckerOptionDocumentation
// is structurally identical so an element-wise conversion is enough.
if len(validatableFields) > 0 {
asFields := make([]happydns.Field, len(validatableFields))
for i, opt := range validatableFields {
asFields[i] = happydns.FieldFromCheckerOption(opt)
}
if err := forms.ValidateMapValues(opts, asFields); err != nil {
return err
}
}
// Check if any rule implements OptionsValidator.
for _, rule := range def.Rules {
if v, ok := rule.(happydns.OptionsValidator); ok {
if err := v.ValidateOptions(opts); err != nil {
return err
}
}
}
return nil
}
// SetCheckerOption sets a single option value at the given scope level.
// If value is nil or empty string, the key is deleted from the scope.
func (u *CheckerOptionsUsecase) SetCheckerOption(
checkerName string,
userId *happydns.Identifier,
domainId *happydns.Identifier,
serviceId *happydns.Identifier,
optName string,
value any,
) error {
// Defense-in-depth: reject NoOverride fields at scopes below their definition.
if def := checkerPkg.FindChecker(checkerName); def != nil {
meta := computeFieldMeta(def)
if defScope, ok := meta.noOverrideScopes[optName]; ok {
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
if currentScope > defScope {
return fmt.Errorf("option %q cannot be overridden at this scope level", optName)
}
}
}
existing, err := u.getScopedOptions(checkerName, userId, domainId, serviceId)
if err != nil {
return err
}
if isEmptyValue(value) {
delete(existing, optName)
} else {
existing[optName] = value
}
return u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, existing)
}
// checkerFieldMeta holds pre-computed field metadata for a checker definition,
// avoiding repeated scans of the same option groups and rules.
type checkerFieldMeta struct {
autoFillIds map[string]string
noOverrideIds map[string]bool
noOverrideScopes map[string]happydns.CheckScopeType
}
// computeFieldMeta returns cached field metadata for a checker definition.
// The result is computed once per definition and cached for the process lifetime.
func computeFieldMeta(def *happydns.CheckerDefinition) checkerFieldMeta {
if cached, ok := fieldMetaCache.Load(def); ok {
return cached.(checkerFieldMeta)
}
meta := buildFieldMeta(def)
fieldMetaCache.Store(def, meta)
return meta
}
// buildFieldMeta scans all option groups and rules of a checker definition
// and returns the consolidated field metadata.
func buildFieldMeta(def *happydns.CheckerDefinition) checkerFieldMeta {
meta := checkerFieldMeta{
autoFillIds: make(map[string]string),
noOverrideIds: make(map[string]bool),
noOverrideScopes: make(map[string]happydns.CheckScopeType),
}
scanDoc := func(doc happydns.CheckerOptionsDocumentation) {
type scopedGroup struct {
fields []happydns.CheckerOptionDocumentation
scope happydns.CheckScopeType
}
groups := []scopedGroup{
{doc.AdminOpts, happydns.CheckScopeAdmin},
{doc.UserOpts, happydns.CheckScopeUser},
{doc.DomainOpts, happydns.CheckScopeDomain},
{doc.ServiceOpts, happydns.CheckScopeService},
{doc.RunOpts, happydns.CheckScopeService}, // RunOpts have no distinct scope; use Service as ceiling.
}
for _, g := range groups {
for _, f := range g.fields {
if f.AutoFill != "" {
meta.autoFillIds[f.Id] = f.AutoFill
}
if f.NoOverride {
meta.noOverrideIds[f.Id] = true
meta.noOverrideScopes[f.Id] = g.scope
}
}
}
}
scanDoc(def.Options)
for _, rule := range def.Rules {
if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok {
scanDoc(rwo.Options())
}
}
return meta
}
// buildAutoFillContext loads domain/zone data from storage and builds a map
// of auto-fill key to resolved value.
func (u *CheckerOptionsUsecase) buildAutoFillContext(
target happydns.CheckTarget,
) (map[string]any, error) {
ctx := make(map[string]any)
if u.autoFillStore == nil {
return ctx, nil
}
domainId := happydns.TargetIdentifier(target.DomainId)
if domainId == nil {
return ctx, nil
}
domain, err := u.autoFillStore.GetDomain(*domainId)
if err != nil {
return ctx, fmt.Errorf("loading domain for auto-fill: %w", err)
}
ctx[happydns.AutoFillDomainName] = domain.DomainName
// Load the WIP zone ([0]) for auto-fill context, so the user can
// configure checkers for services they are currently working on.
if len(domain.ZoneHistory) == 0 {
return ctx, nil
}
zone, err := u.autoFillStore.GetZone(domain.ZoneHistory[0])
if err != nil {
return ctx, fmt.Errorf("loading zone for auto-fill: %w", err)
}
ctx[happydns.AutoFillZone] = zone
// Resolve service if target has a ServiceId.
// Search WIP first, then latest published, then older history.
if serviceId := happydns.TargetIdentifier(target.ServiceId); serviceId != nil {
for i := 0; i < len(domain.ZoneHistory); i++ {
z := zone
if i > 0 {
z, err = u.autoFillStore.GetZone(domain.ZoneHistory[i])
if err != nil {
continue
}
}
for subdomain, services := range z.Services {
for _, svc := range services {
if svc.Id.Equals(*serviceId) {
ctx[happydns.AutoFillSubdomain] = string(subdomain)
ctx[happydns.AutoFillServiceType] = svc.Type
ctx[happydns.AutoFillService] = svc
return ctx, nil
}
}
}
}
}
return ctx, nil
}
// resolveAutoFill looks up the checker definition, scans its fields for AutoFill
// attributes, builds the execution context from storage, and returns a map of
// field ID to resolved value. Returns an empty map (not nil) when there is
// nothing to fill.
func (u *CheckerOptionsUsecase) resolveAutoFill(
checkerName string,
target happydns.CheckTarget,
) (happydns.CheckerOptions, error) {
def := checkerPkg.FindChecker(checkerName)
if def == nil {
return make(happydns.CheckerOptions), nil
}
autoFillFields := computeFieldMeta(def).autoFillIds
if len(autoFillFields) == 0 {
return make(happydns.CheckerOptions), nil
}
ctx, err := u.buildAutoFillContext(target)
if err != nil {
return nil, err
}
result := make(happydns.CheckerOptions, len(autoFillFields))
for fieldId, autoFillKey := range autoFillFields {
if val, ok := ctx[autoFillKey]; ok {
result[fieldId] = val
}
}
return result, nil
}
// BuildMergedCheckerOptionsWithAutoFill merges stored options, runtime overrides,
// and auto-fill values. Auto-fill values are applied last and always win.
func (u *CheckerOptionsUsecase) BuildMergedCheckerOptionsWithAutoFill(
checkerName string,
userId *happydns.Identifier,
domainId *happydns.Identifier,
serviceId *happydns.Identifier,
runOpts happydns.CheckerOptions,
) (happydns.CheckerOptions, error) {
positionals, err := u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId)
if err != nil {
return nil, err
}
def := checkerPkg.FindChecker(checkerName)
// Merge stored options from least to most specific, respecting NoOverride.
var meta checkerFieldMeta
if def != nil {
meta = computeFieldMeta(def)
}
storedOpts := make(happydns.CheckerOptions)
for _, p := range positionals {
for k, v := range p.Options {
if meta.noOverrideIds[k] {
if _, exists := storedOpts[k]; exists {
continue
}
}
storedOpts[k] = v
}
}
// Apply runtime overrides on top.
merged := BuildMergedCheckerOptions(storedOpts, runOpts)
// Restore NoOverride fields from storedOpts so that runOpts cannot override them.
for id := range meta.noOverrideIds {
if v, ok := storedOpts[id]; ok {
merged[id] = v
}
}
// Resolve auto-fill values (always win).
if def != nil && len(meta.autoFillIds) > 0 {
target := happydns.CheckTarget{
UserId: happydns.FormatIdentifier(userId),
DomainId: happydns.FormatIdentifier(domainId),
ServiceId: happydns.FormatIdentifier(serviceId),
}
ctx, err := u.buildAutoFillContext(target)
if err != nil {
return nil, err
}
for fieldId, autoFillKey := range meta.autoFillIds {
if val, ok := ctx[autoFillKey]; ok {
merged[fieldId] = val
}
}
}
return merged, nil
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 usecase layer for the checker/monitoring system.
package checker // import "git.happydns.org/happyDomain/internal/usecase/checker"

View file

@ -0,0 +1,241 @@
// 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 (
"context"
"log"
"sync"
"time"
"git.happydns.org/happyDomain/model"
)
// JanitorUserResolver resolves a user from a CheckTarget so the janitor can
// honour per-user retention overrides stored in UserQuota.
type JanitorUserResolver interface {
GetUser(id happydns.Identifier) (*happydns.User, error)
}
// Janitor periodically prunes old check executions and evaluations according
// to the tiered RetentionPolicy. It is the long-tail enforcement counterpart
// of the cheap hard cap applied at execution-creation time.
type Janitor struct {
planStore CheckPlanStorage
execStore ExecutionStorage
evalStore CheckEvaluationStorage
snapStore ObservationSnapshotStorage
userResolver JanitorUserResolver
defaultPolicy RetentionPolicy
interval time.Duration
mu sync.Mutex
cancel context.CancelFunc
done chan struct{}
running bool
}
// NewJanitor builds a Janitor that runs every `interval`. The defaultPolicy
// is applied to executions of users that did not customize their retention
// horizon via UserQuota. evalStore and snapStore may be nil if evaluation
// pruning is not desired.
func NewJanitor(planStore CheckPlanStorage, execStore ExecutionStorage, evalStore CheckEvaluationStorage, snapStore ObservationSnapshotStorage, userResolver JanitorUserResolver, defaultPolicy RetentionPolicy, interval time.Duration) *Janitor {
if interval <= 0 {
interval = 6 * time.Hour
}
return &Janitor{
planStore: planStore,
execStore: execStore,
evalStore: evalStore,
snapStore: snapStore,
userResolver: userResolver,
defaultPolicy: defaultPolicy,
interval: interval,
}
}
// Start launches the janitor loop in a goroutine. It runs an immediate sweep
// once the loop is up.
func (j *Janitor) Start(ctx context.Context) {
j.mu.Lock()
if j.running {
j.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(ctx)
j.cancel = cancel
j.done = make(chan struct{})
j.running = true
j.mu.Unlock()
go j.loop(ctx)
}
// Stop halts the janitor and waits for the current sweep to finish.
func (j *Janitor) Stop() {
j.mu.Lock()
cancel := j.cancel
done := j.done
j.mu.Unlock()
if cancel != nil {
cancel()
}
if done != nil {
<-done
}
j.mu.Lock()
j.running = false
j.mu.Unlock()
}
func (j *Janitor) loop(ctx context.Context) {
defer close(j.done)
// Run immediately, then on the configured interval.
j.RunOnce(ctx)
ticker := time.NewTicker(j.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
j.RunOnce(ctx)
}
}
}
// RunOnce performs a single sweep over all check plans, applying the per-user
// retention policy to both executions and evaluations. Returns the total
// number of records deleted (executions + evaluations).
func (j *Janitor) RunOnce(ctx context.Context) int {
iter, err := j.planStore.ListAllCheckPlans()
if err != nil {
log.Printf("Janitor: failed to list check plans: %v", err)
return 0
}
defer iter.Close()
now := time.Now()
deleted := 0
// Cache user policies to avoid resolving the same user repeatedly.
policyByUser := map[string]RetentionPolicy{}
for iter.Next() {
select {
case <-ctx.Done():
return deleted
default:
}
plan := iter.Item()
if plan == nil {
continue
}
policy := j.policyForTarget(plan.Target, policyByUser)
hardCutoff := now.AddDate(0, 0, -policy.RetentionDays)
// Prune executions using the tiered retention policy.
execs, err := j.execStore.ListExecutionsByPlan(plan.Id)
if err != nil {
log.Printf("Janitor: failed to list executions for plan %s: %v", plan.Id.String(), err)
} else if len(execs) > 0 {
// All executions share the same (CheckerID, Target) since they come
// from a single plan, so Decide's internal grouping is a no-op here.
_, drop := policy.Decide(execs, now)
for _, id := range drop {
if err := j.execStore.DeleteExecution(id); err != nil {
log.Printf("Janitor: failed to delete execution %s: %v", id.String(), err)
continue
}
deleted++
}
}
// Prune evaluations older than the hard cutoff.
if j.evalStore != nil {
deleted += j.pruneEvaluations(plan.Id, hardCutoff)
}
}
if err := iter.Err(); err != nil {
log.Printf("Janitor: iterator error while walking check plans: %v", err)
}
if deleted > 0 {
log.Printf("Janitor: pruned %d records", deleted)
}
return deleted
}
// pruneEvaluations deletes evaluations for the given plan that are older than
// the cutoff, along with their associated snapshots.
func (j *Janitor) pruneEvaluations(planID happydns.Identifier, cutoff time.Time) int {
evals, err := j.evalStore.ListEvaluationsByPlan(planID)
if err != nil {
log.Printf("Janitor: failed to list evaluations for plan %s: %v", planID.String(), err)
return 0
}
deleted := 0
for _, eval := range evals {
if eval.EvaluatedAt.Before(cutoff) {
// Delete the associated snapshot first.
if j.snapStore != nil && !eval.SnapshotID.IsEmpty() {
if err := j.snapStore.DeleteSnapshot(eval.SnapshotID); err != nil {
log.Printf("Janitor: failed to delete snapshot %s: %v", eval.SnapshotID.String(), err)
}
}
if err := j.evalStore.DeleteEvaluation(eval.Id); err != nil {
log.Printf("Janitor: failed to delete evaluation %s: %v", eval.Id.String(), err)
continue
}
deleted++
}
}
return deleted
}
func (j *Janitor) policyForTarget(target happydns.CheckTarget, cache map[string]RetentionPolicy) RetentionPolicy {
uid := target.UserId
if uid == "" || j.userResolver == nil {
return j.defaultPolicy
}
if p, ok := cache[uid]; ok {
return p
}
policy := j.defaultPolicy
id, err := happydns.NewIdentifierFromString(uid)
if err == nil {
if user, err := j.userResolver.GetUser(id); err == nil && user != nil {
if user.Quota.RetentionDays > 0 {
policy = DefaultRetentionPolicy(user.Quota.RetentionDays)
}
}
}
cache[uid] = policy
return policy
}

Some files were not shown because too many files have changed in this diff Show more