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
229 changed files with 31058 additions and 419 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()