Compare commits

...

35 commits

Author SHA1 Message Date
505a1080db Add storage stats Prometheus collector for business entity counts
Some checks failed
continuous-integration/drone/push Build is failing
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-03-17 18:34:32 +07:00
a0d31a09a0 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-03-17 18:34:32 +07:00
403a2c7d64 Expose /metrics endpoint on admin socket via promhttp 2026-03-17 18:34:32 +07:00
825c86d63b 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-03-17 18:34:32 +07:00
896416879a 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-03-17 18:34:32 +07:00
0695772486 Add String() method to CheckResultStatus for use in metrics labels 2026-03-17 18:34:32 +07:00
f33217bc04 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-03-17 18:34:32 +07:00
c66c5db4a4 New checker: monitor domain expiration date 2026-03-17 18:34:32 +07:00
e1b13b9e5c web: Add WHOIS modal accessible from zone sidebar
Introduces ModalDomainWhois component that fetches and displays domain
registration data (status, dates, registrar, nameservers) in a modal,
accessible via a new "View registration data" item in the zone gear menu.
2026-03-17 18:34:32 +07:00
f804404ac6 Add frontend /whois page for domain RDAP/WHOIS information
Implements a new /whois/[[domain]] route displaying registrar,
creation/expiration dates, nameservers and RDAP status codes
retrieved from the /api/domaininfo/{domain} endpoint. Includes
translations for en, fr, de, es, zh and hi, and a header menu
entry between DNS resolver and Domain Checkers.
2026-03-17 18:34:32 +07:00
32c09bbb86 API route to retrieve RDAP/WHOIS information as domaininfo 2026-03-17 18:34:32 +07:00
e862e24c52 Add a checker for ICMP Ping 2026-03-17 18:34:02 +07:00
e5ccfad7d7 Introduce checker returning metrics 2026-03-17 18:34:02 +07:00
3f45d752b4 Add a checker for Matrix Federation 2026-03-17 18:34:02 +07:00
d8b464e8e2 Handle checks on services 2026-03-17 18:34:02 +07:00
8828de6e04 Enrich domain listing with worst check status
Add last_check_status to the GET /domains response by aggregating the
most recent result of each checker per domain and reporting the worst
(most critical) status. The frontend domain list now renders a badge
with the check status that links to the domain's checks page.
2026-03-17 18:34:02 +07:00
6fb396ff49 web: Refactor check result page: move metadata to sidebar component
Extract check result metadata (status, options, actions) into a new
CheckResultSidebar component rendered in the domain layout sidebar,
allowing the main content area to be used for full-screen report display.
Shared state is managed via new Svelte stores (currentCheckResult,
currentCheckInfo, showHTMLReport).
2026-03-17 18:34:02 +07:00
7b094d919c Add CheckerHTMLReporter interface and Zonemaster HTML report
Introduces an optional CheckerHTMLReporter interface that checkers can
implement to expose a rich HTML document built from their stored Report
field.  The Zonemaster checker implements it, rendering results grouped
by module in collapsible accordions with color-coded severity badges.
2026-03-17 18:34:02 +07:00
e72d577c27 Add a checker for Zonemaster 2026-03-17 18:34:02 +07:00
1a0a8e8619 Implement auto-fill variables for checker option fields
Add an AutoFill attribute to the Field struct that marks option fields
as automatically resolved by the software based on test context, rather
than requiring user input. Auto-fill always overrides any user-provided
value at execution time.
2026-03-17 18:34:02 +07:00
4ea1f08da8 web: Add frontend for domain tests browsing and execution
Add test API client, data models, Svelte store, and pages to list
available tests per domain, view results, and trigger test runs via a
dedicated modal. Also refactor plugins page to use a shared store.
2026-03-17 18:34:02 +07:00
dea0175fab Add admin API and frontend for scheduler management 2026-03-17 18:34:02 +07:00
796ac6d811 Implement checks scheduler 2026-03-17 18:34:02 +07:00
2d3944d83d Implement backend model for test results and schedule 2026-03-17 18:34:02 +07:00
dd811c9341 Add checker interface: api routes and frontend to manage user checker 2026-03-17 18:34:02 +07:00
e611efbad7 Add checker routes to API + refactor check controller 2026-03-17 18:34:02 +07:00
12ed7e4164 web-admin: Implement checkers interface with option editor 2026-03-17 18:34:02 +07:00
47a8289eb3 Implement checker options retrieval 2026-03-17 18:31:22 +07:00
d5719a0272 Add usescases to handle checkers 2026-03-17 18:31:22 +07:00
4fdb466a52 Write plugin technical documentation 2026-03-17 18:31:22 +07:00
d70d8868dc Load checks plugins 2026-03-17 18:31:22 +07:00
ecf30c345a New custom flag parser: ArrayArgs 2026-03-17 18:31:22 +07:00
32756535b8 Add input validation for service fields
Introduce ValidateStructValues helpers in the forms package to enforce
type correctness and choice constraints on dynamically-typed values.
2026-03-17 18:31:22 +07:00
90e6313a6e Include generated services_specs into frontend code
All checks were successful
continuous-integration/drone/push Build is passing
This permit to prerender generator pages so they can be referenced
2026-03-17 13:33:17 +07:00
4f9a308a2d Fix service worker registration 2026-03-17 11:20:48 +07:00
167 changed files with 18589 additions and 376 deletions

113
checks/domain-expiration.go Normal file
View file

@ -0,0 +1,113 @@
package checks
import (
"context"
"fmt"
"math"
"strings"
"time"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/pkg/domaininfo"
)
const (
DEFAULT_WARNING_DAYS = 30
DEFAULT_CRITICAL_DAYS = 7
)
func init() {
RegisterChecker("domain-expiration", &DomainExpirationCheck{})
}
type DomainExpirationCheck struct{}
func (p *DomainExpirationCheck) ID() string { return "domain-expiration" }
func (p *DomainExpirationCheck) Name() string { return "Domain Expiration" }
func (p *DomainExpirationCheck) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{ApplyToDomain: true}
}
func (p *DomainExpirationCheck) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
RunOpts: []happydns.CheckerOptionDocumentation{
{Id: "domainName", Type: "string", Label: "Domain name", AutoFill: happydns.AutoFillDomainName, Required: true},
},
UserOpts: []happydns.CheckerOptionDocumentation{
{Id: "warningDays", Type: "number", Label: "Days before expiration to warn", Default: DEFAULT_WARNING_DAYS},
{Id: "criticalDays", Type: "number", Label: "Days before expiration to alert", Default: DEFAULT_CRITICAL_DAYS},
},
}
}
func (p *DomainExpirationCheck) RunCheck(ctx context.Context, options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
// 1. Extract domainName
domainName, ok := options["domainName"].(string)
if !ok || domainName == "" {
return nil, fmt.Errorf("domainName is required")
}
domainName = strings.TrimSuffix(domainName, ".")
// 2. Extract thresholds (with defaults)
warningDays := extractInt(options, "warningDays", DEFAULT_WARNING_DAYS)
criticalDays := extractInt(options, "criticalDays", DEFAULT_CRITICAL_DAYS)
// 3. Try RDAP, fallback to WHOIS
info, err := domaininfo.GetDomainRDAPInfo(ctx, happydns.Origin(domainName))
if err != nil {
info, err = domaininfo.GetDomainWhoisInfo(ctx, happydns.Origin(domainName))
if err != nil {
return nil, fmt.Errorf("failed to retrieve domain info: %w", err)
}
}
// 4. Check expiration date presence
if info.ExpirationDate == nil {
return &happydns.CheckResult{
Status: happydns.CheckResultStatusUnknown,
StatusLine: "Expiration date not available",
Report: info,
}, nil
}
// 5. Compute days remaining
daysUntil := int(math.Ceil(time.Until(*info.ExpirationDate).Hours() / 24))
// 6. Determine status
var status happydns.CheckResultStatus
var statusLine string
switch {
case daysUntil < 0:
status = happydns.CheckResultStatusCritical
statusLine = fmt.Sprintf("Domain expired %d day(s) ago (expired on %s)", -daysUntil, info.ExpirationDate.Format("2006-01-02"))
case daysUntil < criticalDays:
status = happydns.CheckResultStatusCritical
statusLine = fmt.Sprintf("Domain expires in %d day(s) (on %s)", daysUntil, info.ExpirationDate.Format("2006-01-02"))
case daysUntil < warningDays:
status = happydns.CheckResultStatusWarn
statusLine = fmt.Sprintf("Domain expires in %d day(s) (on %s)", daysUntil, info.ExpirationDate.Format("2006-01-02"))
default:
status = happydns.CheckResultStatusOK
statusLine = fmt.Sprintf("Domain valid until %s (%d days)", info.ExpirationDate.Format("2006-01-02"), daysUntil)
}
return &happydns.CheckResult{
Status: status,
StatusLine: statusLine,
Report: info,
}, nil
}
// extractInt reads an int/float64 option with a default fallback.
func extractInt(options happydns.CheckerOptions, key string, def int) int {
if v, ok := options[key]; ok {
switch n := v.(type) {
case int:
return n
case float64:
return int(n)
}
}
return def
}

95
checks/interface.go Normal file
View file

@ -0,0 +1,95 @@
// 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 checks provides the registry for domain health checkers.
// It allows individual checker implementations to self-register at startup
// via init() functions and exposes functions to retrieve registered checkers.
package checks // import "git.happydns.org/happyDomain/checks"
import (
"encoding/json"
"fmt"
"log"
"git.happydns.org/happyDomain/model"
)
// checkersList is the ordered list of all registered checks.
var checkersList map[string]happydns.Checker = map[string]happydns.Checker{}
// RegisterChecker declares the existence of the given check. It is intended to
// be called from init() functions in individual check files so that each check
// self-registers at program startup.
//
// If two checks try to register the same environment name the program will
// terminate: name collisions are a configuration error, not a runtime one.
func RegisterChecker(name string, checker happydns.Checker) {
log.Println("Registering new checker:")
checkersList[name] = checker
}
// GetCheckers returns the ordered list of all registered checks.
func GetCheckers() *map[string]happydns.Checker {
return &checkersList
}
// FindChecker returns the check registered under the given environment name,
// or an error if no check with that name exists.
func FindChecker(name string) (happydns.Checker, error) {
c, ok := checkersList[name]
if !ok {
return nil, fmt.Errorf("unable to find check %q", name)
}
return c, nil
}
// GetCheckInterval returns the checker's preferred scheduling bounds,
// or nil if the checker does not implement CheckerIntervalProvider.
func GetCheckInterval(checker happydns.Checker) *happydns.CheckIntervalSpec {
ip, ok := checker.(happydns.CheckerIntervalProvider)
if !ok {
return nil
}
spec := ip.CheckInterval()
return &spec
}
// GetHTMLReport renders an HTML report for the given checker and raw JSON report data.
// Returns (html, true, nil) if the checker supports HTML reports, or ("", false, nil) if not.
func GetHTMLReport(checker happydns.Checker, raw json.RawMessage) (string, bool, error) {
hr, ok := checker.(happydns.CheckerHTMLReporter)
if !ok {
return "", false, nil
}
html, err := hr.GetHTMLReport(raw)
return html, true, err
}
// GetMetrics extracts time-series metrics from a slice of check results.
// Returns (report, true, nil) if the checker supports metrics, or (nil, false, nil) if not.
func GetMetrics(checker happydns.Checker, results []*happydns.CheckResult) (*happydns.MetricsReport, bool, error) {
mr, ok := checker.(happydns.CheckerMetricsReporter)
if !ok {
return nil, false, nil
}
report, err := mr.ExtractMetrics(results)
return report, true, err
}

326
checks/ping.go Normal file
View file

@ -0,0 +1,326 @@
// 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 checks
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"strings"
"time"
probing "github.com/prometheus-community/pro-bing"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services/abstract"
)
func init() {
RegisterChecker("ping", &PingCheck{})
}
// PingReport contains the results of a ping check across one or more targets.
type PingReport struct {
Targets []PingTargetResult `json:"targets"`
}
// PingTargetResult contains the ping statistics for a single IP address.
type PingTargetResult struct {
Address string `json:"address"`
RTTMin float64 `json:"rtt_min"`
RTTAvg float64 `json:"rtt_avg"`
RTTMax float64 `json:"rtt_max"`
PacketLoss float64 `json:"packet_loss"`
Sent int `json:"sent"`
Received int `json:"received"`
}
type PingCheck struct{}
func (p *PingCheck) ID() string {
return "ping"
}
func (p *PingCheck) Name() string {
return "Ping (ICMP)"
}
func (p *PingCheck) CheckInterval() happydns.CheckIntervalSpec {
return happydns.CheckIntervalSpec{
Min: 1 * time.Minute,
Max: 1 * time.Hour,
Default: 5 * time.Minute,
}
}
func (p *PingCheck) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.Server"},
}
}
func (p *PingCheck) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
ServiceOpts: []happydns.CheckerOptionDocumentation{
{
Id: "warningRTT",
Type: "number",
Label: "Warning RTT threshold (ms)",
Default: float64(100),
},
{
Id: "criticalRTT",
Type: "uint",
Label: "Critical RTT threshold (ms)",
Default: float64(500),
},
{
Id: "warningPacketLoss",
Type: "uint",
Label: "Warning packet loss threshold (%)",
Default: float64(10),
},
{
Id: "criticalPacketLoss",
Type: "uint",
Label: "Critical packet loss threshold (%)",
Default: float64(50),
},
{
Id: "count",
Type: "uint",
Label: "Number of pings to send",
Default: float64(5),
},
},
RunOpts: []happydns.CheckerOptionDocumentation{
{
Id: "service",
Label: "Service",
AutoFill: happydns.AutoFillService,
},
},
}
}
func getFloatOption(options happydns.CheckerOptions, key string, defaultVal float64) float64 {
v, ok := options[key]
if !ok {
return defaultVal
}
switch val := v.(type) {
case float64:
return val
case json.Number:
f, err := val.Float64()
if err != nil {
return defaultVal
}
return f
default:
return defaultVal
}
}
func getIntOption(options happydns.CheckerOptions, key string, defaultVal int) int {
return int(getFloatOption(options, key, float64(defaultVal)))
}
// ipsFromServiceOption extracts the IP addresses directly from the auto-filled
// service body (abstract.Server). Only IPs actually present in the service
// definition are returned, so an IPv4-only service won't trigger IPv6 pings.
// The service JSON has the shape:
//
// {"_svctype":"abstract.Server","Service":{"A":{...},"AAAA":{...}}}
func ipsFromServiceOption(svc *happydns.ServiceMessage) []net.IP {
var server abstract.Server
if err := json.Unmarshal(svc.Service, &server); err != nil {
return nil
}
var ips []net.IP
if server.A != nil && len(server.A.A) > 0 {
ips = append(ips, server.A.A)
}
if server.AAAA != nil && len(server.AAAA.AAAA) > 0 {
ips = append(ips, server.AAAA.AAAA)
}
return ips
}
func (p *PingCheck) RunCheck(ctx context.Context, options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
service, ok := options["service"].(*happydns.ServiceMessage)
if !ok {
return nil, fmt.Errorf("service not defined")
}
if service.Type != "abstract.Server" {
return nil, fmt.Errorf("service is %s, expected abstract.Server", service.Type)
}
warningRTT := getFloatOption(options, "warningRTT", 100)
criticalRTT := getFloatOption(options, "criticalRTT", 500)
warningPacketLoss := getFloatOption(options, "warningPacketLoss", 10)
criticalPacketLoss := getFloatOption(options, "criticalPacketLoss", 50)
count := getIntOption(options, "count", 5)
if count < 1 {
count = 1
}
if count > 20 {
count = 20
}
// Prefer IPs from the service definition; fall back to live DNS.
// Using service IPs avoids pinging addresses not defined in the service
// (e.g. live IPv6 records that the service doesn't have).
var rawIPs []net.IP
if serviceIPs := ipsFromServiceOption(service); len(serviceIPs) > 0 {
rawIPs = serviceIPs
}
if len(rawIPs) == 0 {
return nil, fmt.Errorf("no IP addresses found for %s", service.Domain)
}
report := PingReport{}
var overallStatus happydns.CheckResultStatus = happydns.CheckResultStatusOK
var summaryParts []string
var errs []error
for _, ip := range rawIPs {
addr := ip.String()
pinger, err := probing.NewPinger(addr)
if err != nil {
errs = append(errs, fmt.Errorf("failed to create pinger for %s: %w", addr, err))
continue
}
pinger.Count = count
pinger.Timeout = time.Duration(count)*time.Second + 5*time.Second
if err = pinger.RunWithContext(ctx); err != nil {
errs = append(errs, fmt.Errorf("ping failed for %s: %w", addr, err))
continue
}
stats := pinger.Statistics()
target := PingTargetResult{
Address: addr,
RTTMin: float64(stats.MinRtt.Microseconds()) / 1000.0,
RTTAvg: float64(stats.AvgRtt.Microseconds()) / 1000.0,
RTTMax: float64(stats.MaxRtt.Microseconds()) / 1000.0,
PacketLoss: stats.PacketLoss,
Sent: stats.PacketsSent,
Received: stats.PacketsRecv,
}
report.Targets = append(report.Targets, target)
if target.PacketLoss >= criticalPacketLoss || target.RTTAvg >= criticalRTT {
overallStatus = happydns.CheckResultStatusCritical
} else if (target.PacketLoss >= warningPacketLoss || target.RTTAvg >= warningRTT) && overallStatus > happydns.CheckResultStatusWarn {
overallStatus = happydns.CheckResultStatusWarn
}
summaryParts = append(summaryParts, fmt.Sprintf("%s: %.1fms avg, %.0f%% loss", addr, target.RTTAvg, target.PacketLoss))
}
// If no IP responded at all, return the combined errors as a fatal error.
if len(report.Targets) == 0 {
return nil, errors.Join(errs...)
}
return &happydns.CheckResult{
Status: overallStatus,
StatusLine: strings.Join(summaryParts, " | "),
Report: report,
}, errors.Join(errs...)
}
// ExtractMetrics implements happydns.CheckerMetricsReporter.
func (p *PingCheck) ExtractMetrics(results []*happydns.CheckResult) (*happydns.MetricsReport, error) {
type seriesKey struct {
metric string
address string
}
seriesMap := map[seriesKey]*happydns.MetricSeries{}
var seriesOrder []seriesKey
for _, result := range results {
if result.Report == nil {
continue
}
raw, err := json.Marshal(result.Report)
if err != nil {
continue
}
var report PingReport
if err := json.Unmarshal(raw, &report); err != nil {
continue
}
for _, target := range report.Targets {
ts := result.ExecutedAt
metrics := []struct {
suffix string
label string
unit string
value float64
}{
{"rtt_avg", "RTT Avg", "ms", target.RTTAvg},
{"rtt_min", "RTT Min", "ms", target.RTTMin},
{"rtt_max", "RTT Max", "ms", target.RTTMax},
{"packet_loss", "Packet Loss", "%", target.PacketLoss},
}
for _, m := range metrics {
key := seriesKey{metric: m.suffix, address: target.Address}
s, exists := seriesMap[key]
if !exists {
s = &happydns.MetricSeries{
Name: fmt.Sprintf("%s_%s", m.suffix, target.Address),
Label: fmt.Sprintf("%s (%s)", m.label, target.Address),
Unit: m.unit,
}
seriesMap[key] = s
seriesOrder = append(seriesOrder, key)
}
s.Points = append(s.Points, happydns.MetricPoint{
Timestamp: ts,
Value: m.value,
})
}
}
}
var series []happydns.MetricSeries
for _, key := range seriesOrder {
series = append(series, *seriesMap[key])
}
return &happydns.MetricsReport{Series: series}, nil
}

615
checks/zonemaster.go Normal file
View file

@ -0,0 +1,615 @@
package checks
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"sort"
"strings"
"time"
"git.happydns.org/happyDomain/model"
)
func init() {
RegisterChecker("zonemaster", &ZonemasterCheck{})
}
type ZonemasterCheck struct{}
func (p *ZonemasterCheck) ID() string {
return "zonemaster"
}
func (p *ZonemasterCheck) Name() string {
return "Zonemaster"
}
func (p *ZonemasterCheck) CheckInterval() happydns.CheckIntervalSpec {
return happydns.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
}
}
func (p *ZonemasterCheck) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToDomain: true,
}
}
func (p *ZonemasterCheck) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
RunOpts: []happydns.CheckerOptionDocumentation{
{
Id: "domainName",
Type: "string",
Label: "Domain name to check",
AutoFill: happydns.AutoFillDomainName,
Required: true,
},
{
Id: "profile",
Type: "string",
Label: "Profile",
Placeholder: "default",
Default: "default",
},
},
UserOpts: []happydns.CheckerOptionDocumentation{
{
Id: "language",
Type: "string",
Label: "Result language",
Default: "en",
Choices: []string{
"en", // English
"fr", // French
"de", // German
"es", // Spanish
"sv", // Swedish
"da", // Danish
"fi", // Finnish
"nb", // Norwegian Bokmål
"nl", // Dutch
"pt", // Portuguese
},
},
},
AdminOpts: []happydns.CheckerOptionDocumentation{
{
Id: "zonemasterAPIURL",
Type: "string",
Label: "Zonemaster API URL",
Placeholder: "https://zonemaster.net/api",
Default: "https://zonemaster.net/api",
},
},
}
}
// JSON-RPC request/response structures
type jsonRPCRequest struct {
Jsonrpc string `json:"jsonrpc"`
Method string `json:"method"`
Params any `json:"params"`
ID int `json:"id"`
}
type jsonRPCResponse struct {
Jsonrpc string `json:"jsonrpc"`
Result json.RawMessage `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
ID int `json:"id"`
}
// Zonemaster API structures
type startTestParams struct {
Domain string `json:"domain"`
Profile string `json:"profile,omitempty"`
IPv4 bool `json:"ipv4,omitempty"`
IPv6 bool `json:"ipv6,omitempty"`
}
type testProgressParams struct {
TestID string `json:"test_id"`
}
type getResultsParams struct {
ID string `json:"id"`
Language string `json:"language"`
}
type testResult struct {
Module string `json:"module"`
Message string `json:"message"`
Level string `json:"level"`
Testcase string `json:"testcase,omitempty"`
}
type zonemasterResults struct {
CreatedAt string `json:"created_at"`
HashID string `json:"hash_id"`
Language string `json:"language,omitempty"`
Params map[string]any `json:"params"`
Results []testResult `json:"results"`
TestcaseDescriptions map[string]string `json:"testcase_descriptions,omitempty"`
}
func (p *ZonemasterCheck) callJSONRPC(ctx context.Context, apiURL, method string, params any) (json.RawMessage, error) {
reqBody := jsonRPCRequest{
Jsonrpc: "2.0",
Method: method,
Params: params,
ID: 1,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
var rpcResp jsonRPCResponse
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("API error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
}
return rpcResp.Result, nil
}
func (p *ZonemasterCheck) RunCheck(ctx context.Context, options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
// Extract options
domainName, ok := options["domainName"].(string)
if !ok || domainName == "" {
return nil, fmt.Errorf("domainName is required")
}
domainName = strings.TrimSuffix(domainName, ".")
apiURL, ok := options["zonemasterAPIURL"].(string)
if !ok || apiURL == "" {
return nil, fmt.Errorf("zonemasterAPIURL is required")
}
apiURL = strings.TrimSuffix(apiURL, "/")
language := "en"
if lang, ok := options["language"].(string); ok && lang != "" {
language = lang
}
profile := "default"
if prof, ok := options["profile"].(string); ok && prof != "" {
profile = prof
}
// Step 1: Start the test
startParams := startTestParams{
Domain: domainName,
Profile: profile,
IPv4: true,
IPv6: true,
}
result, err := p.callJSONRPC(ctx, apiURL, "start_domain_test", startParams)
if err != nil {
return nil, fmt.Errorf("failed to start test: %w", err)
}
var testID string
if err = json.Unmarshal(result, &testID); err != nil {
return nil, fmt.Errorf("failed to parse test ID: %w", err)
}
if testID == "" {
return nil, fmt.Errorf("received empty test ID")
}
// Step 2: Poll for test completion
progressParams := testProgressParams{TestID: testID}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("test cancelled (test ID: %s): %w", testID, ctx.Err())
case <-ticker.C:
result, err := p.callJSONRPC(ctx, apiURL, "test_progress", progressParams)
if err != nil {
return nil, fmt.Errorf("failed to test progress: %w", err)
}
var progress float64
if err := json.Unmarshal(result, &progress); err != nil {
return nil, fmt.Errorf("failed to parse progress: %w", err)
}
if progress >= 100 {
goto testComplete
}
}
}
testComplete:
// Step 3: Get test results
resultsParams := getResultsParams{
ID: testID,
Language: language,
}
result, err = p.callJSONRPC(ctx, apiURL, "get_test_results", resultsParams)
if err != nil {
return nil, fmt.Errorf("failed to get results: %w", err)
}
var results zonemasterResults
if err := json.Unmarshal(result, &results); err != nil {
return nil, fmt.Errorf("failed to parse results: %w", err)
}
results.Language = language
// Analyze results to determine overall status
var (
errorCount int
warningCount int
infoCount int
criticalMsgs []string
)
for _, r := range results.Results {
switch strings.ToUpper(r.Level) {
case "CRITICAL", "ERROR":
errorCount++
if len(criticalMsgs) < 5 { // Keep first 5 critical messages
criticalMsgs = append(criticalMsgs, r.Message)
}
case "WARNING":
warningCount++
case "INFO", "NOTICE":
infoCount++
}
}
// Determine status
var status happydns.CheckResultStatus
var statusLine string
if errorCount > 0 {
status = happydns.CheckResultStatusCritical
statusLine = fmt.Sprintf("%d error(s), %d warning(s) found", errorCount, warningCount)
if len(criticalMsgs) > 0 {
statusLine += ": " + strings.Join(criticalMsgs[:min(2, len(criticalMsgs))], "; ")
}
} else if warningCount > 0 {
status = happydns.CheckResultStatusWarn
statusLine = fmt.Sprintf("%d warning(s) found", warningCount)
} else {
status = happydns.CheckResultStatusOK
statusLine = fmt.Sprintf("All checks passed (%d checks)", len(results.Results))
}
return &happydns.CheckResult{
Status: status,
StatusLine: statusLine,
Report: results,
}, nil
}
// ── HTML report ───────────────────────────────────────────────────────────────
// zmLevelDisplayOrder defines the severity order used for sorting and display.
var zmLevelDisplayOrder = []string{"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"}
var zmLevelRank = func() map[string]int {
m := make(map[string]int, len(zmLevelDisplayOrder))
for i, l := range zmLevelDisplayOrder {
m[l] = len(zmLevelDisplayOrder) - i
}
return m
}()
type zmLevelCount struct {
Level string
Count int
}
type zmModuleGroup struct {
Name string
Position int // first-seen index, used as tiebreaker in sort
Results []testResult
Levels []zmLevelCount // sorted by severity desc, zeros omitted
Worst string
Open bool
}
type zmTemplateData struct {
Domain string
CreatedAt string
HashID string
Language string
Modules []zmModuleGroup
Totals []zmLevelCount // sorted by severity desc, zeros omitted
}
var zonemasterHTMLTemplate = template.Must(
template.New("zonemaster").
Funcs(template.FuncMap{
"badgeClass": func(level string) string {
switch strings.ToUpper(level) {
case "CRITICAL":
return "badge-critical"
case "ERROR":
return "badge-error"
case "WARNING":
return "badge-warning"
case "NOTICE":
return "badge-notice"
case "INFO":
return "badge-info"
default:
return "badge-debug"
}
},
}).
Parse(`<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zonemaster{{if .Domain}} {{.Domain}}{{end}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
a { color: inherit; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
/* Header card */
.hd {
background: #fff;
border-radius: 10px;
padding: 1rem 1.25rem 1.1rem;
margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd h1 { margin: 0 0 .2rem; font-size: 1.15rem; font-weight: 700; }
.hd .meta { color: #6b7280; font-size: .82rem; margin-bottom: .6rem; }
.totals { display: flex; gap: .35rem; flex-wrap: wrap; }
/* Badges */
.badge {
display: inline-flex; align-items: center;
padding: .18em .55em;
border-radius: 9999px;
font-size: .72rem; font-weight: 700;
letter-spacing: .02em; white-space: nowrap;
}
.badge-critical { background: #fee2e2; color: #991b1b; }
.badge-error { background: #ffedd5; color: #9a3412; }
.badge-warning { background: #fef3c7; color: #92400e; }
.badge-notice { background: #e0f2fe; color: #075985; }
.badge-info { background: #dbeafe; color: #1e40af; }
.badge-debug { background: #f3f4f6; color: #4b5563; }
/* Accordion */
details {
background: #fff;
border-radius: 8px;
margin-bottom: .45rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
overflow: hidden;
}
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem;
cursor: pointer;
user-select: none;
list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before {
content: "▶";
font-size: .65rem;
color: #9ca3af;
transition: transform .15s;
flex-shrink: 0;
}
details[open] > summary::before { transform: rotate(90deg); }
.mod-name { font-weight: 600; flex: 1; font-size: .9rem; }
.mod-badges { display: flex; gap: .25rem; flex-wrap: wrap; }
/* Result rows */
.results { border-top: 1px solid #f3f4f6; }
.row {
display: grid;
grid-template-columns: max-content 1fr;
gap: .6rem;
padding: .45rem 1rem;
border-bottom: 1px solid #f9fafb;
align-items: start;
}
.row:last-child { border-bottom: none; }
.row-msg { color: #374151; }
.row-tc { font-size: .75rem; color: #9ca3af; }
</style>
</head>
<body>
<div class="hd">
<h1>Zonemaster{{if .Domain}} <code>{{.Domain}}</code>{{end}}</h1>
<div class="meta">
{{- if .CreatedAt}}Run at {{.CreatedAt}}{{end -}}
{{- if and .CreatedAt .HashID}} &middot; {{end -}}
{{- if .HashID}}ID: <code>{{.HashID}}</code>{{end -}}
</div>
<div class="totals">
{{- range .Totals}}
<span class="badge {{badgeClass .Level}}">{{.Level}}&nbsp;{{.Count}}</span>
{{- end}}
</div>
</div>
{{range .Modules -}}
<details{{if .Open}} open{{end}}>
<summary>
<span class="mod-name">{{.Name}}</span>
<span class="mod-badges">
{{- range .Levels}}
<span class="badge {{badgeClass .Level}}">{{.Count}}</span>
{{- end}}
</span>
</summary>
<div class="results">
{{- range .Results}}
<div class="row">
<span class="badge {{badgeClass .Level}}">{{.Level}}</span>
<div>
<div class="row-msg">{{.Message}}</div>
{{- if .Testcase}}<div class="row-tc">{{.Testcase}}</div>{{end}}
</div>
</div>
{{- end}}
</div>
</details>
{{end -}}
</body>
</html>`),
)
// GetHTMLReport implements happydns.CheckerHTMLReporter.
func (p *ZonemasterCheck) GetHTMLReport(raw json.RawMessage) (string, error) {
var results zonemasterResults
if err := json.Unmarshal(raw, &results); err != nil {
return "", fmt.Errorf("failed to unmarshal zonemaster results: %w", err)
}
// Group results by module, preserving first-seen order.
moduleOrder := []string{}
moduleMap := map[string][]testResult{}
for _, r := range results.Results {
if _, seen := moduleMap[r.Module]; !seen {
moduleOrder = append(moduleOrder, r.Module)
}
moduleMap[r.Module] = append(moduleMap[r.Module], r)
}
totalCounts := map[string]int{}
var modules []zmModuleGroup
for _, name := range moduleOrder {
rs := moduleMap[name]
counts := map[string]int{}
for _, r := range rs {
lvl := strings.ToUpper(r.Level)
counts[lvl]++
totalCounts[lvl]++
}
// Find worst level and build sorted level-count slice.
worst := ""
worstRank := -1
var levels []zmLevelCount
for _, l := range zmLevelDisplayOrder {
if n, ok := counts[l]; ok && n > 0 {
levels = append(levels, zmLevelCount{Level: l, Count: n})
if zmLevelRank[l] > worstRank {
worstRank = zmLevelRank[l]
worst = l
}
}
}
// Append any unknown levels last.
for l, n := range counts {
if _, known := zmLevelRank[l]; !known {
levels = append(levels, zmLevelCount{Level: l, Count: n})
}
}
modules = append(modules, zmModuleGroup{
Name: name,
Position: len(modules),
Results: rs,
Levels: levels,
Worst: worst,
Open: worst == "CRITICAL" || worst == "ERROR",
})
}
// Sort modules: most severe first, then by original appearance order.
sort.Slice(modules, func(i, j int) bool {
ri, rj := zmLevelRank[modules[i].Worst], zmLevelRank[modules[j].Worst]
if ri != rj {
return ri > rj
}
return modules[i].Position < modules[j].Position
})
// Build sorted totals slice.
var totals []zmLevelCount
for _, l := range zmLevelDisplayOrder {
if n, ok := totalCounts[l]; ok && n > 0 {
totals = append(totals, zmLevelCount{Level: l, Count: n})
}
}
domain := ""
if d, ok := results.Params["domain"]; ok {
domain = fmt.Sprintf("%v", d)
}
lang := results.Language
if lang == "" {
lang = "en"
}
data := zmTemplateData{
Domain: domain,
CreatedAt: results.CreatedAt,
HashID: results.HashID,
Language: lang,
Modules: modules,
Totals: totals,
}
var buf strings.Builder
if err := zonemasterHTMLTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to render zonemaster HTML report: %w", err)
}
return buf.String(), nil
}

View file

@ -33,6 +33,7 @@ import (
"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"
@ -56,8 +57,10 @@ func main() {
}
if Version == "custom-build" {
controller.HDVersion.Version = versioninfo.Short()
metrics.SetBuildInfo(versioninfo.Short())
} else {
versioninfo.Version = Version
metrics.SetBuildInfo(Version)
}
log.Println("This is happyDomain", versioninfo.Short())

View file

@ -24,6 +24,7 @@ package main
//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

11
go.mod
View file

@ -17,10 +17,15 @@ require (
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/lib/pq v1.11.2
github.com/likexian/whois v1.15.7
github.com/likexian/whois-parser v1.24.21
github.com/miekg/dns v1.1.72
github.com/mileusna/useragent v1.3.5
github.com/openrdap/rdap v0.9.1
github.com/oracle/nosql-go-sdk v1.4.7
github.com/ovh/go-ovh v1.9.0
github.com/prometheus-community/pro-bing v0.8.0
github.com/prometheus/client_golang v1.23.2
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
@ -53,6 +58,8 @@ require (
github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/Shopify/goreferrer v0.0.0-20250617153402-88c1d9a79b05 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0 // indirect
github.com/alecthomas/kingpin/v2 v2.4.0 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
@ -157,6 +164,7 @@ require (
github.com/labstack/echo/v4 v4.15.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/likexian/gokit v0.25.16 // indirect
github.com/luadns/luadns-go v0.3.0 // indirect
github.com/mailgun/raymond/v2 v2.0.48 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
@ -179,7 +187,6 @@ 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/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.0 // indirect
@ -207,6 +214,7 @@ require (
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/vultr/govultr/v2 v2.17.2 // indirect
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
github.com/yosssi/ace v0.0.5 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
@ -216,7 +224,6 @@ require (
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect

21
go.sum
View file

@ -62,6 +62,10 @@ 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=
@ -218,8 +222,6 @@ github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kb
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
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-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-gandi/go-gandi v0.7.0 h1:gsP33dUspsN1M+ZW9HEgHchK9HiaSkYnltO73RHhSZA=
@ -432,6 +434,12 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
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=
@ -484,6 +492,8 @@ 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=
@ -509,6 +519,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=
@ -571,6 +583,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=
@ -623,6 +636,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=
@ -732,8 +747,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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=

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).
@ -182,10 +190,17 @@ func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happydns.Record, err error) {
var records models.Records
start := time.Now()
defer func() {
if a := recover(); a != nil {
err = fmt.Errorf("%s", a)
}
status := "success"
if err != nil {
status = "error"
}
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, "get_zone_records", status).Inc()
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, "get_zone_records").Observe(time.Since(start).Seconds())
}()
records, err = p.DNSServiceProvider.GetZoneRecords(strings.TrimSuffix(domain, "."), nil)
@ -205,6 +220,16 @@ 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) {
start := time.Now()
defer func() {
status := "success"
if err != nil {
status = "error"
}
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, "get_zone_corrections", status).Inc()
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, "get_zone_corrections").Observe(time.Since(start).Seconds())
}()
var dc *models.DomainConfig
dc, err = NewDNSControlDomainConfig(strings.TrimSuffix(domain, "."), rrs)
if err != nil {
@ -255,23 +280,47 @@ 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) {
start := time.Now()
defer func() {
status := "success"
if err != nil {
status = "error"
}
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, "create_domain", status).Inc()
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, "create_domain").Observe(time.Since(start).Seconds())
}()
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) {
start := time.Now()
defer func() {
status := "success"
if err != nil {
status = "error"
}
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, "list_zones", status).Inc()
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, "list_zones").Observe(time.Since(start).Seconds())
}()
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,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 controller
import (
"net/http"
"github.com/gin-gonic/gin"
apicontroller "git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
// CheckerController handles admin-level operations.
// All methods in this controller work with admin-scoped options (nil user/domain/service IDs).
type CheckerController struct {
*apicontroller.BaseCheckerController
}
func NewCheckerController(checkerService happydns.CheckerUsecase) *CheckerController {
return &CheckerController{
BaseCheckerController: apicontroller.NewBaseCheckerController(checkerService),
}
}
// CheckerHandler is a middleware that retrieves a check by name and sets it in the context.
func (uc *CheckerController) CheckerHandler(c *gin.Context) {
cname := c.Param("cname")
checker, err := uc.BaseCheckerController.GetCheckerService().GetChecker(cname)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Check not found"})
return
}
c.Set("checker", checker)
c.Next()
}
// CheckerOptionHandler is a middleware that retrieves a specific option and sets it in the context.
func (uc *CheckerController) CheckerOptionHandler(c *gin.Context) {
cname := c.Param("cname")
optname := c.Param("optname")
opts, err := uc.BaseCheckerController.GetCheckerService().GetCheckerOptions(cname, nil, nil, nil)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.Set("option", (*opts)[optname])
c.Next()
}
// ListCheckers retrieves all available checks.
//
// @Summary List checkers (admin)
// @Schemes
// @Description Returns a list of all available checks with their version information.
// @Tags checks
// @Accept json
// @Produce json
// @Success 200 {object} map[string]happydns.CheckerResponse "Map of checker names to info"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks [get]
func (uc *CheckerController) ListCheckers(c *gin.Context) {
uc.BaseCheckerController.ListCheckers(c)
}
// GetCheckerStatus retrieves the status and available options for a check.
//
// @Summary Get check info
// @Schemes
// @Description Retrieves the status information and available options for a specific checker.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Success 200 {object} happydns.CheckerResponse "Checker status with version info and available options"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Router /checks/{cname} [get]
func (uc *CheckerController) GetCheckerStatus(c *gin.Context) {
uc.BaseCheckerController.GetCheckerStatus(c)
}
// GetCheckerOptions retrieves all options for a check.
//
// @Summary Get check options (admin)
// @Schemes
// @Description Retrieves all configuration options for a specific check.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Success 200 {object} happydns.CheckerOptions "Checker options as key-value pairs"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [get]
func (uc *CheckerController) GetCheckerOptions(c *gin.Context) {
cname := c.Param("cname")
// Get admin-level options (nil user/domain/service IDs)
uc.GetCheckerOptionsWithScope(c, cname, nil, nil, nil)
}
// AddCheckerOptions adds or overwrites specific admin-level options for a check.
//
// @Summary Add checker options
// @Schemes
// @Description Adds or overwrites specific configuration options for a checker without affecting other options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param body body happydns.SetCheckerOptionsRequest true "Options to add or overwrite"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [post]
func (uc *CheckerController) AddCheckerOptions(c *gin.Context) {
cname := c.Param("cname")
// Add admin-level options (nil user/domain/service IDs)
uc.AddCheckerOptionsWithScope(c, cname, nil, nil, nil)
}
// ChangeCheckerOptions replaces all options for a checker.
//
// @Summary Replace checker options (admin)
// @Schemes
// @Description Replaces all configuration options for a check with the provided options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param body body happydns.SetCheckerOptionsRequest true "New complete set of options"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [put]
func (uc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
cname := c.Param("cname")
// Replace admin-level options (nil user/domain/service IDs)
uc.ChangeCheckerOptionsWithScope(c, cname, nil, nil, nil)
}
// GetCheckerOption retrieves a specific option value for a checker.
//
// @Summary Get checker option (admin)
// @Schemes
// @Description Retrieves the value of a specific configuration option for a checker.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param optname path string true "Option name"
// @Success 200 {object} object "Option value (type varies)"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options/{optname} [get]
func (uc *CheckerController) GetCheckerOption(c *gin.Context) {
uc.GetCheckerOptionValue(c)
}
// SetCheckerOption sets or updates a specific option value for a checker.
//
// @Summary Set checker option (admin)
// @Schemes
// @Description Sets or updates the value of a specific configuration option for a checker.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param optname path string true "Option name"
// @Param body body object true "Option value (type varies by option)"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options/{optname} [put]
func (uc *CheckerController) SetCheckerOption(c *gin.Context) {
cname := c.Param("cname")
optname := c.Param("optname")
// Set admin-level option (nil user/domain/service IDs)
uc.SetCheckerOptionWithScope(c, cname, optname, nil, nil, nil)
}

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,102 @@
// 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/model"
)
// AdminSchedulerController handles admin operations on the test scheduler
type AdminSchedulerController struct {
scheduler happydns.SchedulerUsecase
}
func NewAdminSchedulerController(scheduler happydns.SchedulerUsecase) *AdminSchedulerController {
return &AdminSchedulerController{scheduler: scheduler}
}
// GetSchedulerStatus returns the current scheduler state
//
// @Summary Get scheduler status
// @Description Returns the current state of the test scheduler including worker count, queue size, and upcoming schedules
// @Tags scheduler
// @Produce json
// @Success 200 {object} happydns.SchedulerStatus
// @Router /scheduler [get]
func (ctrl *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) {
c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus())
}
// EnableScheduler enables the test scheduler at runtime
//
// @Summary Enable scheduler
// @Description Enables the test scheduler at runtime without restarting the server
// @Tags scheduler
// @Success 200 {object} happydns.SchedulerStatus
// @Failure 500 {object} happydns.ErrorResponse
// @Router /scheduler/enable [post]
func (ctrl *AdminSchedulerController) EnableScheduler(c *gin.Context) {
if err := ctrl.scheduler.SetEnabled(true); err != nil {
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus())
}
// DisableScheduler disables the test scheduler at runtime
//
// @Summary Disable scheduler
// @Description Disables the test scheduler at runtime without restarting the server
// @Tags scheduler
// @Success 200 {object} happydns.SchedulerStatus
// @Failure 500 {object} happydns.ErrorResponse
// @Router /scheduler/disable [post]
func (ctrl *AdminSchedulerController) DisableScheduler(c *gin.Context) {
if err := ctrl.scheduler.SetEnabled(false); err != nil {
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus())
}
// RescheduleUpcoming randomizes the next run time of all enabled schedules
// within their respective intervals to spread load evenly.
//
// @Summary Reschedule upcoming tests
// @Description Randomizes the next run time of all enabled schedules within their intervals to spread load
// @Tags scheduler
// @Produce json
// @Success 200 {object} map[string]int
// @Failure 500 {object} happydns.ErrorResponse
// @Router /scheduler/reschedule-upcoming [post]
func (ctrl *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) {
n, err := ctrl.scheduler.RescheduleUpcomingChecks()
if err != nil {
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rescheduled": n})
}

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-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
)
func declareChecksRoutes(router *gin.RouterGroup, dep Dependencies) {
cc := controller.NewCheckerController(dep.Checker)
apiChecksRoutes := router.Group("/checks")
apiChecksRoutes.GET("", cc.ListCheckers)
apiCheckerRoutes := apiChecksRoutes.Group("/:cname")
apiCheckerRoutes.Use(cc.CheckerHandler)
apiCheckerRoutes.GET("", cc.GetCheckerStatus)
//apiCheckerRoutes.POST("", tpc.ChangeCheckerStatus)
apiCheckerRoutes.GET("/options", cc.GetCheckerOptions)
apiCheckerRoutes.POST("/options", cc.AddCheckerOptions)
apiCheckerRoutes.PUT("/options", cc.ChangeCheckerOptions)
apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options/:optname")
apiCheckerOptionsRoutes.Use(cc.CheckerOptionHandler)
apiCheckerOptionsRoutes.GET("", cc.GetCheckerOption)
apiCheckerOptionsRoutes.PUT("", cc.SetCheckerOption)
}

View file

@ -32,6 +32,8 @@ import (
// Dependencies holds all use cases required to register the admin API routes.
type Dependencies struct {
AuthUser happydns.AuthUserUsecase
Checker happydns.CheckerUsecase
CheckScheduler happydns.SchedulerUsecase
Domain happydns.DomainUsecase
Provider happydns.ProviderUsecase
RemoteZoneImporter happydns.RemoteZoneImporterUsecase
@ -48,7 +50,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage,
declareBackupRoutes(cfg, apiRoutes, s)
declareDomainRoutes(apiRoutes, dep, s)
declareChecksRoutes(apiRoutes, dep)
declareProviderRoutes(apiRoutes, dep, s)
declareSchedulerRoutes(apiRoutes, dep)
declareSessionsRoutes(cfg, apiRoutes, s)
declareUserAuthsRoutes(apiRoutes, dep, s)
declareUsersRoutes(apiRoutes, dep, s)

View file

@ -0,0 +1,38 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
)
func declareSchedulerRoutes(router *gin.RouterGroup, dep Dependencies) {
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,170 @@
// 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/checks"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// BaseCheckerController contains shared functionality for check controllers.
// It provides common methods that can be used by both admin and user-scoped controllers.
type BaseCheckerController struct {
checkerService happydns.CheckerUsecase
}
func NewBaseCheckerController(checkerService happydns.CheckerUsecase) *BaseCheckerController {
return &BaseCheckerController{
checkerService,
}
}
// GetCheckerService returns the check service for use by derived controllers.
func (bc *BaseCheckerController) GetCheckerService() happydns.CheckerUsecase {
return bc.checkerService
}
// ListCheckers retrieves all available checks.
func (bc *BaseCheckerController) ListCheckers(c *gin.Context) {
checkers, err := bc.checkerService.ListCheckers()
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
res := map[string]happydns.CheckerResponse{}
for name, checker := range *checkers {
_, hasHTML := checker.(happydns.CheckerHTMLReporter)
_, hasMetrics := checker.(happydns.CheckerMetricsReporter)
res[name] = happydns.CheckerResponse{
ID: name,
Name: checker.Name(),
Availability: checker.Availability(),
Options: checker.Options(),
Interval: checks.GetCheckInterval(checker),
HasHTMLReport: hasHTML,
HasMetrics: hasMetrics,
}
}
happydns.ApiResponse(c, res, nil)
}
// GetCheckerStatus retrieves the status and available options for a check.
func (bc *BaseCheckerController) GetCheckerStatus(c *gin.Context) {
checker := c.MustGet("checker").(happydns.Checker)
_, hasHTML := checker.(happydns.CheckerHTMLReporter)
_, hasMetrics := checker.(happydns.CheckerMetricsReporter)
c.JSON(http.StatusOK, happydns.CheckerResponse{
ID: checker.ID(),
Name: checker.Name(),
Availability: checker.Availability(),
Options: checker.Options(),
Interval: checks.GetCheckInterval(checker),
HasHTMLReport: hasHTML,
HasMetrics: hasMetrics,
})
}
// getDomainAndServiceIDFromContext extracts optional domainID and serviceID from the gin context.
func getDomainAndServiceIDFromContext(c *gin.Context) (domainID *happydns.Identifier, serviceID *happydns.Identifier) {
if dn, ok := c.Get("domain"); ok {
domainID = &dn.(*happydns.Domain).Id
}
if svcid, ok := c.Get("serviceid"); ok {
tmp := svcid.(happydns.Identifier)
serviceID = &tmp
}
return
}
// GetCheckerOptionsWithScope retrieves all options for a check with the given scope.
func (bc *BaseCheckerController) GetCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
opts, err := bc.checkerService.GetCheckerOptions(cname, userId, domainId, serviceId)
// For non-basic type, stringify
if opts != nil {
for i, opt := range *opts {
if svc, ok := opt.(*happydns.ServiceMessage); ok {
(*opts)[i] = svc.Type + ": " + svc.Comment
}
}
}
happydns.ApiResponse(c, opts, err)
}
// AddCheckerOptionsWithScope adds or overwrites specific options for a check with the given scope.
func (bc *BaseCheckerController) AddCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
var req happydns.SetCheckerOptionsRequest
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
err = bc.checkerService.OverwriteSomeCheckerOptions(cname, userId, domainId, serviceId, req.Options)
happydns.ApiResponse(c, true, err)
}
// ChangeCheckerOptionsWithScope replaces all options for a check with the given scope.
func (bc *BaseCheckerController) ChangeCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
var req happydns.SetCheckerOptionsRequest
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
err = bc.checkerService.SetCheckerOptions(cname, userId, domainId, serviceId, req.Options)
happydns.ApiResponse(c, true, err)
}
// GetCheckerOptionValue retrieves a specific option value from the context.
func (bc *BaseCheckerController) GetCheckerOptionValue(c *gin.Context) {
opt := c.MustGet("option")
happydns.ApiResponse(c, opt, nil)
}
// SetCheckerOptionWithScope sets or updates a specific option value for a check with the given scope.
func (bc *BaseCheckerController) SetCheckerOptionWithScope(c *gin.Context, cname string, optname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
var req any
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
po := happydns.CheckerOptions{}
po[optname] = req
err = bc.checkerService.OverwriteSomeCheckerOptions(cname, userId, domainId, serviceId, po)
happydns.ApiResponse(c, true, err)
}

View file

@ -0,0 +1,247 @@
// 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/model"
)
// CheckerController handles user-scoped check operations for the main API.
// All methods work with options scoped to the authenticated user.
type CheckerController struct {
*BaseCheckerController
}
func NewCheckerController(checkerService happydns.CheckerUsecase) *CheckerController {
return &CheckerController{
BaseCheckerController: NewBaseCheckerController(checkerService),
}
}
// ListCheckers retrieves all available checks.
//
// @Summary List all checks
// @Schemes
// @Description Returns a list of all available checks with their version information.
// @Tags checks
// @Accept json
// @Produce json
// @Success 200 {object} map[string]happydns.CheckerResponse "Map of check names to version info"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks [get]
func (uc *CheckerController) ListCheckers(c *gin.Context) {
uc.BaseCheckerController.ListCheckers(c)
}
// GetCheckerStatus retrieves the status and available options for a check.
//
// @Summary Get check status
// @Schemes
// @Description Retrieves the status information and available options for a specific check.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Check name"
// @Success 200 {object} happydns.CheckerResponse "Check status with version info and available options"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Router /checks/{cname} [get]
func (uc *CheckerController) GetCheckerStatus(c *gin.Context) {
uc.BaseCheckerController.GetCheckerStatus(c)
}
// CheckerHandler is a middleware that retrieves a check by name and sets it in the context.
func (uc *CheckerController) CheckerHandler(c *gin.Context) {
cname := c.Param("cname")
check, err := uc.checkerService.GetChecker(cname)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Check not found"})
return
}
c.Set("checker", check)
c.Next()
}
// CheckerOptionHandler is a middleware that retrieves a specific check option for the authenticated user and sets it in the context.
func (uc *CheckerController) CheckerOptionHandler(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cname")
optname := c.Param("optname")
opts, err := uc.checkerService.GetCheckerOptions(cname, &user.Id, nil, nil)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.Set("option", (*opts)[optname])
c.Next()
}
// GetCheckerOptions retrieves all options for a check for the authenticated user.
//
// @Summary Get check options
// @Schemes
// @Description Retrieves all configuration options for a specific check for the authenticated user.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Check name"
// @Param domain path string false "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Success 200 {object} happydns.CheckerOptions "Check options as key-value pairs"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [get]
// @Router /domains/{domain}/checks/{cname}/options [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options [get]
func (uc *CheckerController) GetCheckerOptions(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cname")
domainID, serviceID := getDomainAndServiceIDFromContext(c)
uc.GetCheckerOptionsWithScope(c, cname, &user.Id, domainID, serviceID)
}
// AddCheckerOptions adds or overwrites specific options for a check for the authenticated user.
//
// @Summary Add check options
// @Schemes
// @Description Adds or overwrites specific configuration options for a check for the authenticated user without affecting other options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Check name"
// @Param domain path string false "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param body body happydns.SetCheckerOptionsRequest true "Options to add or overwrite"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [post]
// @Router /domains/{domain}/checks/{cname}/options [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options [post]
func (uc *CheckerController) AddCheckerOptions(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cname")
domainID, serviceID := getDomainAndServiceIDFromContext(c)
uc.AddCheckerOptionsWithScope(c, cname, &user.Id, domainID, serviceID)
}
// ChangeCheckerOptions replaces all options for a check for the authenticated user.
//
// @Summary Replace check options
// @Schemes
// @Description Replaces all configuration options for a check for the authenticated user with the provided options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param domain path string false "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param body body happydns.SetCheckerOptionsRequest true "New complete set of options"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [put]
// @Router /domains/{domain}/checks/{cname}/options [put]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options [put]
func (uc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cname")
domainID, serviceID := getDomainAndServiceIDFromContext(c)
uc.ChangeCheckerOptionsWithScope(c, cname, &user.Id, domainID, serviceID)
}
// GetCheckerOption retrieves a specific option value for a check for the authenticated user.
//
// @Summary Get check option
// @Schemes
// @Description Retrieves the value of a specific configuration option for a check for the authenticated user.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Check name"
// @Param optname path string true "Option name"
// @Param domain path string false "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Success 200 {object} object "Option value (type varies)"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options/{optname} [get]
// @Router /domains/{domain}/checks/{cname}/options/{optname} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options/{optname} [get]
func (uc *CheckerController) GetCheckerOption(c *gin.Context) {
uc.GetCheckerOptionValue(c)
}
// SetCheckerOption sets or updates a specific option value for a check for the authenticated user.
//
// @Summary Set check option
// @Schemes
// @Description Sets or updates the value of a specific configuration option for a check for the authenticated user.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Check name"
// @Param optname path string true "Option name"
// @Param domain path string false "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param body body object true "Option value (type varies by option)"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options/{optname} [put]
// @Router /domains/{domain}/checks/{cname}/options/{optname} [put]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options/{optname} [put]
func (uc *CheckerController) SetCheckerOption(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cname")
optname := c.Param("optname")
uc.SetCheckerOptionWithScope(c, cname, optname, &user.Id, nil, nil)
}

View file

@ -0,0 +1,592 @@
// 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 (
"encoding/json"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// CheckResultController handles check result operations
type CheckResultController struct {
scope happydns.CheckScopeType
checkerUC happydns.CheckerUsecase
checkResultUC happydns.CheckResultUsecase
checkScheduler happydns.SchedulerUsecase
}
func NewCheckResultController(
scope happydns.CheckScopeType,
checkerUC happydns.CheckerUsecase,
checkResultUC happydns.CheckResultUsecase,
checkScheduler happydns.SchedulerUsecase,
) *CheckResultController {
return &CheckResultController{
scope: scope,
checkerUC: checkerUC,
checkResultUC: checkResultUC,
checkScheduler: checkScheduler,
}
}
// getTargetFromContext extracts the target ID from context based on scope
func (tc *CheckResultController) getTargetFromContext(c *gin.Context) (happydns.Identifier, error) {
switch tc.scope {
case happydns.CheckScopeUser:
user := middleware.MyUser(c)
return user.Id, nil
case happydns.CheckScopeDomain:
domain := c.MustGet("domain").(*happydns.Domain)
return domain.Id, nil
case happydns.CheckScopeService:
// Services are stored by ID in context
serviceID := c.MustGet("serviceid").(happydns.Identifier)
return serviceID, nil
default:
return happydns.Identifier{}, fmt.Errorf("unsupported scope")
}
}
// getInsideFromContext extracts the inside scope and ID from context based on scope.
// For service scope, the inside is the zone. For other scopes, both are nil.
func (tc *CheckResultController) getInsideFromContext(c *gin.Context) (*happydns.CheckScopeType, *happydns.Identifier) {
if insideZone, ok := c.Get("zone"); tc.scope == happydns.CheckScopeService && ok {
scopetmp := happydns.CheckScopeZone
return &scopetmp, &insideZone.(*happydns.Zone).Id
}
return nil, nil
}
// ListAvailableChecks lists all available check plugins for the target scope
//
// @Summary List available checks
// @Description Retrieves all available check plugins for the target scope with their last execution status if enabled
// @Tags checks
// @Produce json
// @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} object "List of available checks"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks [get]
func (tc *CheckResultController) ListAvailableChecks(c *gin.Context) {
user := middleware.MyUser(c)
domain := c.MustGet("domain").(*happydns.Domain)
var service *happydns.Service
if svc, ok := c.Get("service"); ok {
service = svc.(*happydns.Service)
}
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
insideScope, insideID := tc.getInsideFromContext(c)
checks, err := tc.checkResultUC.ListCheckerStatuses(tc.scope, targetID, insideScope, insideID, user, domain, service)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, checks)
}
// ListLatestCheckResults retrieves the lacheck check results for a specific plugin
//
// @Summary Get lacheck check results
// @Description Retrieves the 5 most recent check results for a specific plugin and target
// @Tags checks
// @Produce json
// @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 cname path string true "Check plugin name"
// @Success 200 {array} happydns.CheckResult
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname} [get]
func (tc *CheckResultController) ListLatestCheckResults(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
insideScope, insideID := tc.getInsideFromContext(c)
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, insideScope, insideID, user.Id, 5)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, results)
}
// TriggerCheck triggers an on-demand check execution
//
// @Summary Trigger check execution
// @Description Triggers an immediate check execution and returns the execution ID
// @Tags checks
// @Accept json
// @Produce json
// @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 cname path string true "Check plugin name"
// @Param body body object false "Optional: Plugin options"
// @Success 202 {object} object{execution_id=string}
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname} [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname} [post]
func (tc *CheckResultController) TriggerCheck(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
var insideId *happydns.Identifier
var insideScope *happydns.CheckScopeType
if insideZone, ok := c.Get("zone"); tc.scope == happydns.CheckScopeService && ok {
scopetmp := happydns.CheckScopeZone
insideScope = &scopetmp
insideId = &insideZone.(*happydns.Zone).Id
}
// Parse run options
var options happydns.SetCheckerOptionsRequest
if err = c.ShouldBindJSON(&options); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
// Trigger the test via scheduler (returns error if scheduler is disabled)
executionID, err := tc.checkScheduler.TriggerOnDemandCheck(checkName, tc.scope, targetID, insideScope, insideId, user.Id, options.Options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"execution_id": executionID.String()})
}
// GetCheckExecutionStatus retrieves the status of a check execution
//
// @Summary Get check execution status
// @Description Retrieves the current status of a check execution
// @Tags checks
// @Produce json
// @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 cname path string true "Check plugin name"
// @Param execution_id path string true "Execution ID"
// @Success 200 {object} happydns.CheckExecution
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/executions/{execution_id} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/executions/{execution_id} [get]
func (tc *CheckResultController) GetCheckExecutionStatus(c *gin.Context) {
user := middleware.MyUser(c)
executionIDStr := c.Param("execution_id")
executionID, err := happydns.NewIdentifierFromString(executionIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid execution ID"))
return
}
execution, err := tc.checkResultUC.GetCheckExecution(executionID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
if !execution.OwnerId.Equals(user.Id) {
middleware.ErrorResponse(c, http.StatusNotFound, happydns.ErrNotFound)
return
}
c.JSON(http.StatusOK, execution)
}
// ListCheckResults lists all results for a check plugin
//
// @Summary List check results
// @Description Lists all check results for a specific check plugin and target
// @Tags checks
// @Produce json
// @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 cname path string true "Check plugin name"
// @Param limit query int false "Maximum number of results to return (default: 10)"
// @Success 200 {array} happydns.CheckResult
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results [get]
func (tc *CheckResultController) ListCheckResults(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Parse limit parameter
limit := 10
if limitStr := c.Query("limit"); limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit)
}
insideScope, insideID := tc.getInsideFromContext(c)
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, insideScope, insideID, user.Id, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, results)
}
// DropCheckResults deletes all results for a check plugin
//
// @Summary Delete all check results
// @Description Deletes all check results for a specific check plugin and target
// @Tags checks
// @Produce json
// @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 cname path string true "Check plugin name"
// @Success 204 "No Content"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results [delete]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results [delete]
func (tc *CheckResultController) DropCheckResults(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
insideScope, insideID := tc.getInsideFromContext(c)
err = tc.checkResultUC.DeleteAllCheckResults(checkName, tc.scope, targetID, insideScope, insideID, user.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}
// GetCheckResult retrieves a specific check result
//
// @Summary Get check result
// @Description Retrieves a specific check result by ID
// @Tags checks
// @Produce json
// @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 cname path string true "Check plugin name"
// @Param result_id path string true "Result ID"
// @Success 200 {object} happydns.CheckResult
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results/{result_id} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id} [get]
func (tc *CheckResultController) GetCheckResult(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
insideScope, insideID := tc.getInsideFromContext(c)
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID, insideScope, insideID, user.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, result)
}
// GetCheckResultHTMLReport returns the HTML report for a specific check result
//
// @Summary Get check result HTML report
// @Description Returns the full HTML document generated from the check result's report data. Only available for checkers that implement HTML reporting.
// @Tags checks
// @Produce html
// @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 cname path string true "Check plugin name"
// @Param result_id path string true "Result ID"
// @Success 200 {string} string "HTML document"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results/{result_id}/report [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id}/report [get]
func (tc *CheckResultController) GetCheckResultHTMLReport(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
insideScope, insideID := tc.getInsideFromContext(c)
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID, insideScope, insideID, user.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
checker, err := tc.checkerUC.GetChecker(checkName)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
raw, err := json.Marshal(result.Report)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
htmlContent, supported, err := checks.GetHTMLReport(checker, json.RawMessage(raw))
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if !supported {
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support HTML reports", checkName))
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
}
// GetCheckResultMetrics returns time-series metrics extracted from check results
//
// @Summary Get check result metrics
// @Description Returns time-series metrics suitable for charting, extracted from recent check results. Only available for checkers that implement metrics reporting.
// @Tags checks
// @Produce json
// @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 cname path string true "Check plugin name"
// @Param limit query int false "Maximum number of results to extract metrics from (default: 100)"
// @Success 200 {object} happydns.MetricsReport
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/metrics [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/metrics [get]
func (tc *CheckResultController) GetCheckResultMetrics(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
limit := 100
if limitStr := c.Query("limit"); limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit)
}
checker, err := tc.checkerUC.GetChecker(checkName)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
insideScope, insideID := tc.getInsideFromContext(c)
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, insideScope, insideID, user.Id, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
report, supported, err := checks.GetMetrics(checker, results)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if !supported {
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support metrics", checkName))
return
}
c.JSON(http.StatusOK, report)
}
// GetSingleCheckResultMetrics returns metrics extracted from a single check result
//
// @Summary Get single check result metrics
// @Description Returns metrics extracted from a single check result. Only available for checkers that implement metrics reporting.
// @Tags checks
// @Produce json
// @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 cname path string true "Check plugin name"
// @Param result_id path string true "Result ID"
// @Success 200 {object} happydns.MetricsReport
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results/{result_id}/metrics [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id}/metrics [get]
func (tc *CheckResultController) GetSingleCheckResultMetrics(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
insideScope, insideID := tc.getInsideFromContext(c)
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID, insideScope, insideID, user.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
checker, err := tc.checkerUC.GetChecker(checkName)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
report, supported, err := checks.GetMetrics(checker, []*happydns.CheckResult{result})
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if !supported {
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support metrics", checkName))
return
}
c.JSON(http.StatusOK, report)
}
// DropCheckResult deletes a specific check result
//
// @Summary Delete check result
// @Description Deletes a specific check result by ID
// @Tags checks
// @Produce json
// @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 cname path string true "Check plugin name"
// @Param result_id path string true "Result ID"
// @Success 204 "No Content"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results/{result_id} [delete]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id} [delete]
func (tc *CheckResultController) DropCheckResult(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
insideScope, insideID := tc.getInsideFromContext(c)
err = tc.checkResultUC.DeleteCheckResult(checkName, tc.scope, targetID, resultID, insideScope, insideID, user.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.Status(http.StatusNoContent)
}

View file

@ -37,13 +37,15 @@ type DomainController struct {
domainService happydns.DomainUsecase
remoteZoneImporter happydns.RemoteZoneImporterUsecase
zoneImporter happydns.ZoneImporterUsecase
checkResultUC happydns.CheckResultUsecase
}
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase) *DomainController {
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, checkResultUC happydns.CheckResultUsecase) *DomainController {
return &DomainController{
domainService: domainService,
remoteZoneImporter: remoteZoneImporter,
zoneImporter: zoneImporter,
checkResultUC: checkResultUC,
}
}
@ -56,7 +58,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 +75,25 @@ func (dc *DomainController) GetDomains(c *gin.Context) {
return
}
c.JSON(http.StatusOK, domains)
var statusByDomain map[string]*happydns.CheckResultStatus
if dc.checkResultUC != nil {
var err error
statusByDomain, err = dc.checkResultUC.GetWorstCheckStatusByUser(happydns.CheckScopeDomain, user.Id)
if err != nil {
log.Printf("GetWorstCheckStatusByUser: %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,74 @@
// 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"
"github.com/gin-gonic/gin"
"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
}
info, err := dc.diuService.GetDomainInfo(c.Request.Context(), happydns.Origin(domain))
if err != nil {
if errors.Is(err, happydns.DomainDoesNotExist) {
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

@ -34,16 +34,18 @@ import (
)
type ServiceController struct {
suService happydns.ServiceUsecase
duService happydns.ZoneServiceUsecase
zuService happydns.ZoneUsecase
checkResultUC happydns.CheckResultUsecase
suService happydns.ServiceUsecase
duService happydns.ZoneServiceUsecase
zuService happydns.ZoneUsecase
}
func NewServiceController(duService happydns.ZoneServiceUsecase, suService happydns.ServiceUsecase, zuService happydns.ZoneUsecase) *ServiceController {
func NewServiceController(duService happydns.ZoneServiceUsecase, suService happydns.ServiceUsecase, zuService happydns.ZoneUsecase, checkResultUC happydns.CheckResultUsecase) *ServiceController {
return &ServiceController{
duService: duService,
suService: suService,
zuService: zuService,
checkResultUC: checkResultUC,
duService: duService,
suService: suService,
zuService: zuService,
}
}
@ -106,18 +108,28 @@ func (sc *ServiceController) AddZoneService(c *gin.Context) {
// @Param zoneId path string true "Zone identifier"
// @Param subdomain path string true "Part of the subdomain considered for the service (@ for the root of the zone ; subdomain is relative to the root, do not include it)"
// @Param serviceId path string true "Service identifier"
// @Success 200 {object} happydns.Service
// @Success 200 {object} happydns.ServiceWithCheckStatus
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
// @Router /domains/{domainId}/zone/{zoneId}/{subdomain}/services/{serviceId} [get]
func (sc *ServiceController) GetZoneService(c *gin.Context) {
zone := c.MustGet("zone").(*happydns.Zone)
serviceid := c.MustGet("serviceid").(happydns.Identifier)
subdomain := c.MustGet("subdomain").(happydns.Subdomain)
user := middleware.MyUser(c)
svc := c.MustGet("service").(*happydns.Service)
_, svc := zone.FindSubdomainService(subdomain, serviceid)
result := &happydns.ServiceWithCheckStatus{Service: svc}
c.JSON(http.StatusOK, svc)
if sc.checkResultUC != nil && user != nil {
insideZone := c.MustGet("zone").(*happydns.Zone)
insideScope := happydns.CheckScopeZone
status, err := sc.checkResultUC.GetWorstCheckStatus(happydns.CheckScopeService, svc.Id, &insideScope, &insideZone.Id, user.Id)
if err != nil {
log.Printf("GetWorstCheckStatus: %s", err.Error())
} else {
result.LastCheckStatus = status
}
}
c.JSON(http.StatusOK, result)
}
// UpdateZoneService adds or updates a service inside the given Zone.

View file

@ -0,0 +1,231 @@
// 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"
)
// CheckerScheduleController handles test schedule operations
type CheckerScheduleController struct {
testScheduleUC happydns.CheckerScheduleUsecase
}
func NewCheckerScheduleController(testScheduleUC happydns.CheckerScheduleUsecase) *CheckerScheduleController {
return &CheckerScheduleController{
testScheduleUC: testScheduleUC,
}
}
// ListCheckerSchedules retrieves schedules for the authenticated user
//
// @Summary List test schedules
// @Description Retrieves test schedules for the authenticated user with optional pagination
// @Tags test-schedules
// @Produce json
// @Param limit query int false "Maximum number of schedules to return (0 = all)"
// @Param offset query int false "Number of schedules to skip (default: 0)"
// @Success 200 {array} happydns.CheckerSchedule
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules [get]
func (tc *CheckerScheduleController) ListCheckerSchedules(c *gin.Context) {
user := middleware.MyUser(c)
schedules, err := tc.testScheduleUC.ListUserSchedules(user.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Apply pagination
limit := 0
offset := 0
fmt.Sscanf(c.Query("limit"), "%d", &limit)
fmt.Sscanf(c.Query("offset"), "%d", &offset)
if offset > len(schedules) {
offset = len(schedules)
}
schedules = schedules[offset:]
if limit > 0 && len(schedules) > limit {
schedules = schedules[:limit]
}
c.JSON(http.StatusOK, schedules)
}
// CreateCheckerSchedule creates a new test schedule
//
// @Summary Create test schedule
// @Description Creates a new test schedule for the authenticated user
// @Tags test-schedules
// @Accept json
// @Produce json
// @Param body body happydns.CheckerSchedule true "Check schedule to create"
// @Success 201 {object} happydns.CheckerSchedule
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules [post]
func (tc *CheckerScheduleController) CreateCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
var schedule happydns.CheckerSchedule
if err := c.ShouldBindJSON(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
// Set user ID
schedule.OwnerId = user.Id
if err := tc.testScheduleUC.CreateSchedule(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusCreated, schedule)
}
// GetCheckerSchedule retrieves a specific schedule
//
// @Summary Get test schedule
// @Description Retrieves a specific test schedule by ID
// @Tags test-schedules
// @Produce json
// @Param schedule_id path string true "Schedule ID"
// @Success 200 {object} happydns.CheckerSchedule
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [get]
func (tc *CheckerScheduleController) GetCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
scheduleIdStr := c.Param("schedule_id")
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
return
}
// Verify ownership
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
middleware.ErrorResponse(c, http.StatusForbidden, err)
return
}
schedule, err := tc.testScheduleUC.GetSchedule(scheduleId)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, schedule)
}
// UpdateCheckerSchedule updates an existing schedule
//
// @Summary Update test schedule
// @Description Updates an existing test schedule
// @Tags test-schedules
// @Accept json
// @Produce json
// @Param schedule_id path string true "Schedule ID"
// @Param body body happydns.CheckerSchedule true "Updated schedule"
// @Success 200 {object} happydns.CheckerSchedule
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [put]
func (tc *CheckerScheduleController) UpdateCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
scheduleIdStr := c.Param("schedule_id")
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
return
}
// Verify ownership
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
middleware.ErrorResponse(c, http.StatusForbidden, err)
return
}
var schedule happydns.CheckerSchedule
if err := c.ShouldBindJSON(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
// Ensure ID matches
schedule.Id = scheduleId
schedule.OwnerId = user.Id
if err := tc.testScheduleUC.UpdateSchedule(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, schedule)
}
// DeleteCheckerSchedule deletes a schedule
//
// @Summary Delete test schedule
// @Description Deletes a test schedule
// @Tags test-schedules
// @Produce json
// @Param schedule_id path string true "Schedule ID"
// @Success 204 "No Content"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [delete]
func (tc *CheckerScheduleController) DeleteCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
scheduleIdStr := c.Param("schedule_id")
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
return
}
// Verify ownership
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
middleware.ErrorResponse(c, http.StatusForbidden, err)
return
}
if err := tc.testScheduleUC.DeleteSchedule(scheduleId); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}

View file

@ -35,13 +35,15 @@ import (
)
type ZoneController struct {
checkResultUC happydns.CheckResultUsecase
domainService happydns.DomainUsecase
zoneCorrectionService happydns.ZoneCorrectionApplierUsecase
zoneService happydns.ZoneUsecase
}
func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase) *ZoneController {
func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase, checkResultUC happydns.CheckResultUsecase) *ZoneController {
return &ZoneController{
checkResultUC: checkResultUC,
domainService: domainService,
zoneCorrectionService: zoneCorrectionService,
zoneService: zoneService,
@ -59,14 +61,37 @@ func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.
// @Security securitydefinitions.basic
// @Param domainId path string true "Domain identifier"
// @Param zoneId path string true "Zone identifier"
// @Success 200 {object} happydns.Zone
// @Success 200 {object} happydns.ZoneWithServicesCheckStatus
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
// @Router /domains/{domainId}/zone/{zoneId} [get]
func (zc *ZoneController) GetZone(c *gin.Context) {
user := middleware.MyUser(c)
zone := c.MustGet("zone").(*happydns.Zone)
c.JSON(http.StatusOK, zone)
result := &happydns.ZoneWithServicesCheckStatus{Zone: zone}
if zc.checkResultUC != nil && user != nil {
statusByService, err := zc.checkResultUC.GetWorstCheckStatusByUser(happydns.CheckScopeService, user.Id)
if err != nil {
log.Printf("GetWorstCheckStatusByUser: %s", err.Error())
} else if statusByService != nil {
result.ServicesCheckStatus = make(map[string]*happydns.CheckResultStatus)
for subdomain := range zone.Services {
for _, svc := range zone.Services[subdomain] {
key := svc.Id.String()
if status, ok := statusByService[key]; ok {
result.ServicesCheckStatus[key] = status
}
}
}
if len(result.ServicesCheckStatus) == 0 {
result.ServicesCheckStatus = nil
}
}
}
c.JSON(http.StatusOK, result)
}
// GetZoneSubdomain returns the services associated with a given subdomain.

View file

@ -43,3 +43,21 @@ func ServiceIdHandler(suService happydns.ServiceUsecase) gin.HandlerFunc {
c.Next()
}
}
func ServiceHandler(suService happydns.ServiceUsecase) gin.HandlerFunc {
return func(c *gin.Context) {
zone := c.MustGet("zone").(*happydns.Zone)
serviceid := c.MustGet("serviceid").(happydns.Identifier)
subdomain := c.MustGet("subdomain").(happydns.Subdomain)
_, svc := zone.FindSubdomainService(subdomain, serviceid)
if svc == nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": fmt.Sprintf("Service not found: %s", serviceid)})
return
}
c.Set("service", svc)
c.Next()
}
}

View file

@ -0,0 +1,93 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
happydns "git.happydns.org/happyDomain/model"
)
func DeclareCheckersRoutes(router *gin.RouterGroup, checkerUC happydns.CheckerUsecase) *controller.CheckerController {
tpc := controller.NewCheckerController(checkerUC)
router.GET("/checks", tpc.ListCheckers)
apiCheckRoutes := router.Group("/checks/:cname")
apiCheckRoutes.Use(tpc.CheckerHandler)
apiCheckRoutes.GET("", tpc.GetCheckerStatus)
DeclareCheckerOptionsRoutes(apiCheckRoutes, tpc)
return tpc
}
func DeclareScopedCheckersRoutes(
scopedRouter *gin.RouterGroup,
checkerUC happydns.CheckerUsecase,
checkResultUC happydns.CheckResultUsecase,
checkScheduler happydns.SchedulerUsecase,
scope happydns.CheckScopeType,
tpc *controller.CheckerController,
) {
tc := controller.NewCheckResultController(
scope,
checkerUC,
checkResultUC,
checkScheduler,
)
// List all available tests with their status
scopedRouter.GET("/checks", tc.ListAvailableChecks)
apiChecksRoutes := scopedRouter.Group("/checks/:cname")
{
DeclareCheckerOptionsRoutes(apiChecksRoutes, tpc)
// Get latest results for a test
apiChecksRoutes.GET("", tc.ListLatestCheckResults)
// Trigger an on-demand test
apiChecksRoutes.POST("", tc.TriggerCheck)
// Check execution routes
apiCheckExecutionsRoutes := apiChecksRoutes.Group("/executions/:execution_id")
{
apiCheckExecutionsRoutes.GET("", tc.GetCheckExecutionStatus)
}
DeclareScopedCheckResultRoutes(apiChecksRoutes, tc)
}
}
func DeclareCheckerOptionsRoutes(apiCheckRoutes *gin.RouterGroup, tpc *controller.CheckerController) {
apiCheckRoutes.GET("/options", tpc.GetCheckerOptions)
apiCheckRoutes.POST("/options", tpc.AddCheckerOptions)
apiCheckRoutes.PUT("/options", tpc.ChangeCheckerOptions)
apiCheckOptionsRoutes := apiCheckRoutes.Group("/options/:optname")
apiCheckOptionsRoutes.Use(tpc.CheckerOptionHandler)
apiCheckOptionsRoutes.GET("", tpc.GetCheckerOption)
apiCheckOptionsRoutes.PUT("", tpc.SetCheckerOption)
}

View file

@ -0,0 +1,46 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
)
// DeclareScopedCheckResultRoutes declares test result routes for a specific scope (domain, zone, or service)
func DeclareScopedCheckResultRoutes(apiChecksRoutes *gin.RouterGroup, tc *controller.CheckResultController) {
// Check metrics route
apiChecksRoutes.GET("/metrics", tc.GetCheckResultMetrics)
// Check results routes
apiChecksRoutes.GET("/results", tc.ListCheckResults)
apiChecksRoutes.DELETE("/results", tc.DropCheckResults)
apiCheckResultsRoutes := apiChecksRoutes.Group("/results/:result_id")
{
apiCheckResultsRoutes.GET("", tc.GetCheckResult)
apiCheckResultsRoutes.DELETE("", tc.DropCheckResult)
apiCheckResultsRoutes.GET("/report", tc.GetCheckResultHTMLReport)
apiCheckResultsRoutes.GET("/metrics", tc.GetSingleCheckResultMetrics)
}
}

View file

@ -0,0 +1,47 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
// DeclareTestScheduleRoutes declares test schedule management routes
func DeclareTestScheduleRoutes(router *gin.RouterGroup, checkerScheduleUC happydns.CheckerScheduleUsecase) {
sc := controller.NewCheckerScheduleController(checkerScheduleUC)
schedulesRoutes := router.Group("/plugins/tests/schedules")
{
schedulesRoutes.GET("", sc.ListCheckerSchedules)
schedulesRoutes.POST("", sc.CreateCheckerSchedule)
scheduleRoutes := schedulesRoutes.Group("/:schedule_id")
{
scheduleRoutes.GET("", sc.GetCheckerSchedule)
scheduleRoutes.PUT("", sc.UpdateCheckerSchedule)
scheduleRoutes.DELETE("", sc.DeleteCheckerSchedule)
}
}
}

View file

@ -39,11 +39,17 @@ func DeclareDomainRoutes(
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
checkerUC happydns.CheckerUsecase,
checkResultUC happydns.CheckResultUsecase,
checkScheduler happydns.SchedulerUsecase,
domainInfoUC happydns.DomainInfoUsecase,
tpc *controller.CheckerController,
) {
dc := controller.NewDomainController(
domainUC,
remoteZoneImporter,
zoneImporter,
checkResultUC,
)
router.GET("/domains", dc.GetDomains)
@ -56,8 +62,20 @@ func DeclareDomainRoutes(
apiDomainsRoutes.PUT("", dc.UpdateDomain)
apiDomainsRoutes.DELETE("", dc.DelDomain)
DeclareDomainInfoRoutes(apiDomainsRoutes.Group("/info"), domainInfoUC)
DeclareDomainLogRoutes(apiDomainsRoutes, domainLogUC)
// Declare test result routes for domain scope
DeclareScopedCheckersRoutes(
apiDomainsRoutes,
checkerUC,
checkResultUC,
checkScheduler,
happydns.CheckScopeDomain,
tpc,
)
apiDomainsRoutes.POST("/zone", dc.ImportZone)
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
@ -68,5 +86,9 @@ func DeclareDomainRoutes(
zoneCorrApplier,
zoneServiceUC,
serviceUC,
checkerUC,
checkResultUC,
checkScheduler,
tpc,
)
}

View file

@ -0,0 +1,37 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
func DeclareDomainInfoRoutes(router *gin.RouterGroup, domainInfoUC happydns.DomainInfoUsecase) {
dc := controller.NewDomainInfoController(
domainInfoUC,
)
router.POST("", dc.GetDomainInfo)
}

View file

@ -34,7 +34,12 @@ type Dependencies struct {
Authentication happydns.AuthenticationUsecase
AuthUser happydns.AuthUserUsecase
CaptchaVerifier happydns.CaptchaVerifier
Checker happydns.CheckerUsecase
CheckResult happydns.CheckResultUsecase
CheckerSchedule happydns.CheckerScheduleUsecase
CheckScheduler happydns.SchedulerUsecase
Domain happydns.DomainUsecase
DomainInfo happydns.DomainInfoUsecase
DomainLog happydns.DomainLogUsecase
FailureTracker happydns.FailureTracker
Provider happydns.ProviderUsecase
@ -88,6 +93,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
dep.FailureTracker,
)
auc := DeclareAuthUserRoutes(apiRoutes, dep.AuthUser, lc)
DeclareDomainInfoRoutes(apiRoutes.Group("/domaininfo/:domain"), dep.DomainInfo)
DeclareProviderSpecsRoutes(apiRoutes, dep.ProviderSpecs)
DeclareRegistrationRoutes(apiRoutes, dep.AuthUser, dep.CaptchaVerifier)
DeclareResolverRoutes(apiRoutes, dep.Resolver)
@ -106,6 +112,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
apiAuthRoutes.Use(middleware.AuthRequired())
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
tpc := DeclareCheckersRoutes(apiAuthRoutes, dep.Checker)
DeclareDomainRoutes(
apiAuthRoutes,
dep.Domain,
@ -116,10 +123,16 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
dep.ZoneCorrectionApplier,
dep.ZoneService,
dep.Service,
dep.Checker,
dep.CheckResult,
dep.CheckScheduler,
dep.DomainInfo,
tpc,
)
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
DeclareRecordRoutes(apiAuthRoutes)
DeclareTestScheduleRoutes(apiAuthRoutes, dep.CheckerSchedule)
DeclareUsersRoutes(apiAuthRoutes, dep.User, lc)
DeclareSessionRoutes(apiAuthRoutes, dep.Session)
}

View file

@ -36,8 +36,12 @@ func DeclareZoneServiceRoutes(
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
zoneUC happydns.ZoneUsecase,
checkerUC happydns.CheckerUsecase,
checkResultUC happydns.CheckResultUsecase,
checkScheduler happydns.SchedulerUsecase,
tpc *controller.CheckerController,
) {
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC, checkResultUC)
apiZonesRoutes.PATCH("", sc.UpdateZoneService)
@ -45,6 +49,19 @@ func DeclareZoneServiceRoutes(
apiZonesSubdomainServiceIDRoutes := apiZonesSubdomainRoutes.Group("/services/:serviceid")
apiZonesSubdomainServiceIDRoutes.Use(middleware.ServiceIdHandler(serviceUC))
apiZonesSubdomainServiceIDRoutes.GET("", sc.GetZoneService)
apiZonesSubdomainServiceIDRoutes.DELETE("", sc.DeleteZoneService)
apiZonesSubdomainServiceRoutes := apiZonesSubdomainRoutes.Group("/services/:serviceid")
apiZonesSubdomainServiceRoutes.Use(middleware.ServiceIdHandler(serviceUC), middleware.ServiceHandler(serviceUC))
apiZonesSubdomainServiceRoutes.GET("", sc.GetZoneService)
// Declare test result routes for service scope
DeclareScopedCheckersRoutes(
apiZonesSubdomainServiceRoutes,
checkerUC,
checkResultUC,
checkScheduler,
happydns.CheckScopeService,
tpc,
)
}

View file

@ -36,11 +36,16 @@ func DeclareZoneRoutes(
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
checkerUC happydns.CheckerUsecase,
checkResultUC happydns.CheckResultUsecase,
checkScheduler happydns.SchedulerUsecase,
tpc *controller.CheckerController,
) {
zc := controller.NewZoneController(
zoneUC,
domainUC,
zoneCorrApplier,
checkResultUC,
)
apiZonesRoutes := router.Group("/zone/:zoneid")
@ -65,6 +70,10 @@ func DeclareZoneRoutes(
zoneServiceUC,
serviceUC,
zoneUC,
checkerUC,
checkResultUC,
checkScheduler,
tpc,
)
apiZonesRoutes.POST("/records", zc.AddRecords)

View file

@ -31,6 +31,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
admin "git.happydns.org/happyDomain/internal/api-admin/route"
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
@ -56,12 +57,16 @@ func NewAdmin(app *App) *Admin {
// Prepare usecases (admin uses unrestricted provider access)
app.usecases.providerAdmin = providerUC.NewService(app.store, nil)
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
admin.DeclareRoutes(
app.cfg,
router,
app.store,
admin.Dependencies{
AuthUser: app.usecases.authUser,
Checker: app.usecases.checker,
CheckScheduler: app.checkScheduler,
Domain: app.usecases.domain,
Provider: app.usecases.providerAdmin,
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,

View file

@ -33,11 +33,14 @@ import (
api "git.happydns.org/happyDomain/internal/api/route"
"git.happydns.org/happyDomain/internal/captcha"
"git.happydns.org/happyDomain/internal/mailer"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/internal/newsletter"
"git.happydns.org/happyDomain/internal/session"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase"
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
checkUC "git.happydns.org/happyDomain/internal/usecase/check"
checkresultUC "git.happydns.org/happyDomain/internal/usecase/checkresult"
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
@ -48,13 +51,18 @@ import (
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
zoneServiceUC "git.happydns.org/happyDomain/internal/usecase/zone_service"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/pkg/domaininfo"
"git.happydns.org/happyDomain/web"
)
type Usecases struct {
authentication happydns.AuthenticationUsecase
authUser happydns.AuthUserUsecase
checker happydns.CheckerUsecase
checkResult happydns.CheckResultUsecase
checkerSchedule happydns.CheckerScheduleUsecase
domain happydns.DomainUsecase
domainInfo happydns.DomainInfoUsecase
domainLog happydns.DomainLogUsecase
provider happydns.ProviderUsecase
providerAdmin happydns.ProviderUsecase
@ -81,6 +89,7 @@ type App struct {
router *gin.Engine
srv *http.Server
store storage.Storage
checkScheduler happydns.SchedulerUsecase
usecases Usecases
}
@ -93,8 +102,12 @@ func NewApp(cfg *happydns.Options) *App {
app.initStorageEngine()
app.initNewsletter()
app.initInsights()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.initCheckScheduler()
app.setupRouter()
return app
@ -108,8 +121,12 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
app.initMailer()
app.initNewsletter()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.initCheckScheduler()
app.setupRouter()
return app
@ -162,6 +179,9 @@ func (app *App) initStorageEngine() {
if err = app.store.MigrateSchema(); err != nil {
log.Fatal("Could not migrate database: ", err)
}
app.store = newInstrumentedStorage(app.store)
metrics.NewStorageStatsCollector(storage.NewStatsProvider(app.store))
}
}
@ -186,6 +206,22 @@ func (app *App) initInsights() {
}
}
func (app *App) initCheckScheduler() {
if app.cfg.DisableScheduler {
// Use a disabled scheduler that returns clear errors
app.checkScheduler = &disabledScheduler{}
return
}
app.checkScheduler = newCheckScheduler(
app.cfg,
app.store,
app.usecases.checker,
app.usecases.checkResult,
app.usecases.checkerSchedule,
)
}
func (app *App) initUsecases() {
sessionService := sessionUC.NewService(app.store)
authUserService := authuserUC.NewAuthUserUsecases(
@ -207,6 +243,10 @@ func (app *App) initUsecases() {
app.usecases.service = serviceService
app.usecases.serviceSpecs = usecase.NewServiceSpecsUsecase()
app.usecases.zone = zoneService
app.usecases.domainInfo = usecase.NewDomainInfoUsecase(
domaininfo.GetDomainRDAPInfo,
domaininfo.GetDomainWhoisInfo,
)
app.usecases.domainLog = domainLogService
domainService := domainUC.NewService(
@ -234,6 +274,9 @@ func (app *App) initUsecases() {
app.usecases.authUser = authUserService
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
app.usecases.session = sessionService
app.usecases.checker = checkUC.NewCheckerUsecase(app.cfg, app.store, app.store)
app.usecases.checkerSchedule = checkresultUC.NewCheckScheduleUsecase(app.store, app.cfg, app.store, app.usecases.checker)
app.usecases.checkResult = checkresultUC.NewCheckResultUsecase(app.store, app.cfg, app.usecases.checker, app.usecases.checkerSchedule)
app.usecases.orchestrator = orchestrator.NewOrchestrator(
domainLogService,
@ -255,7 +298,7 @@ func (app *App) setupRouter() {
gin.ForceConsoleColor()
app.router = gin.New()
app.router.Use(gin.Logger(), gin.Recovery(), sessions.Sessions(
app.router.Use(gin.Logger(), gin.Recovery(), metrics.HTTPMiddleware(), sessions.Sessions(
session.COOKIE_NAME,
session.NewSessionStore(app.cfg, app.store, []byte(app.cfg.JWTSecretKey)),
))
@ -275,7 +318,12 @@ func (app *App) setupRouter() {
Authentication: app.usecases.authentication,
AuthUser: app.usecases.authUser,
CaptchaVerifier: app.captchaVerifier,
Checker: app.usecases.checker,
CheckResult: app.usecases.checkResult,
CheckerSchedule: app.usecases.checkerSchedule,
CheckScheduler: app.checkScheduler,
Domain: app.usecases.domain,
DomainInfo: app.usecases.domainInfo,
DomainLog: app.usecases.domainLog,
FailureTracker: app.failureTracker,
Provider: app.usecases.provider,
@ -308,6 +356,8 @@ func (app *App) Start() {
go app.insights.Run()
}
go app.checkScheduler.Run()
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
@ -333,4 +383,6 @@ func (app *App) Stop() {
if app.failureTracker != nil {
app.failureTracker.Close()
}
app.checkScheduler.Close()
}

View file

@ -0,0 +1,641 @@
// 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 app
import (
"container/heap"
"context"
"fmt"
"log"
"runtime"
"sync"
"time"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
)
const (
SchedulerCheckInterval = 1 * time.Minute // How often to check for due tests
SchedulerCleanupInterval = 24 * time.Hour // How often to clean up old executions
SchedulerDiscoveryInterval = 1 * time.Hour // How often to auto-discover new targets
CheckExecutionTimeout = 5 * time.Minute // Max time for a single check
MaxRetries = 3 // Max retry attempts for failed checks
)
// Priority levels for test execution queue
const (
PriorityOnDemand = iota // On-demand tests (highest priority)
PriorityOverdue // Overdue scheduled tests
PriorityScheduled // Regular scheduled tests
)
// checkScheduler manages background test execution
type checkScheduler struct {
cfg *happydns.Options
store storage.Storage
checkerUsecase happydns.CheckerUsecase
resultUsecase happydns.CheckResultUsecase
scheduleUsecase happydns.CheckerScheduleUsecase
stop chan struct{} // closed to stop the main Run loop
stopWorkers chan struct{} // closed to stop all workers simultaneously
runNowChan chan *queueItem // on-demand items routed through the main loop
workAvail chan struct{} // non-blocking signals that queue has new work
queue *priorityQueue
activeExecutions map[string]*activeExecution
workers []*worker
mu sync.RWMutex
wg sync.WaitGroup
runtimeEnabled bool
running bool
}
// activeExecution tracks a running test execution
type activeExecution struct {
execution *happydns.CheckExecution
cancel context.CancelFunc
startTime time.Time
}
// queueItem represents a test execution request in the queue
type queueItem struct {
schedule *happydns.CheckerSchedule
execution *happydns.CheckExecution
priority int
queuedAt time.Time
retries int
}
// --- container/heap implementation for priorityQueue ---
// priorityHeap is the underlying heap, ordered by priority then arrival time.
type priorityHeap []*queueItem
func (h priorityHeap) Len() int { return len(h) }
func (h priorityHeap) Less(i, j int) bool {
if h[i].priority != h[j].priority {
return h[i].priority < h[j].priority
}
return h[i].queuedAt.Before(h[j].queuedAt)
}
func (h priorityHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *priorityHeap) Push(x any) { *h = append(*h, x.(*queueItem)) }
func (h *priorityHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
old[n-1] = nil // avoid memory leak
*h = old[:n-1]
return x
}
// priorityQueue is a thread-safe min-heap of queueItems.
type priorityQueue struct {
h priorityHeap
mu sync.Mutex
}
func newPriorityQueue() *priorityQueue {
pq := &priorityQueue{}
heap.Init(&pq.h)
return pq
}
// Push adds an item to the queue.
func (q *priorityQueue) Push(item *queueItem) {
q.mu.Lock()
defer q.mu.Unlock()
heap.Push(&q.h, item)
}
// Pop removes and returns the highest-priority item, or nil if empty.
func (q *priorityQueue) Pop() *queueItem {
q.mu.Lock()
defer q.mu.Unlock()
if q.h.Len() == 0 {
return nil
}
return heap.Pop(&q.h).(*queueItem)
}
// Len returns the queue length.
func (q *priorityQueue) Len() int {
q.mu.Lock()
defer q.mu.Unlock()
return q.h.Len()
}
// worker processes tests from the queue
type worker struct {
id int
scheduler *checkScheduler
}
// newCheckScheduler creates a new test scheduler
func newCheckScheduler(
cfg *happydns.Options,
store storage.Storage,
checkerUsecase happydns.CheckerUsecase,
resultUsecase happydns.CheckResultUsecase,
scheduleUsecase happydns.CheckerScheduleUsecase,
) *checkScheduler {
numWorkers := cfg.TestWorkers
if numWorkers <= 0 {
numWorkers = runtime.NumCPU()
}
scheduler := &checkScheduler{
cfg: cfg,
store: store,
checkerUsecase: checkerUsecase,
resultUsecase: resultUsecase,
scheduleUsecase: scheduleUsecase,
stop: make(chan struct{}),
stopWorkers: make(chan struct{}),
runNowChan: make(chan *queueItem, 100),
workAvail: make(chan struct{}, numWorkers),
queue: newPriorityQueue(),
activeExecutions: make(map[string]*activeExecution),
workers: make([]*worker, numWorkers),
runtimeEnabled: true,
}
for i := 0; i < numWorkers; i++ {
scheduler.workers[i] = &worker{
id: i,
scheduler: scheduler,
}
}
return scheduler
}
// enqueue pushes an item to the priority queue and wakes one idle worker.
func (s *checkScheduler) enqueue(item *queueItem) {
s.queue.Push(item)
metrics.SchedulerQueueDepth.Set(float64(s.queue.Len()))
select {
case s.workAvail <- struct{}{}:
default:
// All workers are already busy or already notified; they will drain
// the queue on their own after finishing the current item.
}
}
// Close stops the scheduler and waits for all workers to finish.
func (s *checkScheduler) Close() {
log.Println("Stopping test scheduler...")
// Unblock the main Run loop.
close(s.stop)
// Unblock all workers simultaneously.
close(s.stopWorkers)
// Cancel all active test executions.
s.mu.Lock()
for _, exec := range s.activeExecutions {
exec.cancel()
}
s.mu.Unlock()
// Wait for all workers to finish their current item.
s.wg.Wait()
log.Println("Check scheduler stopped")
}
// Run starts the scheduler main loop. It must not be called more than once.
func (s *checkScheduler) Run() {
s.mu.Lock()
s.running = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}()
log.Printf("Starting test scheduler with %d workers...\n", len(s.workers))
// Reschedule overdue tests before starting workers so that tests missed
// during a server suspend or shutdown are spread into the near future
// instead of all firing at once.
if n, err := s.scheduleUsecase.RescheduleOverdueChecks(); err != nil {
log.Printf("Warning: failed to reschedule overdue tests: %v\n", err)
} else if n > 0 {
log.Printf("Rescheduled %d overdue test(s) into the near future\n", n)
}
// Start workers
for _, w := range s.workers {
s.wg.Add(1)
go w.run(&s.wg)
}
// Main scheduling loop
checkTicker := time.NewTicker(SchedulerCheckInterval)
cleanupTicker := time.NewTicker(SchedulerCleanupInterval)
discoveryTicker := time.NewTicker(SchedulerDiscoveryInterval)
defer checkTicker.Stop()
defer cleanupTicker.Stop()
defer discoveryTicker.Stop()
// Initial discovery: create default schedules for all existing targets
if err := s.scheduleUsecase.DiscoverAndEnsureSchedules(); err != nil {
log.Printf("Warning: schedule discovery encountered errors: %v\n", err)
}
// Initial check
s.checkSchedules()
for {
select {
case <-checkTicker.C:
s.checkSchedules()
case <-cleanupTicker.C:
s.cleanup()
case <-discoveryTicker.C:
if err := s.scheduleUsecase.DiscoverAndEnsureSchedules(); err != nil {
log.Printf("Warning: schedule discovery encountered errors: %v\n", err)
}
case item := <-s.runNowChan:
s.enqueue(item)
case <-s.stop:
return
}
}
}
// checkSchedules checks for due tests and queues them
func (s *checkScheduler) checkSchedules() {
s.mu.RLock()
enabled := s.runtimeEnabled
s.mu.RUnlock()
if !enabled {
return
}
dueSchedules, err := s.scheduleUsecase.ListDueSchedules()
if err != nil {
log.Printf("Error listing due schedules: %v\n", err)
return
}
now := time.Now()
for _, schedule := range dueSchedules {
// Determine priority based on how overdue the test is
priority := PriorityScheduled
if schedule.NextRun.Add(schedule.Interval).Before(now) {
priority = PriorityOverdue
}
// Create execution record
execution := &happydns.CheckExecution{
ScheduleId: &schedule.Id,
CheckerName: schedule.CheckerName,
OwnerId: schedule.OwnerId,
InsideType: schedule.InsideType,
InsideId: schedule.InsideId,
TargetType: schedule.TargetType,
TargetId: schedule.TargetId,
Status: happydns.CheckExecutionPending,
StartedAt: now,
Options: schedule.Options,
}
if err := s.resultUsecase.CreateCheckExecution(execution); err != nil {
log.Printf("Error creating execution for schedule %s: %v\n", schedule.Id.String(), err)
continue
}
s.enqueue(&queueItem{
schedule: schedule,
execution: execution,
priority: priority,
queuedAt: now,
retries: 0,
})
}
// Mark scheduler run
if err := s.store.CheckSchedulerRun(); err != nil {
log.Printf("Error marking scheduler run: %v\n", err)
}
}
// TriggerOnDemandCheck triggers an immediate test execution.
// It creates the execution record synchronously (so the caller gets an ID back)
// and then routes the item through runNowChan so the main loop controls
// all queue insertions.
func (s *checkScheduler) TriggerOnDemandCheck(checkerName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, insideScopeType *happydns.CheckScopeType, insideId *happydns.Identifier, ownerId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) {
schedule := &happydns.CheckerSchedule{
CheckerName: checkerName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
InsideType: insideScopeType,
InsideId: insideId,
Interval: 0, // On-demand, no interval
Enabled: true,
Options: options,
}
now := time.Now()
execution := &happydns.CheckExecution{
ScheduleId: nil,
CheckerName: checkerName,
OwnerId: ownerId,
InsideType: insideScopeType,
InsideId: insideId,
TargetType: targetType,
TargetId: targetId,
Status: happydns.CheckExecutionPending,
StartedAt: now,
Options: options,
}
if err := s.resultUsecase.CreateCheckExecution(execution); err != nil {
return happydns.Identifier{}, err
}
item := &queueItem{
schedule: schedule,
execution: execution,
priority: PriorityOnDemand,
queuedAt: now,
retries: 0,
}
// Route through the main loop when possible; fall back to direct enqueue
// if the channel is full so that the caller never blocks.
select {
case s.runNowChan <- item:
default:
s.enqueue(item)
}
return execution.Id, nil
}
// GetSchedulerStatus returns a snapshot of the current scheduler state
func (s *checkScheduler) GetSchedulerStatus() happydns.SchedulerStatus {
s.mu.RLock()
activeCount := len(s.activeExecutions)
running := s.running
runtimeEnabled := s.runtimeEnabled
s.mu.RUnlock()
nextSchedules, _ := s.scheduleUsecase.ListUpcomingSchedules(20)
return happydns.SchedulerStatus{
ConfigEnabled: !s.cfg.DisableScheduler,
RuntimeEnabled: runtimeEnabled,
Running: running,
WorkerCount: len(s.workers),
QueueSize: s.queue.Len(),
ActiveCount: activeCount,
NextSchedules: nextSchedules,
}
}
// SetEnabled enables or disables the scheduler at runtime
func (s *checkScheduler) SetEnabled(enabled bool) error {
s.mu.Lock()
wasEnabled := s.runtimeEnabled
s.runtimeEnabled = enabled
s.mu.Unlock()
if enabled && !wasEnabled {
// Spread out any overdue tests to avoid a thundering herd, then
// immediately enqueue whatever is now due.
if n, err := s.scheduleUsecase.RescheduleOverdueChecks(); err != nil {
log.Printf("Warning: failed to reschedule overdue tests on re-enable: %v\n", err)
} else if n > 0 {
log.Printf("Rescheduled %d overdue test(s) after scheduler re-enable\n", n)
}
s.checkSchedules()
}
return nil
}
// RescheduleUpcomingChecks randomizes the next run time of all enabled schedules
// within their respective intervals, delegating to the schedule usecase.
func (s *checkScheduler) RescheduleUpcomingChecks() (int, error) {
return s.scheduleUsecase.RescheduleUpcomingChecks()
}
// cleanup removes old execution records and expired test results
func (s *checkScheduler) cleanup() {
log.Println("Running scheduler cleanup...")
// Delete completed/failed execution records older than 7 days
if err := s.resultUsecase.DeleteCompletedExecutions(7 * 24 * time.Hour); err != nil {
log.Printf("Error cleaning up old executions: %v\n", err)
}
// Delete test results older than the configured retention period
if err := s.resultUsecase.CleanupOldResults(); err != nil {
log.Printf("Error cleaning up old test results: %v\n", err)
}
log.Println("Scheduler cleanup complete")
}
// run is the worker's main loop. It drains the queue eagerly and waits for a
// workAvail signal when idle, rather than sleeping on a fixed timer.
func (w *worker) run(wg *sync.WaitGroup) {
defer wg.Done()
log.Printf("Worker %d started\n", w.id)
for {
// Drain: try to grab work before blocking.
if item := w.scheduler.queue.Pop(); item != nil {
metrics.SchedulerQueueDepth.Set(float64(w.scheduler.queue.Len()))
w.executeCheck(item)
continue
}
// Queue is empty; wait for new work or a stop signal.
select {
case <-w.scheduler.workAvail:
// Loop back to attempt a Pop.
case <-w.scheduler.stopWorkers:
log.Printf("Worker %d stopped\n", w.id)
return
}
}
}
// executeCheck runs a checker and stores the result.
func (w *worker) executeCheck(item *queueItem) {
ctx, cancel := context.WithTimeout(context.Background(), CheckExecutionTimeout)
defer cancel()
execution := item.execution
schedule := item.schedule
metrics.SchedulerActiveWorkers.Inc()
checkStart := time.Now()
defer func() {
metrics.SchedulerActiveWorkers.Dec()
metrics.SchedulerCheckDuration.WithLabelValues(schedule.CheckerName).Observe(time.Since(checkStart).Seconds())
}()
// Always update schedule NextRun after execution, whether it succeeds or fails.
// This prevents the schedule from being re-queued on the next tick if the test fails.
if execution.ScheduleId != nil {
defer func() {
if err := w.scheduler.scheduleUsecase.UpdateScheduleAfterRun(*execution.ScheduleId); err != nil {
log.Printf("Worker %d: Error updating schedule after run: %v\n", w.id, err)
}
}()
}
// Mark execution as running
execution.Status = happydns.CheckExecutionRunning
if err := w.scheduler.resultUsecase.UpdateCheckExecution(execution); err != nil {
log.Printf("Worker %d: Error updating execution status: %v\n", w.id, err)
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, err.Error())
return
}
// Track active execution
startTime := time.Now()
w.scheduler.mu.Lock()
w.scheduler.activeExecutions[execution.Id.String()] = &activeExecution{
execution: execution,
cancel: cancel,
startTime: startTime,
}
w.scheduler.mu.Unlock()
defer func() {
w.scheduler.mu.Lock()
delete(w.scheduler.activeExecutions, execution.Id.String())
w.scheduler.mu.Unlock()
}()
// Get the checker
checker, err := w.scheduler.checkerUsecase.GetChecker(schedule.CheckerName)
if err != nil {
errMsg := fmt.Sprintf("checker not found: %s - %v", schedule.CheckerName, err)
log.Printf("Worker %d: %s\n", w.id, errMsg)
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, errMsg)
return
}
var domainId, serviceId *happydns.Identifier
switch schedule.TargetType {
case happydns.CheckScopeDomain:
domainId = &schedule.TargetId
case happydns.CheckScopeService:
serviceId = &schedule.TargetId
}
// Merge options: global defaults < user opts < domain/service opts < schedule/on-demand opts < auto-fill
mergedOptions, mergeErr := w.scheduler.checkerUsecase.BuildMergedCheckerOptions(schedule.CheckerName, &schedule.OwnerId, domainId, serviceId, schedule.Options)
if mergeErr != nil {
// Non-fatal: fall back to schedule-only options
log.Printf("Worker %d: warning, could not prepare checker options for %s: %v\n", w.id, schedule.CheckerName, mergeErr)
mergedOptions = schedule.Options
}
// Prepare metadata
meta := map[string]string{
"target_type": schedule.TargetType.String(),
"target_id": schedule.TargetId.String(),
}
// Run the check synchronously with context-based timeout.
// The checker is responsible for honouring ctx cancellation.
checkResult, testErr := w.runCheckSafe(ctx, checker, mergedOptions, meta)
duration := time.Since(startTime)
// Build the result record, enriching the partial CheckResult from RunCheck
// with execution metadata (owner, target, duration, etc.).
result := &happydns.CheckResult{
CheckerName: schedule.CheckerName,
CheckType: schedule.TargetType,
TargetId: schedule.TargetId,
OwnerId: schedule.OwnerId,
ExecutedAt: startTime,
ScheduledCheck: execution.ScheduleId != nil,
Options: schedule.Options,
Duration: duration,
}
if checkResult != nil {
result.Status = checkResult.Status
result.StatusLine = checkResult.StatusLine
result.Report = checkResult.Report
if testErr != nil {
result.Error = testErr.Error()
}
} else {
result.Status = happydns.CheckResultStatusUnknown
result.StatusLine = "Check execution failed"
if testErr != nil {
result.Error = testErr.Error()
} else {
result.Error = "Unknown check execution error"
}
}
// Record check status metric
metrics.SchedulerChecksTotal.WithLabelValues(schedule.CheckerName, result.Status.String()).Inc()
// Save the result
if err := w.scheduler.resultUsecase.CreateCheckResult(result); err != nil {
log.Printf("Worker %d: Error saving test result: %v\n", w.id, err)
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, err.Error())
return
}
// Complete the execution
if err := w.scheduler.resultUsecase.CompleteCheckExecution(execution.Id, result.Id); err != nil {
log.Printf("Worker %d: Error completing execution: %v\n", w.id, err)
return
}
log.Printf("Worker %d: Completed test %s for target %s (status: %d, duration: %v)\n",
w.id, schedule.CheckerName, schedule.TargetId.String(), result.Status, duration)
}
// runCheckSafe calls checker.RunCheck and recovers from panics.
func (w *worker) runCheckSafe(ctx context.Context, checker happydns.Checker, options happydns.CheckerOptions, meta map[string]string) (result *happydns.CheckResult, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("checker panicked: %v", r)
}
}()
return checker.RunCheck(ctx, options, meta)
}

View file

@ -0,0 +1,58 @@
// 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 app
import (
"fmt"
"git.happydns.org/happyDomain/model"
)
// disabledScheduler is a no-op implementation of SchedulerUsecase used when the
// scheduler is disabled in configuration.
type disabledScheduler struct{}
func (d *disabledScheduler) Run() {}
func (d *disabledScheduler) Close() {}
func (d *disabledScheduler) TriggerOnDemandCheck(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, _ *happydns.CheckScopeType, _ *happydns.Identifier, userId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) {
return happydns.Identifier{}, fmt.Errorf("test scheduler is disabled in configuration")
}
// GetSchedulerStatus returns a status indicating the scheduler is disabled
func (d *disabledScheduler) GetSchedulerStatus() happydns.SchedulerStatus {
return happydns.SchedulerStatus{
ConfigEnabled: false,
RuntimeEnabled: false,
Running: false,
}
}
// SetEnabled returns an error since the scheduler is disabled in configuration
func (d *disabledScheduler) SetEnabled(enabled bool) error {
return fmt.Errorf("scheduler is disabled in configuration, cannot enable at runtime")
}
// RescheduleUpcomingChecks returns an error since the scheduler is disabled
func (d *disabledScheduler) RescheduleUpcomingChecks() (int, error) {
return 0, fmt.Errorf("test scheduler is disabled in configuration")
}

View file

@ -0,0 +1,624 @@
// 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 app
import (
"time"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
)
// instrumentedStorage wraps a storage.Storage to record Prometheus metrics for
// every operation.
type instrumentedStorage struct {
inner storage.Storage
}
// newInstrumentedStorage wraps the given Storage with metrics instrumentation.
func newInstrumentedStorage(s storage.Storage) storage.Storage {
return &instrumentedStorage{inner: s}
}
// observe records the duration and outcome of a storage operation.
func observeStorage(operation, entity string, start time.Time, err error) {
status := "success"
if err != nil {
status = "error"
}
metrics.StorageOperationsTotal.WithLabelValues(operation, entity, status).Inc()
metrics.StorageOperationDuration.WithLabelValues(operation, entity).Observe(time.Since(start).Seconds())
}
// Schema / lifecycle
func (s *instrumentedStorage) SchemaVersion() int {
return s.inner.SchemaVersion()
}
func (s *instrumentedStorage) MigrateSchema() error {
return s.inner.MigrateSchema()
}
func (s *instrumentedStorage) Close() error {
return s.inner.Close()
}
// AuthUser
func (s *instrumentedStorage) ListAllAuthUsers() (ret happydns.Iterator[happydns.UserAuth], err error) {
start := time.Now()
ret, err = s.inner.ListAllAuthUsers()
observeStorage("list", "authuser", start, err)
return
}
func (s *instrumentedStorage) GetAuthUser(id happydns.Identifier) (ret *happydns.UserAuth, err error) {
start := time.Now()
ret, err = s.inner.GetAuthUser(id)
observeStorage("get", "authuser", start, err)
return
}
func (s *instrumentedStorage) GetAuthUserByEmail(email string) (ret *happydns.UserAuth, err error) {
start := time.Now()
ret, err = s.inner.GetAuthUserByEmail(email)
observeStorage("get", "authuser", start, err)
return
}
func (s *instrumentedStorage) AuthUserExists(email string) (ret bool, err error) {
start := time.Now()
ret, err = s.inner.AuthUserExists(email)
observeStorage("get", "authuser", start, err)
return
}
func (s *instrumentedStorage) CreateAuthUser(user *happydns.UserAuth) (err error) {
start := time.Now()
err = s.inner.CreateAuthUser(user)
observeStorage("create", "authuser", start, err)
return
}
func (s *instrumentedStorage) UpdateAuthUser(user *happydns.UserAuth) (err error) {
start := time.Now()
err = s.inner.UpdateAuthUser(user)
observeStorage("update", "authuser", start, err)
return
}
func (s *instrumentedStorage) DeleteAuthUser(user *happydns.UserAuth) (err error) {
start := time.Now()
err = s.inner.DeleteAuthUser(user)
observeStorage("delete", "authuser", start, err)
return
}
func (s *instrumentedStorage) ClearAuthUsers() (err error) {
start := time.Now()
err = s.inner.ClearAuthUsers()
observeStorage("delete", "authuser", start, err)
return
}
// Domain
func (s *instrumentedStorage) ListAllDomains() (ret happydns.Iterator[happydns.Domain], err error) {
start := time.Now()
ret, err = s.inner.ListAllDomains()
observeStorage("list", "domain", start, err)
return
}
func (s *instrumentedStorage) ListDomains(user *happydns.User) (ret []*happydns.Domain, err error) {
start := time.Now()
ret, err = s.inner.ListDomains(user)
observeStorage("list", "domain", start, err)
return
}
func (s *instrumentedStorage) GetDomain(domainid happydns.Identifier) (ret *happydns.Domain, err error) {
start := time.Now()
ret, err = s.inner.GetDomain(domainid)
observeStorage("get", "domain", start, err)
return
}
func (s *instrumentedStorage) GetDomainByDN(user *happydns.User, fqdn string) (ret []*happydns.Domain, err error) {
start := time.Now()
ret, err = s.inner.GetDomainByDN(user, fqdn)
observeStorage("get", "domain", start, err)
return
}
func (s *instrumentedStorage) CreateDomain(domain *happydns.Domain) (err error) {
start := time.Now()
err = s.inner.CreateDomain(domain)
observeStorage("create", "domain", start, err)
return
}
func (s *instrumentedStorage) UpdateDomain(domain *happydns.Domain) (err error) {
start := time.Now()
err = s.inner.UpdateDomain(domain)
observeStorage("update", "domain", start, err)
return
}
func (s *instrumentedStorage) DeleteDomain(domainid happydns.Identifier) (err error) {
start := time.Now()
err = s.inner.DeleteDomain(domainid)
observeStorage("delete", "domain", start, err)
return
}
func (s *instrumentedStorage) ClearDomains() (err error) {
start := time.Now()
err = s.inner.ClearDomains()
observeStorage("delete", "domain", start, err)
return
}
// DomainLog
func (s *instrumentedStorage) ListAllDomainLogs() (ret happydns.Iterator[happydns.DomainLogWithDomainId], err error) {
start := time.Now()
ret, err = s.inner.ListAllDomainLogs()
observeStorage("list", "domain_log", start, err)
return
}
func (s *instrumentedStorage) ListDomainLogs(domain *happydns.Domain) (ret []*happydns.DomainLog, err error) {
start := time.Now()
ret, err = s.inner.ListDomainLogs(domain)
observeStorage("list", "domain_log", start, err)
return
}
func (s *instrumentedStorage) CreateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
start := time.Now()
err = s.inner.CreateDomainLog(domain, log)
observeStorage("create", "domain_log", start, err)
return
}
func (s *instrumentedStorage) UpdateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
start := time.Now()
err = s.inner.UpdateDomainLog(domain, log)
observeStorage("update", "domain_log", start, err)
return
}
func (s *instrumentedStorage) DeleteDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
start := time.Now()
err = s.inner.DeleteDomainLog(domain, log)
observeStorage("delete", "domain_log", start, err)
return
}
// Insight
func (s *instrumentedStorage) InsightsRun() (err error) {
start := time.Now()
err = s.inner.InsightsRun()
observeStorage("run", "insight", start, err)
return
}
func (s *instrumentedStorage) LastInsightsRun() (t *time.Time, id happydns.Identifier, err error) {
start := time.Now()
t, id, err = s.inner.LastInsightsRun()
observeStorage("get", "insight", start, err)
return
}
// Provider
func (s *instrumentedStorage) ListAllProviders() (ret happydns.Iterator[happydns.ProviderMessage], err error) {
start := time.Now()
ret, err = s.inner.ListAllProviders()
observeStorage("list", "provider", start, err)
return
}
func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.ProviderMessages, err error) {
start := time.Now()
ret, err = s.inner.ListProviders(user)
observeStorage("list", "provider", start, err)
return
}
func (s *instrumentedStorage) GetProvider(prvdid happydns.Identifier) (ret *happydns.ProviderMessage, err error) {
start := time.Now()
ret, err = s.inner.GetProvider(prvdid)
observeStorage("get", "provider", start, err)
return
}
func (s *instrumentedStorage) CreateProvider(prvd *happydns.Provider) (err error) {
start := time.Now()
err = s.inner.CreateProvider(prvd)
observeStorage("create", "provider", start, err)
return
}
func (s *instrumentedStorage) UpdateProvider(prvd *happydns.Provider) (err error) {
start := time.Now()
err = s.inner.UpdateProvider(prvd)
observeStorage("update", "provider", start, err)
return
}
func (s *instrumentedStorage) DeleteProvider(prvdid happydns.Identifier) (err error) {
start := time.Now()
err = s.inner.DeleteProvider(prvdid)
observeStorage("delete", "provider", start, err)
return
}
func (s *instrumentedStorage) ClearProviders() (err error) {
start := time.Now()
err = s.inner.ClearProviders()
observeStorage("delete", "provider", start, err)
return
}
// Session
func (s *instrumentedStorage) ListAllSessions() (ret happydns.Iterator[happydns.Session], err error) {
start := time.Now()
ret, err = s.inner.ListAllSessions()
observeStorage("list", "session", start, err)
return
}
func (s *instrumentedStorage) GetSession(sessionid string) (ret *happydns.Session, err error) {
start := time.Now()
ret, err = s.inner.GetSession(sessionid)
observeStorage("get", "session", start, err)
return
}
func (s *instrumentedStorage) ListAuthUserSessions(user *happydns.UserAuth) (ret []*happydns.Session, err error) {
start := time.Now()
ret, err = s.inner.ListAuthUserSessions(user)
observeStorage("list", "session", start, err)
return
}
func (s *instrumentedStorage) ListUserSessions(userid happydns.Identifier) (ret []*happydns.Session, err error) {
start := time.Now()
ret, err = s.inner.ListUserSessions(userid)
observeStorage("list", "session", start, err)
return
}
func (s *instrumentedStorage) UpdateSession(session *happydns.Session) (err error) {
start := time.Now()
err = s.inner.UpdateSession(session)
observeStorage("update", "session", start, err)
return
}
func (s *instrumentedStorage) DeleteSession(sessionid string) (err error) {
start := time.Now()
err = s.inner.DeleteSession(sessionid)
observeStorage("delete", "session", start, err)
return
}
func (s *instrumentedStorage) ClearSessions() (err error) {
start := time.Now()
err = s.inner.ClearSessions()
observeStorage("delete", "session", start, err)
return
}
// CheckResult / CheckSchedule / CheckExecution
func (s *instrumentedStorage) ListCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) (ret []*happydns.CheckResult, err error) {
start := time.Now()
ret, err = s.inner.ListCheckResults(checkName, targetType, targetId, limit)
observeStorage("list", "check_result", start, err)
return
}
func (s *instrumentedStorage) ListCheckResultsByPlugin(userId happydns.Identifier, checkName string, limit int) (ret []*happydns.CheckResult, err error) {
start := time.Now()
ret, err = s.inner.ListCheckResultsByPlugin(userId, checkName, limit)
observeStorage("list", "check_result", start, err)
return
}
func (s *instrumentedStorage) ListCheckResultsByUser(userId happydns.Identifier, limit int) (ret []*happydns.CheckResult, err error) {
start := time.Now()
ret, err = s.inner.ListCheckResultsByUser(userId, limit)
observeStorage("list", "check_result", start, err)
return
}
func (s *instrumentedStorage) GetCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (ret *happydns.CheckResult, err error) {
start := time.Now()
ret, err = s.inner.GetCheckResult(checkName, targetType, targetId, resultId)
observeStorage("get", "check_result", start, err)
return
}
func (s *instrumentedStorage) CreateCheckResult(result *happydns.CheckResult) (err error) {
start := time.Now()
err = s.inner.CreateCheckResult(result)
observeStorage("create", "check_result", start, err)
return
}
func (s *instrumentedStorage) DeleteCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (err error) {
start := time.Now()
err = s.inner.DeleteCheckResult(checkName, targetType, targetId, resultId)
observeStorage("delete", "check_result", start, err)
return
}
func (s *instrumentedStorage) DeleteOldCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, keepCount int) (err error) {
start := time.Now()
err = s.inner.DeleteOldCheckResults(checkName, targetType, targetId, keepCount)
observeStorage("delete", "check_result", start, err)
return
}
func (s *instrumentedStorage) ListEnabledCheckerSchedules() (ret []*happydns.CheckerSchedule, err error) {
start := time.Now()
ret, err = s.inner.ListEnabledCheckerSchedules()
observeStorage("list", "check_schedule", start, err)
return
}
func (s *instrumentedStorage) ListCheckerSchedulesByUser(userId happydns.Identifier) (ret []*happydns.CheckerSchedule, err error) {
start := time.Now()
ret, err = s.inner.ListCheckerSchedulesByUser(userId)
observeStorage("list", "check_schedule", start, err)
return
}
func (s *instrumentedStorage) ListCheckerSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier, insideType *happydns.CheckScopeType, insideId *happydns.Identifier) (ret []*happydns.CheckerSchedule, err error) {
start := time.Now()
ret, err = s.inner.ListCheckerSchedulesByTarget(targetType, targetId, insideType, insideId)
observeStorage("list", "check_schedule", start, err)
return
}
func (s *instrumentedStorage) GetCheckerSchedule(scheduleId happydns.Identifier) (ret *happydns.CheckerSchedule, err error) {
start := time.Now()
ret, err = s.inner.GetCheckerSchedule(scheduleId)
observeStorage("get", "check_schedule", start, err)
return
}
func (s *instrumentedStorage) CreateCheckerSchedule(schedule *happydns.CheckerSchedule) (err error) {
start := time.Now()
err = s.inner.CreateCheckerSchedule(schedule)
observeStorage("create", "check_schedule", start, err)
return
}
func (s *instrumentedStorage) UpdateCheckerSchedule(schedule *happydns.CheckerSchedule) (err error) {
start := time.Now()
err = s.inner.UpdateCheckerSchedule(schedule)
observeStorage("update", "check_schedule", start, err)
return
}
func (s *instrumentedStorage) DeleteCheckerSchedule(scheduleId happydns.Identifier) (err error) {
start := time.Now()
err = s.inner.DeleteCheckerSchedule(scheduleId)
observeStorage("delete", "check_schedule", start, err)
return
}
func (s *instrumentedStorage) ListActiveCheckExecutions() (ret []*happydns.CheckExecution, err error) {
start := time.Now()
ret, err = s.inner.ListActiveCheckExecutions()
observeStorage("list", "check_execution", start, err)
return
}
func (s *instrumentedStorage) GetCheckExecution(executionId happydns.Identifier) (ret *happydns.CheckExecution, err error) {
start := time.Now()
ret, err = s.inner.GetCheckExecution(executionId)
observeStorage("get", "check_execution", start, err)
return
}
func (s *instrumentedStorage) CreateCheckExecution(execution *happydns.CheckExecution) (err error) {
start := time.Now()
err = s.inner.CreateCheckExecution(execution)
observeStorage("create", "check_execution", start, err)
return
}
func (s *instrumentedStorage) UpdateCheckExecution(execution *happydns.CheckExecution) (err error) {
start := time.Now()
err = s.inner.UpdateCheckExecution(execution)
observeStorage("update", "check_execution", start, err)
return
}
func (s *instrumentedStorage) DeleteCheckExecution(executionId happydns.Identifier) (err error) {
start := time.Now()
err = s.inner.DeleteCheckExecution(executionId)
observeStorage("delete", "check_execution", start, err)
return
}
func (s *instrumentedStorage) CheckSchedulerRun() (err error) {
start := time.Now()
err = s.inner.CheckSchedulerRun()
observeStorage("run", "check_schedule", start, err)
return
}
func (s *instrumentedStorage) LastCheckSchedulerRun() (t *time.Time, err error) {
start := time.Now()
t, err = s.inner.LastCheckSchedulerRun()
observeStorage("get", "check_schedule", start, err)
return
}
// CheckerConfiguration
func (s *instrumentedStorage) ListAllCheckerConfigurations() (ret happydns.Iterator[happydns.CheckerOptions], err error) {
start := time.Now()
ret, err = s.inner.ListAllCheckerConfigurations()
observeStorage("list", "check_config", start, err)
return
}
func (s *instrumentedStorage) ListCheckerConfiguration(name string) (ret []*happydns.CheckerOptionsPositional, err error) {
start := time.Now()
ret, err = s.inner.ListCheckerConfiguration(name)
observeStorage("list", "check_config", start, err)
return
}
func (s *instrumentedStorage) GetCheckerConfiguration(name string, a *happydns.Identifier, b *happydns.Identifier, c *happydns.Identifier) (ret []*happydns.CheckerOptionsPositional, err error) {
start := time.Now()
ret, err = s.inner.GetCheckerConfiguration(name, a, b, c)
observeStorage("get", "check_config", start, err)
return
}
func (s *instrumentedStorage) UpdateCheckerConfiguration(name string, a *happydns.Identifier, b *happydns.Identifier, c *happydns.Identifier, opts happydns.CheckerOptions) (err error) {
start := time.Now()
err = s.inner.UpdateCheckerConfiguration(name, a, b, c, opts)
observeStorage("update", "check_config", start, err)
return
}
func (s *instrumentedStorage) DeleteCheckerConfiguration(name string, a *happydns.Identifier, b *happydns.Identifier, c *happydns.Identifier) (err error) {
start := time.Now()
err = s.inner.DeleteCheckerConfiguration(name, a, b, c)
observeStorage("delete", "check_config", start, err)
return
}
func (s *instrumentedStorage) ClearCheckerConfigurations() (err error) {
start := time.Now()
err = s.inner.ClearCheckerConfigurations()
observeStorage("delete", "check_config", start, err)
return
}
// User
func (s *instrumentedStorage) ListAllUsers() (ret happydns.Iterator[happydns.User], err error) {
start := time.Now()
ret, err = s.inner.ListAllUsers()
observeStorage("list", "user", start, err)
return
}
func (s *instrumentedStorage) GetUser(userid happydns.Identifier) (ret *happydns.User, err error) {
start := time.Now()
ret, err = s.inner.GetUser(userid)
observeStorage("get", "user", start, err)
return
}
func (s *instrumentedStorage) GetUserByEmail(email string) (ret *happydns.User, err error) {
start := time.Now()
ret, err = s.inner.GetUserByEmail(email)
observeStorage("get", "user", start, err)
return
}
func (s *instrumentedStorage) CreateOrUpdateUser(user *happydns.User) (err error) {
start := time.Now()
err = s.inner.CreateOrUpdateUser(user)
observeStorage("update", "user", start, err)
return
}
func (s *instrumentedStorage) DeleteUser(userid happydns.Identifier) (err error) {
start := time.Now()
err = s.inner.DeleteUser(userid)
observeStorage("delete", "user", start, err)
return
}
func (s *instrumentedStorage) ClearUsers() (err error) {
start := time.Now()
err = s.inner.ClearUsers()
observeStorage("delete", "user", start, err)
return
}
// Zone
func (s *instrumentedStorage) ListAllZones() (ret happydns.Iterator[happydns.ZoneMessage], err error) {
start := time.Now()
ret, err = s.inner.ListAllZones()
observeStorage("list", "zone", start, err)
return
}
func (s *instrumentedStorage) GetZoneMeta(zoneid happydns.Identifier) (ret *happydns.ZoneMeta, err error) {
start := time.Now()
ret, err = s.inner.GetZoneMeta(zoneid)
observeStorage("get", "zone", start, err)
return
}
func (s *instrumentedStorage) GetZone(zoneid happydns.Identifier) (ret *happydns.ZoneMessage, err error) {
start := time.Now()
ret, err = s.inner.GetZone(zoneid)
observeStorage("get", "zone", start, err)
return
}
func (s *instrumentedStorage) CreateZone(zone *happydns.Zone) (err error) {
start := time.Now()
err = s.inner.CreateZone(zone)
observeStorage("create", "zone", start, err)
return
}
func (s *instrumentedStorage) UpdateZone(zone *happydns.Zone) (err error) {
start := time.Now()
err = s.inner.UpdateZone(zone)
observeStorage("update", "zone", start, err)
return
}
func (s *instrumentedStorage) DeleteZone(zoneid happydns.Identifier) (err error) {
start := time.Now()
err = s.inner.DeleteZone(zoneid)
observeStorage("delete", "zone", start, err)
return
}
func (s *instrumentedStorage) ClearZones() (err error) {
start := time.Now()
err = s.inner.ClearZones()
observeStorage("delete", "zone", start, err)
return
}

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

@ -0,0 +1,134 @@
// 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 app
import (
"fmt"
"log"
"os"
"path/filepath"
"plugin"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/model"
)
// pluginLoader attempts to find and register one specific kind of plugin
// symbol from an already-opened .so file.
//
// It returns (true, nil) when the symbol was found and registration
// succeeded, (true, err) when the symbol was found but something went wrong,
// and (false, nil) when the symbol simply isn't present in that file (which
// is not considered an error — a single .so may implement only a subset of
// the known plugin types).
type pluginLoader func(p *plugin.Plugin, fname string) (found bool, err error)
// pluginLoaders is the authoritative list of plugin types that happyDomain
// knows about. To support a new plugin type, add a single entry here.
var pluginLoaders = []pluginLoader{
loadCheckPlugin,
}
// loadCheckPlugin handles the NewTestPlugin symbol.
func loadCheckPlugin(p *plugin.Plugin, fname string) (bool, error) {
sym, err := p.Lookup("NewCheckPlugin")
if err != nil {
// Symbol not present in this .so — not an error.
return false, nil
}
factory, ok := sym.(func() (string, happydns.Checker, error))
if !ok {
return true, fmt.Errorf("symbol NewCheckPlugin has unexpected type %T", sym)
}
pluginname, myplugin, err := factory()
if err != nil {
return true, err
}
checks.RegisterChecker(pluginname, myplugin)
log.Printf("Plugin %s loaded", pluginname)
return true, nil
}
// initPlugins scans each directory listed in cfg.PluginsDirectories, loads
// every .so file found as a Go plugin, and registers it in the application's
// PluginManager. All load errors are collected and returned as a joined error
// so that a single bad plugin does not prevent the others from loading.
func (a *App) initPlugins() error {
for _, directory := range a.cfg.PluginsDirectories {
files, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("unable to read plugins directory %q: %s", directory, err)
}
for _, file := range files {
if file.IsDir() {
continue
}
// Only attempt to load shared-object files.
if filepath.Ext(file.Name()) != ".so" {
continue
}
fname := filepath.Join(directory, file.Name())
err = loadPlugin(fname)
if err != nil {
log.Printf("Unable to load plugin %q: %s", fname, err)
}
}
}
return nil
}
// loadPlugin opens the .so file at fname and runs every registered
// pluginLoader against it. A loader that does not find its symbol is silently
// skipped. If no loader recognises any symbol in the file a warning is logged,
// but no error is returned because the file might be a valid plugin for a
// future version of happyDomain. The first loader error that is encountered
// is returned immediately.
func loadPlugin(fname string) error {
p, err := plugin.Open(fname)
if err != nil {
return err
}
anyFound := false
for _, loader := range pluginLoaders {
found, err := loader(p, fname)
if err != nil {
return err
}
if found {
anyFound = true
}
}
if !anyFound {
log.Printf("Warning: plugin %q exports no recognised symbols", fname)
}
return nil
}

View file

@ -60,6 +60,8 @@ func declareFlags(o *happydns.Options) {
flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (altcha, hcaptcha, recaptchav2, turnstile, or empty to disable)")
flag.IntVar(&o.CaptchaLoginThreshold, "captcha-login-threshold", 3, "Number of failed login attempts before captcha is required (0 = always require when provider configured)")
flag.Var(&ArrayArgs{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing plugins (can be repeated multiple times)")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -44,15 +44,18 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
// Define defaults options
opts = &happydns.Options{
AdminBind: "./happydomain.sock",
BasePath: "/",
Bind: ":8081",
DefaultNameServer: "127.0.0.1:53",
ExternalURL: *u,
JWTSigningMethod: "HS512",
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
MailSMTPPort: 587,
StorageEngine: "leveldb",
AdminBind: "./happydomain.sock",
BasePath: "/",
Bind: ":8081",
DefaultNameServer: "127.0.0.1:53",
ExternalURL: *u,
JWTSigningMethod: "HS512",
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
MailSMTPPort: 587,
StorageEngine: "leveldb",
MaxResultsPerCheck: 100,
ResultRetentionDays: 90,
TestWorkers: 2,
}
declareFlags(opts)

View file

@ -25,8 +25,25 @@ import (
"encoding/base64"
"net/mail"
"net/url"
"strings"
)
type ArrayArgs struct {
Slice *[]string
}
func (i *ArrayArgs) String() string {
if i == nil || i.Slice == nil {
return ""
}
return strings.Join(*i.Slice, ",")
}
func (i *ArrayArgs) Set(value string) error {
*i.Slice = append(*i.Slice, value)
return nil
}
type JWTSecretKey struct {
Secret *[]byte
}

View file

@ -22,7 +22,9 @@
package forms // import "git.happydns.org/happyDomain/forms"
import (
"fmt"
"reflect"
"slices"
"strings"
"git.happydns.org/happyDomain/model"
@ -78,6 +80,53 @@ func GenField(field reflect.StructField) (f *happydns.Field) {
return
}
// ValidateStructValues validates the field values of a struct against the
// constraints declared in its happydomain struct tags (choices, required).
// Since the struct is already typed, basic type checking is handled by the
// JSON decoder; this function validates higher-level constraints.
func ValidateStructValues(data any) error {
if data == nil {
return nil
}
v := reflect.Indirect(reflect.ValueOf(data))
t := v.Type()
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
if sf.Anonymous {
if err := ValidateStructValues(v.Field(i).Interface()); err != nil {
return err
}
continue
}
field := GenField(sf)
fv := v.Field(i)
if field.Required && fv.IsZero() {
label := field.Label
if label == "" {
label = field.Id
}
return fmt.Errorf("field %q is required", label)
}
if len(field.Choices) > 0 && fv.Kind() == reflect.String {
s := fv.String()
if s != "" && !slices.Contains(field.Choices, s) {
label := field.Label
if label == "" {
label = field.Id
}
return fmt.Errorf("field %q: value %q is not a valid choice (valid: %v)", label, s, field.Choices)
}
}
}
return nil
}
// GenStructFields generates corresponding SourceFields of the given Source.
func GenStructFields(data any) (fields []*happydns.Field) {
if data != nil {

View file

@ -33,6 +33,9 @@ func DoSettingState(fu happydns.FormUsecase, state *happydns.FormState, data any
}
if state.State == 1 {
if verr := ValidateStructValues(data); verr != nil {
return nil, nil, verr
}
err = happydns.DoneForm
} else {
form = defaultForm(data)

View file

@ -0,0 +1,101 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
)
// StatsProvider is the minimal interface required by StorageStatsCollector to
// count business entities. It is implemented by internal/app.storageStatsProvider.
type StatsProvider interface {
CountUsers() (int, error)
CountDomains() (int, error)
CountZones() (int, error)
CountProviders() (int, error)
}
// StorageStatsCollector is a Prometheus Collector that queries storage at each
// scrape to report accurate business-entity counts.
type StorageStatsCollector struct {
provider StatsProvider
usersDesc *prometheus.Desc
domainsDesc *prometheus.Desc
zonesDesc *prometheus.Desc
providersDesc *prometheus.Desc
}
// NewStorageStatsCollector creates a new collector backed by the given StatsProvider
// and registers it with the default Prometheus registry.
func NewStorageStatsCollector(p StatsProvider) *StorageStatsCollector {
c := &StorageStatsCollector{
provider: p,
usersDesc: prometheus.NewDesc(
"happydomain_registered_users_total",
"Current number of registered user accounts.",
nil, nil,
),
domainsDesc: prometheus.NewDesc(
"happydomain_domains_total",
"Current number of domains managed across all users.",
nil, nil,
),
zonesDesc: prometheus.NewDesc(
"happydomain_zones_total",
"Current number of zone snapshots stored.",
nil, nil,
),
providersDesc: prometheus.NewDesc(
"happydomain_providers_total",
"Current number of provider configurations across all users.",
nil, nil,
),
}
prometheus.MustRegister(c)
return c
}
// Describe implements prometheus.Collector.
func (c *StorageStatsCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.usersDesc
ch <- c.domainsDesc
ch <- c.zonesDesc
ch <- c.providersDesc
}
// Collect implements prometheus.Collector. It queries storage live so the
// values always reflect the actual database state.
func (c *StorageStatsCollector) Collect(ch chan<- prometheus.Metric) {
if n, err := c.provider.CountUsers(); err == nil {
ch <- prometheus.MustNewConstMetric(c.usersDesc, prometheus.GaugeValue, float64(n))
}
if n, err := c.provider.CountDomains(); err == nil {
ch <- prometheus.MustNewConstMetric(c.domainsDesc, prometheus.GaugeValue, float64(n))
}
if n, err := c.provider.CountZones(); err == nil {
ch <- prometheus.MustNewConstMetric(c.zonesDesc, prometheus.GaugeValue, float64(n))
}
if n, err := c.provider.CountProviders(); err == nil {
ch <- prometheus.MustNewConstMetric(c.providersDesc, prometheus.GaugeValue, float64(n))
}
}

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

@ -0,0 +1,54 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package metrics
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// HTTPMiddleware returns a Gin middleware that records HTTP request metrics.
// It uses c.FullPath() to get the route pattern (e.g. /api/domains/:domain)
// rather than the actual URL, avoiding high-cardinality labels.
func HTTPMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
HTTPRequestsInFlight.Inc()
c.Next()
HTTPRequestsInFlight.Dec()
path := c.FullPath()
if path == "" {
path = "unknown"
}
method := c.Request.Method
status := strconv.Itoa(c.Writer.Status())
duration := time.Since(start).Seconds()
HTTPRequestsTotal.WithLabelValues(method, path, status).Inc()
HTTPRequestDuration.WithLabelValues(method, path).Observe(duration)
}
}

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

@ -0,0 +1,104 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
// HTTP metrics
HTTPRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_http_requests_total",
Help: "Total number of HTTP requests.",
}, []string{"method", "path", "status"})
HTTPRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "happydomain_http_request_duration_seconds",
Help: "Duration of HTTP requests in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"method", "path"})
HTTPRequestsInFlight = promauto.NewGauge(prometheus.GaugeOpts{
Name: "happydomain_http_requests_in_flight",
Help: "Current number of HTTP requests being served.",
})
// Scheduler metrics
SchedulerQueueDepth = promauto.NewGauge(prometheus.GaugeOpts{
Name: "happydomain_scheduler_queue_depth",
Help: "Number of items currently in the check scheduler queue.",
})
SchedulerActiveWorkers = promauto.NewGauge(prometheus.GaugeOpts{
Name: "happydomain_scheduler_active_workers",
Help: "Number of check scheduler workers currently executing a check.",
})
SchedulerChecksTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_scheduler_checks_total",
Help: "Total number of checks executed by the scheduler.",
}, []string{"checker", "status"})
SchedulerCheckDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "happydomain_scheduler_check_duration_seconds",
Help: "Duration of individual check executions in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"checker"})
// DNS provider API metrics
ProviderAPICallsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_provider_api_calls_total",
Help: "Total number of DNS provider API calls.",
}, []string{"provider", "operation", "status"})
ProviderAPIDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "happydomain_provider_api_duration_seconds",
Help: "Duration of DNS provider API calls in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"provider", "operation"})
// Storage metrics
StorageOperationsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_storage_operations_total",
Help: "Total number of storage operations.",
}, []string{"operation", "entity", "status"})
StorageOperationDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "happydomain_storage_operation_duration_seconds",
Help: "Duration of storage operations in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"operation", "entity"})
// Build info
BuildInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "happydomain_build_info",
Help: "Build information about the running happyDomain instance.",
}, []string{"version"})
)
// SetBuildInfo records the application version in the build info metric.
// Call this once during application startup.
func SetBuildInfo(version string) {
BuildInfo.WithLabelValues(version).Set(1)
}

View file

@ -40,6 +40,7 @@ type InMemoryStorage struct {
data map[string][]byte // Generic key-value store for KVStorage interface
authUsers map[string]*happydns.UserAuth
authUsersByEmail map[string]happydns.Identifier
checksCfg map[string]*happydns.CheckerOptions
domains map[string]*happydns.Domain
domainLogs map[string]*happydns.DomainLogWithDomainId
domainLogsByDomains map[string][]*happydns.Identifier
@ -58,6 +59,7 @@ func NewInMemoryStorage() (*InMemoryStorage, error) {
data: make(map[string][]byte),
authUsers: make(map[string]*happydns.UserAuth),
authUsersByEmail: make(map[string]happydns.Identifier),
checksCfg: make(map[string]*happydns.CheckerOptions),
domains: make(map[string]*happydns.Domain),
domainLogs: make(map[string]*happydns.DomainLogWithDomainId),
domainLogsByDomains: make(map[string][]*happydns.Identifier),

View file

@ -23,6 +23,8 @@ package storage // import "git.happydns.org/happyDomain/internal/storage"
import (
"git.happydns.org/happyDomain/internal/usecase/authuser"
"git.happydns.org/happyDomain/internal/usecase/check"
"git.happydns.org/happyDomain/internal/usecase/checkresult"
"git.happydns.org/happyDomain/internal/usecase/domain"
"git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/insight"
@ -43,8 +45,10 @@ type Storage interface {
domain.DomainStorage
domainlog.DomainLogStorage
insight.InsightStorage
check.CheckerStorage
provider.ProviderStorage
session.SessionStorage
checkresult.CheckResultStorage
user.UserStorage
zone.ZoneStorage

View file

@ -0,0 +1,185 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"strings"
"git.happydns.org/happyDomain/model"
)
func (s *KVStorage) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) {
iter := s.db.Search("chckrcfg-")
return NewKVIterator[happydns.CheckerOptions](s.db, iter), nil
}
func buildCheckerKey(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) string {
u := ""
if user != nil {
u = user.String()
}
d := ""
if domain != nil {
d = domain.String()
}
s := ""
if service != nil {
s = service.String()
}
return strings.Join([]string{cname, u, d, s}, "/")
}
func keyToPositional(key string, opts *happydns.CheckerOptions) (*happydns.CheckerOptionsPositional, error) {
tmp := strings.Split(key, "/")
if len(tmp) < 4 {
return nil, fmt.Errorf("malformed plugin configuration key, got %q", key)
}
cname := tmp[0]
var userid *happydns.Identifier
if len(tmp[1]) > 0 {
u, err := happydns.NewIdentifierFromString(tmp[1])
if err != nil {
return nil, err
}
userid = &u
}
var domainid *happydns.Identifier
if len(tmp[2]) > 0 {
d, err := happydns.NewIdentifierFromString(tmp[2])
if err != nil {
return nil, err
}
domainid = &d
}
var serviceid *happydns.Identifier
if len(tmp[3]) > 0 {
s, err := happydns.NewIdentifierFromString(tmp[3])
if err != nil {
return nil, err
}
serviceid = &s
}
return &happydns.CheckerOptionsPositional{
CheckName: cname,
UserId: userid,
DomainId: domainid,
ServiceId: serviceid,
Options: *opts,
}, nil
}
func (s *KVStorage) ListCheckerConfiguration(cname string) (configs []*happydns.CheckerOptionsPositional, err error) {
iter := s.db.Search("chckrcfg-" + cname + "/")
defer iter.Release()
for iter.Next() {
var p happydns.CheckerOptions
e := s.db.DecodeData(iter.Value(), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "chckrcfg-"), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
configs = append(configs, opts)
}
return
}
func (s *KVStorage) GetCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) (configs []*happydns.CheckerOptionsPositional, err error) {
iter := s.db.Search("chckrcfg-" + cname + "/")
defer iter.Release()
for iter.Next() {
var p happydns.CheckerOptions
e := s.db.DecodeData(iter.Value(), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "chckrcfg-"), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
// Match logic:
// - When parameter is nil: match ONLY configs with nil ID (requesting specific scope)
// - When parameter is not nil: match configs with nil ID (admin-level) OR matching ID
matchUser := (user == nil && opts.UserId == nil) ||
(user != nil && (opts.UserId == nil || opts.UserId.Equals(*user)))
matchDomain := (domain == nil && opts.DomainId == nil) ||
(domain != nil && (opts.DomainId == nil || opts.DomainId.Equals(*domain)))
matchService := (service == nil && opts.ServiceId == nil) ||
(service != nil && (opts.ServiceId == nil || opts.ServiceId.Equals(*service)))
if matchUser && matchDomain && matchService {
configs = append(configs, opts)
}
}
return
}
func (s *KVStorage) UpdateCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier, opts happydns.CheckerOptions) error {
return s.db.Put(fmt.Sprintf("chckrcfg-%s", buildCheckerKey(cname, user, domain, service)), opts)
}
func (s *KVStorage) DeleteCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) error {
return s.db.Delete(fmt.Sprintf("chckrcfg-%s", buildCheckerKey(cname, user, domain, service)))
}
func (s *KVStorage) ClearCheckerConfigurations() error {
iter := s.db.Search("chckrcfg-")
defer iter.Release()
for iter.Next() {
err := s.db.Delete(iter.Key())
if err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,453 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"sort"
"strings"
"time"
"git.happydns.org/happyDomain/model"
)
// Check Result storage keys:
// checkresult|{plugin-name}|{target-type}|{target-id}|{result-id}
func makeCheckResultKey(checkName string, targetType happydns.CheckScopeType, targetId, resultId happydns.Identifier) string {
return fmt.Sprintf("checkresult|%s|%d|%s|%s", checkName, targetType, targetId.String(), resultId.String())
}
func makeCheckResultPrefix(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier) string {
return fmt.Sprintf("checkresult|%s|%d|%s|", checkName, targetType, targetId.String())
}
// ListCheckResults retrieves check results for a specific plugin+target combination
func (s *KVStorage) ListCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
prefix := makeCheckResultPrefix(checkName, targetType, targetId)
iter := s.db.Search(prefix)
defer iter.Release()
var results []*happydns.CheckResult
for iter.Next() {
var r happydns.CheckResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
return nil, err
}
results = append(results, &r)
}
// Sort by ExecutedAt descending (most recent first)
sort.Slice(results, func(i, j int) bool {
return results[i].ExecutedAt.After(results[j].ExecutedAt)
})
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// ListCheckResultsByPlugin retrieves all check results for a plugin across all targets for a user
func (s *KVStorage) ListCheckResultsByPlugin(userId happydns.Identifier, checkName string, limit int) ([]*happydns.CheckResult, error) {
prefix := fmt.Sprintf("checkresult|%s|", checkName)
iter := s.db.Search(prefix)
defer iter.Release()
var results []*happydns.CheckResult
for iter.Next() {
var r happydns.CheckResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
return nil, err
}
// Filter by user
if r.OwnerId.Equals(userId) {
results = append(results, &r)
}
}
// Sort by ExecutedAt descending (most recent first)
sort.Slice(results, func(i, j int) bool {
return results[i].ExecutedAt.After(results[j].ExecutedAt)
})
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// ListCheckResultsByUser retrieves all check results for a user
func (s *KVStorage) ListCheckResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
iter := s.db.Search("checkresult|")
defer iter.Release()
var results []*happydns.CheckResult
for iter.Next() {
var r happydns.CheckResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
return nil, err
}
// Filter by user
if r.OwnerId.Equals(userId) {
results = append(results, &r)
}
}
// Sort by ExecutedAt descending (most recent first)
sort.Slice(results, func(i, j int) bool {
return results[i].ExecutedAt.After(results[j].ExecutedAt)
})
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// GetCheckResult retrieves a specific check result by its ID
func (s *KVStorage) GetCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error) {
key := makeCheckResultKey(checkName, targetType, targetId, resultId)
var result happydns.CheckResult
err := s.db.Get(key, &result)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrCheckResultNotFound
}
return &result, err
}
// CreateCheckResult stores a new check result
func (s *KVStorage) CreateCheckResult(result *happydns.CheckResult) error {
prefix := makeCheckResultPrefix(result.CheckerName, result.CheckType, result.TargetId)
key, id, err := s.db.FindIdentifierKey(prefix)
if err != nil {
return err
}
result.Id = id
return s.db.Put(key, result)
}
// DeleteCheckResult removes a specific check result
func (s *KVStorage) DeleteCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
key := makeCheckResultKey(checkName, targetType, targetId, resultId)
return s.db.Delete(key)
}
// DeleteOldCheckResults removes old check results keeping only the most recent N results
func (s *KVStorage) DeleteOldCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, keepCount int) error {
results, err := s.ListCheckResults(checkName, targetType, targetId, 0)
if err != nil {
return err
}
// Results are already sorted by ExecutedAt descending
// Delete results beyond keepCount
if len(results) > keepCount {
for _, r := range results[keepCount:] {
if err := s.DeleteCheckResult(checkName, targetType, targetId, r.Id); err != nil {
return err
}
}
}
return nil
}
// Checker Schedule storage keys:
// checkschedule|{schedule-id}
// checkschedule.byuser|{user-id}|{schedule-id}
// checkschedule.bytarget|{target-type}|{target-id}|{schedule-id}
func makeCheckerScheduleKey(scheduleId happydns.Identifier) string {
return fmt.Sprintf("checkschedule|%s", scheduleId.String())
}
func makeCheckerScheduleUserIndexKey(userId, scheduleId happydns.Identifier) string {
return fmt.Sprintf("checkschedule.byuser|%s|%s", userId.String(), scheduleId.String())
}
func makeCheckerScheduleTargetIndexKey(targetType happydns.CheckScopeType, targetId, scheduleId happydns.Identifier) string {
return fmt.Sprintf("checkschedule.bytarget|%d|%s|%s", targetType, targetId.String(), scheduleId.String())
}
// ListEnabledCheckerSchedules retrieves all enabled schedules
func (s *KVStorage) ListEnabledCheckerSchedules() ([]*happydns.CheckerSchedule, error) {
iter := s.db.Search("checkschedule|")
defer iter.Release()
var schedules []*happydns.CheckerSchedule
for iter.Next() {
var sched happydns.CheckerSchedule
if err := s.db.DecodeData(iter.Value(), &sched); err != nil {
return nil, err
}
if sched.Enabled {
schedules = append(schedules, &sched)
}
}
return schedules, nil
}
// ListCheckerSchedulesByUser retrieves all schedules for a specific user
func (s *KVStorage) ListCheckerSchedulesByUser(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
prefix := fmt.Sprintf("checkschedule.byuser|%s|", userId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var schedules []*happydns.CheckerSchedule
for iter.Next() {
// Extract schedule ID from index key
key := string(iter.Key())
parts := strings.Split(key, "|")
if len(parts) < 3 {
continue
}
scheduleId, err := happydns.NewIdentifierFromString(parts[2])
if err != nil {
continue
}
// Get the actual schedule
var sched happydns.CheckerSchedule
schedKey := makeCheckerScheduleKey(scheduleId)
if err := s.db.Get(schedKey, &sched); err != nil {
continue
}
schedules = append(schedules, &sched)
}
return schedules, nil
}
// ListCheckerSchedulesByTarget retrieves all schedules for a specific target
func (s *KVStorage) ListCheckerSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier, insideType *happydns.CheckScopeType, insideId *happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
prefix := fmt.Sprintf("checkschedule.bytarget|%d|%s|", targetType, targetId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var schedules []*happydns.CheckerSchedule
for iter.Next() {
// Extract schedule ID from index key
key := string(iter.Key())
parts := strings.Split(key, "|")
if len(parts) < 4 {
continue
}
scheduleId, err := happydns.NewIdentifierFromString(parts[3])
if err != nil {
continue
}
// Get the actual schedule
var sched happydns.CheckerSchedule
schedKey := makeCheckerScheduleKey(scheduleId)
if err := s.db.Get(schedKey, &sched); err != nil {
continue
}
// Post-filter by insideType/insideId
if insideType == nil {
if sched.InsideType != nil {
continue
}
} else {
if sched.InsideType == nil || *sched.InsideType != *insideType {
continue
}
if insideId == nil {
if sched.InsideId != nil {
continue
}
} else {
if sched.InsideId == nil || !sched.InsideId.Equals(*insideId) {
continue
}
}
}
schedules = append(schedules, &sched)
}
return schedules, nil
}
// GetCheckerSchedule retrieves a specific schedule by ID
func (s *KVStorage) GetCheckerSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error) {
key := makeCheckerScheduleKey(scheduleId)
var schedule happydns.CheckerSchedule
err := s.db.Get(key, &schedule)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrCheckScheduleNotFound
}
return &schedule, err
}
// CreateCheckerSchedule creates a new check schedule
func (s *KVStorage) CreateCheckerSchedule(schedule *happydns.CheckerSchedule) error {
key, id, err := s.db.FindIdentifierKey("checkschedule|")
if err != nil {
return err
}
schedule.Id = id
// Store the schedule
if err := s.db.Put(key, schedule); err != nil {
return err
}
// Create indexes
userIndexKey := makeCheckerScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
if err := s.db.Put(userIndexKey, []byte{}); err != nil {
return err
}
targetIndexKey := makeCheckerScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
if err := s.db.Put(targetIndexKey, []byte{}); err != nil {
return err
}
return nil
}
// UpdateCheckerSchedule updates an existing schedule
func (s *KVStorage) UpdateCheckerSchedule(schedule *happydns.CheckerSchedule) error {
key := makeCheckerScheduleKey(schedule.Id)
return s.db.Put(key, schedule)
}
// DeleteCheckerSchedule removes a schedule and its indexes
func (s *KVStorage) DeleteCheckerSchedule(scheduleId happydns.Identifier) error {
// Get the schedule first to know what indexes to delete
schedule, err := s.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
// Delete indexes
userIndexKey := makeCheckerScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
if err := s.db.Delete(userIndexKey); err != nil {
return err
}
targetIndexKey := makeCheckerScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
if err := s.db.Delete(targetIndexKey); err != nil {
return err
}
// Delete the schedule itself
key := makeCheckerScheduleKey(scheduleId)
return s.db.Delete(key)
}
// Check Execution storage keys:
// checkexec|{execution-id}
func makeCheckExecutionKey(executionId happydns.Identifier) string {
return fmt.Sprintf("checkexec|%s", executionId.String())
}
// ListActiveCheckExecutions retrieves all executions that are pending or running
func (s *KVStorage) ListActiveCheckExecutions() ([]*happydns.CheckExecution, error) {
iter := s.db.Search("checkexec|")
defer iter.Release()
var executions []*happydns.CheckExecution
for iter.Next() {
var exec happydns.CheckExecution
if err := s.db.DecodeData(iter.Value(), &exec); err != nil {
return nil, err
}
if exec.Status == happydns.CheckExecutionPending || exec.Status == happydns.CheckExecutionRunning {
executions = append(executions, &exec)
}
}
return executions, nil
}
// GetCheckExecution retrieves a specific execution by ID
func (s *KVStorage) GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error) {
key := makeCheckExecutionKey(executionId)
var execution happydns.CheckExecution
err := s.db.Get(key, &execution)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrCheckExecutionNotFound
}
return &execution, err
}
// CreateCheckExecution creates a new check execution record
func (s *KVStorage) CreateCheckExecution(execution *happydns.CheckExecution) error {
key, id, err := s.db.FindIdentifierKey("checkexec|")
if err != nil {
return err
}
execution.Id = id
return s.db.Put(key, execution)
}
// UpdateCheckExecution updates an existing execution record
func (s *KVStorage) UpdateCheckExecution(execution *happydns.CheckExecution) error {
key := makeCheckExecutionKey(execution.Id)
return s.db.Put(key, execution)
}
// DeleteCheckExecution removes an execution record
func (s *KVStorage) DeleteCheckExecution(executionId happydns.Identifier) error {
key := makeCheckExecutionKey(executionId)
return s.db.Delete(key)
}
// Scheduler state storage key:
// checkscheduler.lastrun
// CheckerSchedulerRun marks that the scheduler has run at current time
func (s *KVStorage) CheckSchedulerRun() error {
now := time.Now()
return s.db.Put("checkscheduler.lastrun", &now)
}
// LastCheckSchedulerRun retrieves the last time the scheduler ran
func (s *KVStorage) LastCheckSchedulerRun() (*time.Time, error) {
var lastRun time.Time
err := s.db.Get("checkscheduler.lastrun", &lastRun)
if errors.Is(err, happydns.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &lastRun, nil
}

View file

@ -0,0 +1,84 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package storage
// StatsProvider implements metrics.StatsProvider using a Storage.
type StatsProvider struct {
store Storage
}
// NewStatsProvider creates a StatsProvider backed by the given Storage.
func NewStatsProvider(s Storage) *StatsProvider {
return &StatsProvider{store: s}
}
func (p *StatsProvider) CountUsers() (int, error) {
it, err := p.store.ListAllUsers()
if err != nil {
return 0, err
}
n := 0
for it.Next() {
n++
}
it.Close()
return n, nil
}
func (p *StatsProvider) CountDomains() (int, error) {
it, err := p.store.ListAllDomains()
if err != nil {
return 0, err
}
n := 0
for it.Next() {
n++
}
it.Close()
return n, nil
}
func (p *StatsProvider) CountZones() (int, error) {
it, err := p.store.ListAllZones()
if err != nil {
return 0, err
}
n := 0
for it.Next() {
n++
}
it.Close()
return n, nil
}
func (p *StatsProvider) CountProviders() (int, error) {
it, err := p.store.ListAllProviders()
if err != nil {
return 0, err
}
n := 0
for it.Next() {
n++
}
it.Close()
return n, nil
}

View file

@ -0,0 +1,450 @@
// 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 check_test
import (
"context"
"testing"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/storage/inmemory"
kv "git.happydns.org/happyDomain/internal/storage/kvtpl"
uc "git.happydns.org/happyDomain/internal/usecase/check"
"git.happydns.org/happyDomain/model"
)
// ---------------------------------------------------------------------------
// mockCheckerForOptions registered once at package init.
// ---------------------------------------------------------------------------
const testCheckerName = "test-mock-checker-options"
type mockCheckerForOptions struct{}
func (m *mockCheckerForOptions) ID() string { return testCheckerName }
func (m *mockCheckerForOptions) Name() string { return testCheckerName }
func (m *mockCheckerForOptions) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{ApplyToDomain: true}
}
func (m *mockCheckerForOptions) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
RunOpts: []happydns.CheckerOptionDocumentation{
{Id: "run-param", Default: "run-default"},
},
DomainOpts: []happydns.CheckerOptionDocumentation{
{Id: "domain-autofill", AutoFill: happydns.AutoFillDomainName},
{Id: "domain-param", Default: "domain-default"},
},
UserOpts: []happydns.CheckerOptionDocumentation{
{Id: "user-param"},
},
ServiceOpts: []happydns.CheckerOptionDocumentation{
{Id: "service-param"},
},
}
}
func (m *mockCheckerForOptions) RunCheck(_ context.Context, opts happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
return nil, nil
}
func init() {
checks.RegisterChecker(testCheckerName, &mockCheckerForOptions{})
}
// ---------------------------------------------------------------------------
// Helper: create a fresh in-memory database for each test.
// ---------------------------------------------------------------------------
func newOptionsTestDB(t *testing.T) storage.Storage {
t.Helper()
mem, err := inmemory.NewInMemoryStorage()
if err != nil {
t.Fatalf("failed to create in-memory storage: %v", err)
}
db, err := kv.NewKVDatabase(mem)
if err != nil {
t.Fatalf("failed to create KV database: %v", err)
}
return db
}
func newTestCheckerUsecase(db storage.Storage) happydns.CheckerUsecase {
return uc.NewCheckerUsecase(&happydns.Options{}, db, db)
}
// ---------------------------------------------------------------------------
// GetStoredCheckerOptionsNoDefault tests
// ---------------------------------------------------------------------------
func Test_GetStoredOptions_EmptyStore(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault(testCheckerName, nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(opts) != 0 {
t.Errorf("expected empty options from empty store, got %v", opts)
}
}
func Test_GetStoredOptions_MergesStored(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
userId, _ := happydns.NewRandomIdentifier()
// Store user-level option.
if err := db.UpdateCheckerConfiguration(testCheckerName, &userId, nil, nil, happydns.CheckerOptions{"user-param": "val"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault(testCheckerName, &userId, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts["user-param"] != "val" {
t.Errorf("expected user-param='val', got %v", opts["user-param"])
}
}
func Test_GetStoredOptions_AutoFillInjects(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Create a domain in the db.
domain := &happydns.Domain{
DomainName: "example.com.",
}
if err := db.CreateDomain(domain); err != nil {
t.Fatalf("failed to create domain: %v", err)
}
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault(testCheckerName, nil, &domain.Id, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts["domain-autofill"] != "example.com." {
t.Errorf("expected domain-autofill='example.com.', got %v", opts["domain-autofill"])
}
}
func Test_GetStoredOptions_UnknownCheckerReturnsStored(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Store options for an unknown checker.
if err := db.UpdateCheckerConfiguration("unknown-checker", nil, nil, nil, happydns.CheckerOptions{"some-param": "some-value"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault("unknown-checker", nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts["some-param"] != "some-value" {
t.Errorf("expected some-param='some-value', got %v", opts["some-param"])
}
}
// ---------------------------------------------------------------------------
// BuildMergedCheckerOptions tests
// ---------------------------------------------------------------------------
func Test_BuildMerged_DefaultsFirst(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged["run-param"] != "run-default" {
t.Errorf("expected run-param='run-default', got %v", merged["run-param"])
}
if merged["domain-param"] != "domain-default" {
t.Errorf("expected domain-param='domain-default', got %v", merged["domain-param"])
}
}
func Test_BuildMerged_StoredOverridesDefault(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
domainId, _ := happydns.NewRandomIdentifier()
// Store domain-level option that overrides the default.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, &domainId, nil, happydns.CheckerOptions{"domain-param": "custom"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, &domainId, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged["domain-param"] != "custom" {
t.Errorf("expected domain-param='custom' (stored overrides default), got %v", merged["domain-param"])
}
}
func Test_BuildMerged_RunOptsOverrideStored(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Store an admin-level value for run-param.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"run-param": "stored-value"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
runOpts := happydns.CheckerOptions{"run-param": "runtime"}
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, nil, nil, runOpts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged["run-param"] != "runtime" {
t.Errorf("expected run-param='runtime' (runOpts wins), got %v", merged["run-param"])
}
}
func Test_BuildMerged_AutoFillWinsOverAll(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Create domain in db.
domain := &happydns.Domain{
DomainName: "example.com.",
}
if err := db.CreateDomain(domain); err != nil {
t.Fatalf("failed to create domain: %v", err)
}
// Both stored and runOpts attempt to set domain-autofill.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"domain-autofill": "manual-value"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
runOpts := happydns.CheckerOptions{"domain-autofill": "runtime-value"}
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, &domain.Id, nil, runOpts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Auto-fill always wins.
if merged["domain-autofill"] != "example.com." {
t.Errorf("expected domain-autofill='example.com.' (auto-fill wins), got %v", merged["domain-autofill"])
}
}
func Test_BuildMerged_NilAutoFillStoreSkips(t *testing.T) {
db := newOptionsTestDB(t)
// Pass nil as the CheckAutoFillStorage interface (not a typed nil).
checkerUC := uc.NewCheckerUsecase(&happydns.Options{}, db, nil)
domainId, _ := happydns.NewRandomIdentifier()
// Should not panic even when autoFillStore is nil.
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, &domainId, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// domain-autofill should NOT be set (no auto-fill storage available).
if _, ok := merged["domain-autofill"]; ok {
t.Errorf("expected domain-autofill to be absent when autoFillStore is nil, got %v", merged["domain-autofill"])
}
}
// ---------------------------------------------------------------------------
// SetCheckerOptions tests
// ---------------------------------------------------------------------------
func Test_SetOptions_ServiceLevel(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
userId, _ := happydns.NewRandomIdentifier()
domainId, _ := happydns.NewRandomIdentifier()
serviceId, _ := happydns.NewRandomIdentifier()
opts := happydns.CheckerOptions{"service-param": "val"}
if err := checkerUC.SetCheckerOptions(testCheckerName, &userId, &domainId, &serviceId, opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the configuration was stored at service scope.
configs, err := db.GetCheckerConfiguration(testCheckerName, &userId, &domainId, &serviceId)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
// Find the service-level entry (UserId, DomainId, ServiceId all set).
found := false
for _, c := range configs {
if c.UserId != nil && c.DomainId != nil && c.ServiceId != nil {
found = true
break
}
}
if !found {
t.Error("expected a service-level configuration entry to be stored")
}
}
func Test_SetOptions_DomainLevel(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
userId, _ := happydns.NewRandomIdentifier()
domainId, _ := happydns.NewRandomIdentifier()
opts := happydns.CheckerOptions{"domain-param": "val"}
if err := checkerUC.SetCheckerOptions(testCheckerName, &userId, &domainId, nil, opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
configs, err := db.GetCheckerConfiguration(testCheckerName, &userId, &domainId, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
found := false
for _, c := range configs {
if c.UserId != nil && c.DomainId != nil && c.ServiceId == nil {
found = true
break
}
}
if !found {
t.Error("expected a domain-level configuration entry to be stored")
}
}
func Test_SetOptions_UserLevel(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
userId, _ := happydns.NewRandomIdentifier()
opts := happydns.CheckerOptions{"user-param": "val"}
if err := checkerUC.SetCheckerOptions(testCheckerName, &userId, nil, nil, opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
configs, err := db.GetCheckerConfiguration(testCheckerName, &userId, nil, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
found := false
for _, c := range configs {
if c.UserId != nil && c.DomainId == nil && c.ServiceId == nil {
found = true
break
}
}
if !found {
t.Error("expected a user-level configuration entry to be stored")
}
}
func Test_SetOptions_AdminLevel(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
opts := happydns.CheckerOptions{"run-param": "admin-val"}
if err := checkerUC.SetCheckerOptions(testCheckerName, nil, nil, nil, opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
configs, err := db.GetCheckerConfiguration(testCheckerName, nil, nil, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
if len(configs) == 0 {
t.Error("expected at least one admin-level configuration entry to be stored")
}
}
func Test_SetOptions_UnknownCheckerErrors(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
opts := happydns.CheckerOptions{"param": "val"}
if err := checkerUC.SetCheckerOptions("unknown-checker-xyz", nil, nil, nil, opts); err == nil {
t.Fatal("expected error for unknown checker")
}
}
// ---------------------------------------------------------------------------
// OverwriteSomeCheckerOptions tests
// ---------------------------------------------------------------------------
func Test_Overwrite_MergesWithExisting(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Pre-seed existing options at admin scope.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"a": "1"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
if err := checkerUC.OverwriteSomeCheckerOptions(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"b": "2"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Retrieve the stored options and verify both keys are present.
configs, err := db.GetCheckerConfiguration(testCheckerName, nil, nil, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
if len(configs) == 0 {
t.Fatal("expected at least one config entry")
}
merged := configs[0].Options
if merged["a"] != "1" {
t.Errorf("expected a='1' to be preserved, got %v", merged["a"])
}
if merged["b"] != "2" {
t.Errorf("expected b='2' to be added, got %v", merged["b"])
}
}
func Test_Overwrite_OverridesExistingKey(t *testing.T) {
db := newOptionsTestDB(t)
checkerUC := newTestCheckerUsecase(db)
// Pre-seed existing options.
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"a": "1"}); err != nil {
t.Fatalf("failed to seed option: %v", err)
}
if err := checkerUC.OverwriteSomeCheckerOptions(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"a": "99"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
configs, err := db.GetCheckerConfiguration(testCheckerName, nil, nil, nil)
if err != nil {
t.Fatalf("failed to retrieve config: %v", err)
}
if len(configs) == 0 {
t.Fatal("expected at least one config entry")
}
if configs[0].Options["a"] != "99" {
t.Errorf("expected a='99' after overwrite, got %v", configs[0].Options["a"])
}
}

View file

@ -0,0 +1,62 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package check
import (
"git.happydns.org/happyDomain/model"
)
type CheckerStorage interface {
// ListAllCheckConfigurations retrieves the list of known Providers.
ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error)
// ListCheckerConfiguration retrieves all providers own by the given User.
ListCheckerConfiguration(string) ([]*happydns.CheckerOptionsPositional, error)
// GetCheckerConfiguration retrieves the full Provider with the given identifier and owner.
GetCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error)
// UpdateCheckerConfiguration updates the fields of the given Provider.
UpdateCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier, happydns.CheckerOptions) error
// DeleteCheckerConfiguration removes the given Provider from the database.
DeleteCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) error
// ClearCheckerConfigurations deletes all Providers present in the database.
ClearCheckerConfigurations() error
}
// CheckAutoFillStorage provides the domain/zone/user lookups needed to
// resolve auto-fill variables for test check options.
type CheckAutoFillStorage interface {
// GetDomain retrieves the Domain with the given identifier.
GetDomain(domainid happydns.Identifier) (*happydns.Domain, error)
// GetUser retrieves the User with the given identifier.
GetUser(userid happydns.Identifier) (*happydns.User, error)
// ListDomains retrieves all Domains associated to the given User.
ListDomains(user *happydns.User) ([]*happydns.Domain, error)
// GetZone retrieves the full Zone (including Services and metadata) for the given identifier.
GetZone(zoneid happydns.Identifier) (*happydns.ZoneMessage, error)
}

View file

@ -0,0 +1,331 @@
// 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 check
import (
"cmp"
"fmt"
"log"
"maps"
"slices"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/model"
)
type checkerUsecase struct {
config *happydns.Options
store CheckerStorage
autoFillStore CheckAutoFillStorage
}
func NewCheckerUsecase(cfg *happydns.Options, store CheckerStorage, autoFillStore CheckAutoFillStorage) happydns.CheckerUsecase {
return &checkerUsecase{
config: cfg,
store: store,
autoFillStore: autoFillStore,
}
}
func (tu *checkerUsecase) GetChecker(cname string) (happydns.Checker, error) {
checker, err := checks.FindChecker(cname)
if err != nil {
return nil, fmt.Errorf("unable to find check named %q: %w", cname, err)
}
return checker, nil
}
// copyNonEmpty copies key/value pairs from src into dst, skipping nil or empty-string values.
func copyNonEmpty(dst, src happydns.CheckerOptions) {
for k, v := range src {
if v == nil {
continue
}
if s, ok := v.(string); ok && s == "" {
continue
}
dst[k] = v
}
}
func compareIdentifiers(a, b *happydns.Identifier) int {
if a == nil && b == nil {
return 0
}
if a == nil {
return -1
}
if b == nil {
return 1
}
if a.Equals(*b) {
return 0
}
return a.Compare(*b)
}
// CompareCheckerOptionsPositional defines the merge precedence ordering for
// checker option configs: admin < user < domain < service.
func CompareCheckerOptionsPositional(a, b *happydns.CheckerOptionsPositional) int {
if a.CheckName != b.CheckName {
return cmp.Compare(a.CheckName, b.CheckName)
}
if res := compareIdentifiers(a.UserId, b.UserId); res != 0 {
return res
}
if res := compareIdentifiers(a.DomainId, b.DomainId); res != 0 {
return res
}
return compareIdentifiers(a.ServiceId, b.ServiceId)
}
func (tu *checkerUsecase) GetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (*happydns.CheckerOptions, error) {
configs, err := tu.store.GetCheckerConfiguration(cname, userid, domainid, serviceid)
if err != nil {
return nil, err
}
slices.SortFunc(configs, CompareCheckerOptionsPositional)
opts := make(happydns.CheckerOptions)
for _, c := range configs {
maps.Copy(opts, c.Options)
}
return &opts, nil
}
func (tu *checkerUsecase) ListCheckers() (*map[string]happydns.Checker, error) {
return checks.GetCheckers(), nil
}
// GetStoredCheckerOptionsNoDefault returns the stored options (user/domain/service scopes)
// with auto-fill variables applied, but without checker-defined defaults or run-time overrides.
func (tu *checkerUsecase) GetStoredCheckerOptionsNoDefault(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (happydns.CheckerOptions, error) {
stored, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
if err != nil {
return nil, err
}
var opts happydns.CheckerOptions
if stored != nil {
opts = *stored
} else {
opts = make(happydns.CheckerOptions)
}
checker, err := tu.GetChecker(cname)
if err != nil {
return opts, nil
}
return tu.applyAutoFill(checker, userid, domainid, serviceid, opts), nil
}
// BuildMergedCheckerOptions merges checker options from all sources in priority order:
// checker defaults < stored (user/domain/service) options < runOpts < auto-fill variables.
func (tu *checkerUsecase) BuildMergedCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckerOptions, error) {
merged := make(happydns.CheckerOptions)
// 1. Fill checker defaults.
checker, err := tu.GetChecker(cname)
if err != nil {
log.Printf("Warning: unable to get checker %q for default options: %v", cname, err)
} else {
opts := checker.Options()
allOpts := []happydns.CheckerOptionDocumentation{}
allOpts = append(allOpts, opts.RunOpts...)
allOpts = append(allOpts, opts.ServiceOpts...)
allOpts = append(allOpts, opts.DomainOpts...)
allOpts = append(allOpts, opts.UserOpts...)
allOpts = append(allOpts, opts.AdminOpts...)
for _, opt := range allOpts {
if opt.Default != nil {
merged[opt.Id] = opt.Default
} else if opt.Placeholder != "" {
merged[opt.Id] = opt.Placeholder
}
}
}
// 2. Override with stored options (user/domain/service scopes).
baseOptions, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
if err != nil {
return merged, fmt.Errorf("could not fetch stored checker options for %s: %w", cname, err)
}
if baseOptions != nil {
copyNonEmpty(merged, *baseOptions)
}
// 3. Override with caller-supplied run options.
copyNonEmpty(merged, runOpts)
// 4. Inject auto-fill variables (always win over any user-supplied value).
if checker != nil {
merged = tu.applyAutoFill(checker, userid, domainid, serviceid, merged)
}
return merged, nil
}
// applyAutoFill resolves auto-fill fields declared by the checker and injects
// the context-resolved values into a copy of opts.
func (tu *checkerUsecase) applyAutoFill(
checker happydns.Checker,
userid *happydns.Identifier,
domainid *happydns.Identifier,
serviceid *happydns.Identifier,
opts happydns.CheckerOptions,
) happydns.CheckerOptions {
// Collect which auto-fill keys are needed.
needed := make(map[string]string) // autoFill constant → field id
options := checker.Options()
for _, groups := range [][]happydns.CheckerOptionDocumentation{
options.RunOpts, options.DomainOpts, options.ServiceOpts,
options.UserOpts, options.AdminOpts,
} {
for _, opt := range groups {
if opt.AutoFill != "" {
needed[opt.AutoFill] = opt.Id
}
}
}
if len(needed) == 0 || tu.autoFillStore == nil {
return opts
}
autoFillCtx := tu.buildAutoFillContext(userid, domainid, serviceid)
result := maps.Clone(opts)
for autoFillKey, fieldId := range needed {
if val, ok := autoFillCtx[autoFillKey]; ok {
result[fieldId] = val
}
}
return result
}
// buildAutoFillContext resolves the available auto-fill values for the given
// user/domain/service identifiers.
func (tu *checkerUsecase) buildAutoFillContext(userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) map[string]any {
ctx := make(map[string]any)
if domainid != nil {
if domain, err := tu.autoFillStore.GetDomain(*domainid); err == nil {
ctx[happydns.AutoFillDomainName] = domain.DomainName
if len(domain.ZoneHistory) > 0 {
// The first element in ZoneHistory is the current (most recent) zone.
zoneMsg, err := tu.autoFillStore.GetZone(domain.ZoneHistory[0])
if err == nil {
ctx[happydns.AutoFillZone] = zoneMsg
}
}
}
} else if serviceid != nil && userid != nil {
// To resolve service context we need to find which domain/zone owns the service.
user, err := tu.autoFillStore.GetUser(*userid)
if err != nil {
return ctx
}
domains, err := tu.autoFillStore.ListDomains(user)
if err != nil {
return ctx
}
for _, domain := range domains {
if len(domain.ZoneHistory) == 0 {
continue
}
// The first element in ZoneHistory is the current (most recent) zone.
zoneMsg, err := tu.autoFillStore.GetZone(domain.ZoneHistory[0])
if err != nil {
continue
}
for subdomain, svcs := range zoneMsg.Services {
for _, svc := range svcs {
if svc.Id.Equals(*serviceid) {
ctx[happydns.AutoFillDomainName] = domain.DomainName
ctx[happydns.AutoFillSubdomain] = string(subdomain)
ctx[happydns.AutoFillZone] = zoneMsg
ctx[happydns.AutoFillService] = svc
ctx[happydns.AutoFillServiceType] = svc.Type
return ctx
}
}
}
}
}
return ctx
}
func (tu *checkerUsecase) SetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
// filter opts that correspond to the level set
checker, err := tu.GetChecker(cname)
if err != nil {
return fmt.Errorf("unable to get checker: %w", err)
}
options := checker.Options()
var relevantOpts []happydns.CheckerOptionDocumentation
if serviceid != nil {
relevantOpts = options.ServiceOpts
} else if domainid != nil {
relevantOpts = options.DomainOpts
} else if userid != nil {
relevantOpts = options.UserOpts
} else {
relevantOpts = options.AdminOpts
}
allowed := make(map[string]struct{}, len(relevantOpts))
for _, opt := range relevantOpts {
allowed[opt.Id] = struct{}{}
}
filteredOpts := make(happydns.CheckerOptions)
for id := range allowed {
if val, exists := opts[id]; exists && val != "" {
filteredOpts[id] = val
}
}
return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, filteredOpts)
}
func (tu *checkerUsecase) OverwriteSomeCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
current, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
if err != nil {
return err
}
maps.Copy(*current, opts)
return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, *current)
}

View file

@ -0,0 +1,85 @@
package check_test
import (
"slices"
"testing"
uc "git.happydns.org/happyDomain/internal/usecase/check"
"git.happydns.org/happyDomain/model"
)
func TestSortByCheckName(t *testing.T) {
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "zeta"},
{CheckName: "alpha"},
{CheckName: "beta"},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
got := []string{slice[0].CheckName, slice[1].CheckName, slice[2].CheckName}
want := []string{"alpha", "beta", "zeta"}
for i := range want {
if got[i] != want[i] {
t.Errorf("expected %v, got %v", want, got)
break
}
}
}
func TestNilBeforeNonNil(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: &uid},
{CheckName: "alpha", UserId: nil},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
if slice[0].UserId != nil {
t.Errorf("expected nil UserId first, got %+v", slice[0].UserId)
}
}
func TestDomainIdOrder(t *testing.T) {
did, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: nil, DomainId: &did},
{CheckName: "alpha", UserId: nil, DomainId: nil},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
if slice[0].DomainId != nil {
t.Errorf("expected nil DomainId first, got %+v", slice[0].DomainId)
}
}
func TestServiceIdOrder(t *testing.T) {
sid, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: nil, DomainId: nil, ServiceId: &sid},
{CheckName: "alpha", UserId: nil, DomainId: nil, ServiceId: nil},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
if slice[0].ServiceId != nil {
t.Errorf("expected nil ServiceId first, got %+v", slice[0].ServiceId)
}
}
func TestStableGrouping(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: &uid},
{CheckName: "alpha", UserId: &uid},
}
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
if slice[0].CheckName != slice[1].CheckName {
t.Errorf("expected grouping, got %+v vs %+v", slice[0], slice[1])
}
}

View file

@ -0,0 +1,434 @@
// 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 checkresult
import (
"fmt"
"slices"
"time"
"git.happydns.org/happyDomain/model"
)
// CheckResultUsecase implements business logic for check results
type CheckResultUsecase struct {
storage CheckResultStorage
options *happydns.Options
checkerUC happydns.CheckerUsecase
checkerScheduleUC happydns.CheckerScheduleUsecase
}
// NewCheckResultUsecase creates a new check result usecase
func NewCheckResultUsecase(storage CheckResultStorage, options *happydns.Options, checkerUC happydns.CheckerUsecase, checkerScheduleUC happydns.CheckerScheduleUsecase) *CheckResultUsecase {
return &CheckResultUsecase{
storage: storage,
options: options,
checkerUC: checkerUC,
checkerScheduleUC: checkerScheduleUC,
}
}
// ListCheckerStatuses returns all checkers applicable to scope with their schedule
// and most recent result for the given target.
func (u *CheckResultUsecase) ListCheckerStatuses(scope happydns.CheckScopeType, targetID happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, user *happydns.User, domain *happydns.Domain, service *happydns.Service) ([]happydns.CheckerStatus, error) {
plugins, err := u.checkerUC.ListCheckers()
if err != nil {
return nil, err
}
// Get schedules for this target
schedules, err := u.checkerScheduleUC.ListSchedulesByTarget(scope, targetID, insideScope, insideID)
if err != nil {
return nil, err
}
// Build schedule map
scheduleMap := make(map[string]*happydns.CheckerSchedule, len(schedules))
for _, sched := range schedules {
if sched.OwnerId.Equals(user.Id) {
scheduleMap[sched.CheckerName] = sched
}
}
// Get service type for LimitToServices filtering
var serviceType string
if scope == happydns.CheckScopeService && service != nil {
serviceType = service.Type
}
// Build response with last results
var statuses []happydns.CheckerStatus
for checkername, check := range *plugins {
// Filter plugins by scope
if scope == happydns.CheckScopeDomain && !check.Availability().ApplyToDomain {
continue
}
if scope == happydns.CheckScopeService && !check.Availability().ApplyToService {
continue
}
// Filter plugins by service type if LimitToServices is set
if scope == happydns.CheckScopeService && serviceType != "" {
limitTo := check.Availability().LimitToServices
if len(limitTo) > 0 && !slices.Contains(limitTo, serviceType) {
continue
}
}
info := happydns.CheckerStatus{
CheckerName: checkername,
NotDiscovered: true,
}
// Check if there's a schedule
if sched, ok := scheduleMap[checkername]; ok {
info.Enabled = sched.Enabled
info.Schedule = sched
info.NotDiscovered = false
// Get last result
results, err := u.ListCheckResultsByTarget(checkername, scope, targetID, insideScope, insideID, user.Id, 1)
if err == nil && len(results) > 0 {
info.LastResult = results[0]
}
}
statuses = append(statuses, info)
}
return statuses, nil
}
// ListCheckResultsByTarget retrieves check results for a specific target owned by userId
func (u *CheckResultUsecase) ListCheckResultsByTarget(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
// Apply default limit if not specified
if limit <= 0 {
limit = 5 // Default to 5 most recent results
}
results, err := u.storage.ListCheckResults(pluginName, targetType, targetId, limit)
if err != nil {
return nil, err
}
results = filterResultsByInside(results, insideScope, insideID)
// Filter by owner
owned := results[:0]
for _, r := range results {
if r.OwnerId.Equals(userId) {
owned = append(owned, r)
}
}
return owned, nil
}
// ListAllCheckResultsByTarget retrieves all check results for a target across all plugins
func (u *CheckResultUsecase) ListAllCheckResultsByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
// Get all results for the user and filter by target
allResults, err := u.storage.ListCheckResultsByUser(userId, 0)
if err != nil {
return nil, err
}
// Filter by target
var results []*happydns.CheckResult
for _, r := range allResults {
if r.CheckType == targetType && r.TargetId.Equals(targetId) {
results = append(results, r)
}
}
results = filterResultsByInside(results, insideScope, insideID)
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// filterResultsByInside filters check results by insideScope/insideID.
// If insideScope is nil, only results with nil InsideType are returned.
func filterResultsByInside(results []*happydns.CheckResult, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier) []*happydns.CheckResult {
filtered := results[:0]
for _, r := range results {
if insideScope == nil {
if r.InsideType == nil {
filtered = append(filtered, r)
}
} else {
if r.InsideType != nil && *r.InsideType == *insideScope && insideID != nil && r.InsideId != nil && r.InsideId.Equals(*insideID) {
filtered = append(filtered, r)
}
}
}
return filtered
}
// GetCheckResult retrieves a specific check result owned by userId
func (u *CheckResultUsecase) GetCheckResult(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, userId happydns.Identifier) (*happydns.CheckResult, error) {
result, err := u.storage.GetCheckResult(pluginName, targetType, targetId, resultId)
if err != nil {
return nil, err
}
// Verify the result belongs to the expected inside scope
if insideScope == nil {
if result.InsideType != nil {
return nil, happydns.ErrNotFound
}
} else {
if result.InsideType == nil || *result.InsideType != *insideScope || insideID == nil || result.InsideId == nil || !result.InsideId.Equals(*insideID) {
return nil, happydns.ErrNotFound
}
}
// Verify ownership
if !result.OwnerId.Equals(userId) {
return nil, happydns.ErrNotFound
}
return result, nil
}
// CreateCheckResult stores a new check result and enforces retention policy
func (u *CheckResultUsecase) CreateCheckResult(result *happydns.CheckResult) error {
// Store the result
if err := u.storage.CreateCheckResult(result); err != nil {
return err
}
// Enforce retention policy
maxResults := u.options.MaxResultsPerCheck
if maxResults <= 0 {
maxResults = 100 // Default
}
return u.storage.DeleteOldCheckResults(result.CheckerName, result.CheckType, result.TargetId, maxResults)
}
// DeleteCheckResult removes a specific check result owned by userId
func (u *CheckResultUsecase) DeleteCheckResult(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, userId happydns.Identifier) error {
result, err := u.storage.GetCheckResult(pluginName, targetType, targetId, resultId)
if err != nil {
return err
}
if !result.OwnerId.Equals(userId) {
return happydns.ErrNotFound
}
return u.storage.DeleteCheckResult(pluginName, targetType, targetId, resultId)
}
// DeleteAllCheckResults removes all results for a specific plugin+target combination owned by userId
func (u *CheckResultUsecase) DeleteAllCheckResults(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, userId happydns.Identifier) error {
// Get all results first
results, err := u.storage.ListCheckResults(pluginName, targetType, targetId, 0)
if err != nil {
return err
}
results = filterResultsByInside(results, insideScope, insideID)
// Delete only results owned by the requesting user
for _, r := range results {
if !r.OwnerId.Equals(userId) {
continue
}
if err := u.storage.DeleteCheckResult(pluginName, targetType, targetId, r.Id); err != nil {
return err
}
}
return nil
}
// CleanupOldResults removes check results older than retention period
func (u *CheckResultUsecase) CleanupOldResults() error {
retentionDays := u.options.ResultRetentionDays
if retentionDays <= 0 {
retentionDays = 90 // Default
}
cutoffTime := time.Now().AddDate(0, 0, -retentionDays)
// Get all results for all users (inefficient but necessary without a time-based index)
// In a production system, you might want to add a time-based index for this
// For now, we'll iterate through results and delete old ones
// This is a placeholder - the actual implementation would need to be optimized
// based on specific storage patterns
_ = cutoffTime
return nil
}
// GetCheckExecution retrieves the status of a check execution
func (u *CheckResultUsecase) GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error) {
return u.storage.GetCheckExecution(executionId)
}
// CreateCheckExecution creates a new check execution record
func (u *CheckResultUsecase) CreateCheckExecution(execution *happydns.CheckExecution) error {
if execution.StartedAt.IsZero() {
execution.StartedAt = time.Now()
}
return u.storage.CreateCheckExecution(execution)
}
// UpdateCheckExecution updates an existing check execution
func (u *CheckResultUsecase) UpdateCheckExecution(execution *happydns.CheckExecution) error {
return u.storage.UpdateCheckExecution(execution)
}
// CompleteCheckExecution marks an execution as completed with a result
func (u *CheckResultUsecase) CompleteCheckExecution(executionId happydns.Identifier, resultId happydns.Identifier) error {
execution, err := u.storage.GetCheckExecution(executionId)
if err != nil {
return err
}
now := time.Now()
execution.Status = happydns.CheckExecutionCompleted
execution.CompletedAt = &now
execution.ResultId = &resultId
return u.storage.UpdateCheckExecution(execution)
}
// FailCheckExecution marks an execution as failed
func (u *CheckResultUsecase) FailCheckExecution(executionId happydns.Identifier, errorMsg string) error {
execution, err := u.storage.GetCheckExecution(executionId)
if err != nil {
return err
}
now := time.Now()
execution.Status = happydns.CheckExecutionFailed
execution.CompletedAt = &now
// Store error in a result
result := &happydns.CheckResult{
CheckerName: execution.CheckerName,
CheckType: execution.TargetType,
TargetId: execution.TargetId,
InsideType: execution.InsideType,
InsideId: execution.InsideId,
OwnerId: execution.OwnerId,
ExecutedAt: time.Now(),
ScheduledCheck: execution.ScheduleId != nil,
Options: execution.Options,
Status: happydns.CheckResultStatusCritical,
StatusLine: "Execution failed",
Error: errorMsg,
Duration: now.Sub(execution.StartedAt),
}
if err := u.CreateCheckResult(result); err != nil {
return fmt.Errorf("failed to create error result: %w", err)
}
execution.ResultId = &result.Id
return u.storage.UpdateCheckExecution(execution)
}
// GetWorstCheckStatus returns the worst (most critical) status from the most
// recent result of each checker for a given target. Returns nil if no results exist.
func (u *CheckResultUsecase) GetWorstCheckStatus(targetType happydns.CheckScopeType, targetId happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, userId happydns.Identifier) (*happydns.CheckResultStatus, error) {
results, err := u.ListAllCheckResultsByTarget(targetType, targetId, insideScope, insideID, userId, 0)
if err != nil || len(results) == 0 {
return nil, err
}
// Keep only the latest result per checker
latest := map[string]*happydns.CheckResult{}
for _, r := range results {
if prev, ok := latest[r.CheckerName]; !ok || r.ExecutedAt.After(prev.ExecutedAt) {
latest[r.CheckerName] = r
}
}
// Find minimum (worst) status among latest results, ignoring Unknown (which
// means the check couldn't run, not that the domain is in a bad state).
var worst *happydns.CheckResultStatus
for _, r := range latest {
s := r.Status
if s == happydns.CheckResultStatusUnknown {
continue
}
if worst == nil || s < *worst {
worst = &s
}
}
return worst, nil
}
// GetWorstCheckStatusByUser fetches all results for the user once and returns
// a map from target ID string to worst (most critical) status per target.
func (u *CheckResultUsecase) GetWorstCheckStatusByUser(targetType happydns.CheckScopeType, userId happydns.Identifier) (map[string]*happydns.CheckResultStatus, error) {
allResults, err := u.storage.ListCheckResultsByUser(userId, 0)
if err != nil {
return nil, err
}
type key struct {
target string
checker string
}
latest := map[key]*happydns.CheckResult{}
for _, r := range allResults {
if r.CheckType != targetType || r.CheckerName == "" {
continue
}
k := key{target: r.TargetId.String(), checker: r.CheckerName}
if prev, ok := latest[k]; !ok || r.ExecutedAt.After(prev.ExecutedAt) {
latest[k] = r
}
}
worst := map[string]*happydns.CheckResultStatus{}
for k, r := range latest {
s := r.Status
if prev, ok := worst[k.target]; !ok || s < *prev {
worst[k.target] = &s
}
}
return worst, nil
}
// DeleteCompletedExecutions removes execution records that are completed
func (u *CheckResultUsecase) DeleteCompletedExecutions(olderThan time.Duration) error {
cutoffTime := time.Now().Add(-olderThan)
// Get active executions (this won't include completed ones)
// We need a different query to get completed executions older than cutoff
// For now, this is a placeholder
_ = cutoffTime
return nil
}

View file

@ -0,0 +1,490 @@
// 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 checkresult_test
import (
"testing"
"time"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/storage/inmemory"
kv "git.happydns.org/happyDomain/internal/storage/kvtpl"
checkresultUC "git.happydns.org/happyDomain/internal/usecase/checkresult"
"git.happydns.org/happyDomain/model"
)
// newTestDB creates a fresh in-memory storage for each test.
func newTestDB(t *testing.T) storage.Storage {
t.Helper()
mem, err := inmemory.NewInMemoryStorage()
if err != nil {
t.Fatalf("failed to create in-memory storage: %v", err)
}
db, err := kv.NewKVDatabase(mem)
if err != nil {
t.Fatalf("failed to create KV database: %v", err)
}
return db
}
func newTestCheckResultUsecase(db storage.Storage, maxResults int) *checkresultUC.CheckResultUsecase {
return checkresultUC.NewCheckResultUsecase(db, &happydns.Options{MaxResultsPerCheck: maxResults}, nil, nil)
}
// ---------------------------------------------------------------------------
// CreateCheckResult tests
// ---------------------------------------------------------------------------
func Test_CreateCheckResult_DefaultMaxResults(t *testing.T) {
db := newTestDB(t)
// MaxResultsPerCheck=0 → default 100; verify results are stored correctly.
uc := newTestCheckResultUsecase(db, 0)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
result := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId,
OwnerId: ownerId,
ExecutedAt: time.Now(),
}
if err := uc.CreateCheckResult(result); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Id.IsEmpty() {
t.Error("expected result to have a non-empty ID after create")
}
stored, err := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId, 0)
if err != nil {
t.Fatalf("unexpected error listing results: %v", err)
}
if len(stored) != 1 {
t.Errorf("expected 1 stored result, got %d", len(stored))
}
}
func Test_CreateCheckResult_CustomMaxResults(t *testing.T) {
db := newTestDB(t)
// MaxResultsPerCheck=3: pre-seed 5 results, create 1 more → expect 3 to remain.
uc := newTestCheckResultUsecase(db, 3)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
for i := range 5 {
r := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId,
OwnerId: ownerId,
ExecutedAt: time.Now().Add(-time.Duration(5-i) * time.Minute),
}
if err := db.CreateCheckResult(r); err != nil {
t.Fatalf("failed to seed result: %v", err)
}
}
// Creating one more via the usecase triggers retention → prune to 3.
if err := uc.CreateCheckResult(&happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId,
OwnerId: ownerId,
ExecutedAt: time.Now(),
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
remaining, err := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId, 0)
if err != nil {
t.Fatalf("unexpected error listing results: %v", err)
}
if len(remaining) != 3 {
t.Errorf("expected 3 results after retention (MaxResultsPerCheck=3), got %d", len(remaining))
}
}
func Test_CreateCheckResult_StoresResult(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
result := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId,
OwnerId: ownerId,
ExecutedAt: time.Now(),
}
if err := uc.CreateCheckResult(result); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Id.IsEmpty() {
t.Error("expected result to have a non-empty ID after create")
}
// Verify retrievable from storage.
fetched, err := db.GetCheckResult("checker", happydns.CheckScopeDomain, targetId, result.Id)
if err != nil {
t.Fatalf("expected result to be retrievable: %v", err)
}
if fetched.Id.IsEmpty() {
t.Error("retrieved result has empty ID")
}
}
// ---------------------------------------------------------------------------
// ListCheckResultsByTarget tests
// ---------------------------------------------------------------------------
func Test_ListCheckResultsByTarget_DefaultLimit(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 100)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
for i := range 8 {
r := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId,
OwnerId: ownerId,
ExecutedAt: time.Now().Add(time.Duration(i) * time.Second),
}
if err := db.CreateCheckResult(r); err != nil {
t.Fatalf("failed to seed result: %v", err)
}
}
results, err := uc.ListCheckResultsByTarget("checker", happydns.CheckScopeDomain, targetId, nil, nil, ownerId, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) > 5 {
t.Errorf("expected at most 5 results (default limit), got %d", len(results))
}
}
func Test_ListCheckResultsByTarget_CustomLimit(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 100)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
for i := range 4 {
r := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId,
OwnerId: ownerId,
ExecutedAt: time.Now().Add(time.Duration(i) * time.Second),
}
if err := db.CreateCheckResult(r); err != nil {
t.Fatalf("failed to seed result: %v", err)
}
}
results, err := uc.ListCheckResultsByTarget("checker", happydns.CheckScopeDomain, targetId, nil, nil, ownerId, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 2 {
t.Errorf("expected 2 results with limit=2, got %d", len(results))
}
}
// ---------------------------------------------------------------------------
// DeleteAllCheckResults tests
// ---------------------------------------------------------------------------
func Test_DeleteAllCheckResults_Empty(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
if err := uc.DeleteAllCheckResults("checker", happydns.CheckScopeDomain, targetId, nil, nil, targetId); err != nil {
t.Fatalf("unexpected error on empty store: %v", err)
}
}
func Test_DeleteAllCheckResults_OnlyTargetDeleted(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 100)
targetId1, _ := happydns.NewRandomIdentifier()
targetId2, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
for range 3 {
r := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId1,
OwnerId: ownerId,
ExecutedAt: time.Now(),
}
if err := db.CreateCheckResult(r); err != nil {
t.Fatalf("failed to seed targetId1 result: %v", err)
}
}
for range 2 {
r := &happydns.CheckResult{
CheckerName: "checker",
CheckType: happydns.CheckScopeDomain,
TargetId: targetId2,
OwnerId: ownerId,
ExecutedAt: time.Now(),
}
if err := db.CreateCheckResult(r); err != nil {
t.Fatalf("failed to seed targetId2 result: %v", err)
}
}
if err := uc.DeleteAllCheckResults("checker", happydns.CheckScopeDomain, targetId1, nil, nil, ownerId); err != nil {
t.Fatalf("unexpected error: %v", err)
}
remaining1, _ := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId1, 0)
if len(remaining1) != 0 {
t.Errorf("expected 0 results for targetId1 after delete, got %d", len(remaining1))
}
remaining2, _ := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId2, 0)
if len(remaining2) != 2 {
t.Errorf("expected 2 results for targetId2 to remain, got %d", len(remaining2))
}
}
// ---------------------------------------------------------------------------
// CreateCheckExecution tests
// ---------------------------------------------------------------------------
func Test_CreateCheckExecution_SetsStartedAt(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
execution := &happydns.CheckExecution{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
}
before := time.Now()
if err := uc.CreateCheckExecution(execution); err != nil {
t.Fatalf("unexpected error: %v", err)
}
after := time.Now()
if execution.StartedAt.IsZero() {
t.Error("expected StartedAt to be set")
}
if execution.StartedAt.Before(before) || execution.StartedAt.After(after) {
t.Errorf("StartedAt %v not in expected range [%v, %v]", execution.StartedAt, before, after)
}
}
func Test_CreateCheckExecution_PreservesStartedAt(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
specificTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
execution := &happydns.CheckExecution{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
StartedAt: specificTime,
}
if err := uc.CreateCheckExecution(execution); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !execution.StartedAt.Equal(specificTime) {
t.Errorf("expected StartedAt to be preserved as %v, got %v", specificTime, execution.StartedAt)
}
}
// ---------------------------------------------------------------------------
// CompleteCheckExecution tests
// ---------------------------------------------------------------------------
func Test_CompleteCheckExecution_SetsStatus(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
resultId, _ := happydns.NewRandomIdentifier()
execution := &happydns.CheckExecution{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Status: happydns.CheckExecutionRunning,
StartedAt: time.Now().Add(-time.Second),
}
if err := uc.CreateCheckExecution(execution); err != nil {
t.Fatalf("failed to create execution: %v", err)
}
before := time.Now()
if err := uc.CompleteCheckExecution(execution.Id, resultId); err != nil {
t.Fatalf("unexpected error: %v", err)
}
after := time.Now()
updated, err := db.GetCheckExecution(execution.Id)
if err != nil {
t.Fatalf("failed to retrieve execution: %v", err)
}
if updated.Status != happydns.CheckExecutionCompleted {
t.Errorf("expected status Completed, got %v", updated.Status)
}
if updated.CompletedAt == nil {
t.Error("expected CompletedAt to be set")
} else if updated.CompletedAt.Before(before) || updated.CompletedAt.After(after) {
t.Errorf("CompletedAt %v not in expected range [%v, %v]", *updated.CompletedAt, before, after)
}
if updated.ResultId == nil || !updated.ResultId.Equals(resultId) {
t.Error("expected ResultId to be set to the provided resultId")
}
}
func Test_CompleteCheckExecution_NotFound(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
nonExistentId, _ := happydns.NewRandomIdentifier()
resultId, _ := happydns.NewRandomIdentifier()
if err := uc.CompleteCheckExecution(nonExistentId, resultId); err == nil {
t.Fatal("expected error for non-existent execution ID")
}
}
// ---------------------------------------------------------------------------
// FailCheckExecution tests
// ---------------------------------------------------------------------------
func Test_FailCheckExecution_CreatesErrorResult(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
execution := &happydns.CheckExecution{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Status: happydns.CheckExecutionRunning,
StartedAt: time.Now().Add(-time.Second),
}
if err := uc.CreateCheckExecution(execution); err != nil {
t.Fatalf("failed to create execution: %v", err)
}
if err := uc.FailCheckExecution(execution.Id, "something went wrong"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the check result was created.
results, err := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId, 0)
if err != nil {
t.Fatalf("failed to list results: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 check result to be created on failure, got %d", len(results))
}
result := results[0]
if result.Status != happydns.CheckResultStatusCritical {
t.Errorf("expected Status=KO, got %v", result.Status)
}
if result.Error != "something went wrong" {
t.Errorf("expected Error='something went wrong', got %q", result.Error)
}
// Verify the execution status was updated.
updated, err := db.GetCheckExecution(execution.Id)
if err != nil {
t.Fatalf("failed to retrieve execution: %v", err)
}
if updated.Status != happydns.CheckExecutionFailed {
t.Errorf("expected execution status Failed, got %v", updated.Status)
}
}
func Test_FailCheckExecution_ScheduledCheckFlag(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckResultUsecase(db, 10)
targetId, _ := happydns.NewRandomIdentifier()
ownerId, _ := happydns.NewRandomIdentifier()
scheduleId, _ := happydns.NewRandomIdentifier()
execution := &happydns.CheckExecution{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
ScheduleId: &scheduleId,
Status: happydns.CheckExecutionRunning,
StartedAt: time.Now().Add(-time.Second),
}
if err := uc.CreateCheckExecution(execution); err != nil {
t.Fatalf("failed to create execution: %v", err)
}
if err := uc.FailCheckExecution(execution.Id, "scheduled check failed"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
results, err := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId, 0)
if err != nil {
t.Fatalf("failed to list results: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if !results[0].ScheduledCheck {
t.Error("expected ScheduledCheck=true when execution has a non-nil ScheduleId")
}
}

View file

@ -0,0 +1,465 @@
// 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 checkresult
import (
"errors"
"fmt"
"math/rand"
"sort"
"time"
"git.happydns.org/happyDomain/model"
)
const (
// Default check intervals
DefaultUserCheckInterval = 4 * time.Hour // 4 hours for user checks
DefaultDomainCheckInterval = 24 * time.Hour // 24 hours for domain checks
DefaultServiceCheckInterval = 1 * time.Hour // 1 hour for service checks
MinimumCheckInterval = 5 * time.Minute // Minimum interval allowed
)
// CheckScheduleUsecase implements business logic for check schedules
type CheckScheduleUsecase struct {
storage CheckResultStorage
options *happydns.Options
domainLister DomainLister
checkerUsecase happydns.CheckerUsecase
}
// NewCheckScheduleUsecase creates a new check schedule usecase
func NewCheckScheduleUsecase(storage CheckResultStorage, options *happydns.Options, domainLister DomainLister, checkerUsecase happydns.CheckerUsecase) *CheckScheduleUsecase {
return &CheckScheduleUsecase{
storage: storage,
options: options,
domainLister: domainLister,
checkerUsecase: checkerUsecase,
}
}
// ListUserSchedules retrieves all schedules for a specific user
func (u *CheckScheduleUsecase) ListUserSchedules(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
return u.storage.ListCheckerSchedulesByUser(userId)
}
// ListSchedulesByTarget retrieves all schedules for a specific target
func (u *CheckScheduleUsecase) ListSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier, insideType *happydns.CheckScopeType, insideId *happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
return u.storage.ListCheckerSchedulesByTarget(targetType, targetId, insideType, insideId)
}
// GetSchedule retrieves a specific schedule by ID
func (u *CheckScheduleUsecase) GetSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error) {
return u.storage.GetCheckerSchedule(scheduleId)
}
// CreateSchedule creates a new check schedule with validation
func (u *CheckScheduleUsecase) CreateSchedule(schedule *happydns.CheckerSchedule) error {
// Set default interval if not specified
if schedule.Interval == 0 {
schedule.Interval = u.getDefaultInterval(schedule.CheckerName, schedule.TargetType)
}
// Validate interval against per-checker and global bounds
if err := u.validateInterval(schedule.CheckerName, &schedule.Interval); err != nil {
return err
}
// Validate interval
if schedule.Interval < MinimumCheckInterval {
return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval)
}
// Calculate next run time: pick a random offset within the interval
// to spread load evenly across all schedules
// TODO: Use a smarter load balance function in the future
if schedule.NextRun.IsZero() {
offset := time.Duration(rand.Int63n(int64(schedule.Interval)))
schedule.NextRun = time.Now().Add(offset)
}
return u.storage.CreateCheckerSchedule(schedule)
}
// UpdateSchedule updates an existing schedule
func (u *CheckScheduleUsecase) UpdateSchedule(schedule *happydns.CheckerSchedule) error {
// Validate interval against per-checker and global bounds
if err := u.validateInterval(schedule.CheckerName, &schedule.Interval); err != nil {
return err
}
// Get existing schedule to preserve certain fields
existing, err := u.storage.GetCheckerSchedule(schedule.Id)
if err != nil {
return err
}
// Preserve LastRun if not explicitly changed
if schedule.LastRun == nil {
schedule.LastRun = existing.LastRun
}
// Recalculate next run time if interval changed
if schedule.Interval != existing.Interval {
if schedule.LastRun != nil {
schedule.NextRun = schedule.LastRun.Add(schedule.Interval)
} else {
schedule.NextRun = time.Now().Add(schedule.Interval)
}
}
return u.storage.UpdateCheckerSchedule(schedule)
}
// DeleteSchedule removes a schedule
func (u *CheckScheduleUsecase) DeleteSchedule(scheduleId happydns.Identifier) error {
return u.storage.DeleteCheckerSchedule(scheduleId)
}
// EnableSchedule enables a schedule
func (u *CheckScheduleUsecase) EnableSchedule(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
schedule.Enabled = true
// Reset next run time if it's in the past
if schedule.NextRun.Before(time.Now()) {
schedule.NextRun = time.Now().Add(schedule.Interval)
}
return u.storage.UpdateCheckerSchedule(schedule)
}
// DisableSchedule disables a schedule
func (u *CheckScheduleUsecase) DisableSchedule(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
schedule.Enabled = false
return u.storage.UpdateCheckerSchedule(schedule)
}
// UpdateScheduleAfterRun updates a schedule after it has been executed
func (u *CheckScheduleUsecase) UpdateScheduleAfterRun(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
now := time.Now()
schedule.LastRun = &now
schedule.NextRun = now.Add(schedule.Interval)
return u.storage.UpdateCheckerSchedule(schedule)
}
// ListDueSchedules retrieves all enabled schedules that are due to run
func (u *CheckScheduleUsecase) ListDueSchedules() ([]*happydns.CheckerSchedule, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return nil, err
}
now := time.Now()
var dueSchedules []*happydns.CheckerSchedule
for _, schedule := range schedules {
if schedule.NextRun.Before(now) {
dueSchedules = append(dueSchedules, schedule)
}
}
return dueSchedules, nil
}
// ListUpcomingSchedules retrieves the next `limit` enabled schedules sorted by NextRun ascending
func (u *CheckScheduleUsecase) ListUpcomingSchedules(limit int) ([]*happydns.CheckerSchedule, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return nil, err
}
sort.Slice(schedules, func(i, j int) bool {
return schedules[i].NextRun.Before(schedules[j].NextRun)
})
if limit > 0 && len(schedules) > limit {
schedules = schedules[:limit]
}
return schedules, nil
}
// getDefaultInterval returns the default check interval. If the checker
// implements CheckerIntervalProvider, its default is used; otherwise a
// scope-based fallback is returned.
func (u *CheckScheduleUsecase) getDefaultInterval(checkerName string, targetType happydns.CheckScopeType) time.Duration {
if spec := u.getCheckerIntervalSpec(checkerName); spec != nil {
return spec.Default
}
switch targetType {
case happydns.CheckScopeUser:
return DefaultUserCheckInterval
case happydns.CheckScopeDomain:
return DefaultDomainCheckInterval
case happydns.CheckScopeService:
return DefaultServiceCheckInterval
default:
return DefaultDomainCheckInterval
}
}
// validateInterval clamps the interval to per-checker bounds (if available)
// and enforces the global MinimumCheckInterval as an absolute floor.
func (u *CheckScheduleUsecase) validateInterval(checkerName string, interval *time.Duration) error {
if spec := u.getCheckerIntervalSpec(checkerName); spec != nil {
if *interval < spec.Min {
*interval = spec.Min
}
if *interval > spec.Max {
*interval = spec.Max
}
}
if *interval < MinimumCheckInterval {
return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval)
}
return nil
}
// getCheckerIntervalSpec returns the CheckIntervalSpec for a checker if it
// implements CheckerIntervalProvider, or nil otherwise.
func (u *CheckScheduleUsecase) getCheckerIntervalSpec(checkerName string) *happydns.CheckIntervalSpec {
if u.checkerUsecase == nil {
return nil
}
checker, err := u.checkerUsecase.GetChecker(checkerName)
if err != nil {
return nil
}
ip, ok := checker.(happydns.CheckerIntervalProvider)
if !ok {
return nil
}
spec := ip.CheckInterval()
return &spec
}
// ValidateScheduleOwnership checks if a user owns a schedule
func (u *CheckScheduleUsecase) ValidateScheduleOwnership(scheduleId happydns.Identifier, userId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
if !schedule.OwnerId.Equals(userId) {
return fmt.Errorf("user does not own this schedule")
}
return nil
}
// CreateDefaultSchedulesForTarget creates default schedules for a new target
func (u *CheckScheduleUsecase) CreateDefaultSchedulesForTarget(
checkerName string,
targetType happydns.CheckScopeType,
targetId happydns.Identifier,
insideType *happydns.CheckScopeType,
insideId *happydns.Identifier,
ownerId happydns.Identifier,
enabled bool,
) error {
schedule := &happydns.CheckerSchedule{
CheckerName: checkerName,
OwnerId: ownerId,
InsideType: insideType,
InsideId: insideId,
TargetType: targetType,
TargetId: targetId,
Interval: u.getDefaultInterval(checkerName, targetType),
Enabled: enabled,
NextRun: time.Now().Add(u.getDefaultInterval(checkerName, targetType)),
Options: make(happydns.CheckerOptions),
}
return u.CreateSchedule(schedule)
}
// rescheduleChecks reschedules each given schedule to a random time in [now, now+maxOffsetFn(schedule)].
func (u *CheckScheduleUsecase) rescheduleChecks(schedules []*happydns.CheckerSchedule, maxOffsetFn func(*happydns.CheckerSchedule) time.Duration) (int, error) {
count := 0
now := time.Now()
for _, schedule := range schedules {
maxOffset := maxOffsetFn(schedule)
if maxOffset <= 0 {
maxOffset = time.Second
}
schedule.NextRun = now.Add(time.Duration(rand.Int63n(int64(maxOffset))))
if err := u.storage.UpdateCheckerSchedule(schedule); err != nil {
return count, err
}
count++
}
return count, nil
}
// RescheduleUpcomingChecks randomizes the next run time of all enabled schedules
// within their respective intervals to spread load evenly. Useful after a restart.
func (u *CheckScheduleUsecase) RescheduleUpcomingChecks() (int, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return 0, err
}
return u.rescheduleChecks(schedules, func(s *happydns.CheckerSchedule) time.Duration {
return s.Interval
})
}
// RescheduleOverdueChecks reschedules checks whose NextRun is in the past,
// spreading them over a short window to avoid scheduler famine (e.g. after
// a long machine suspend or server downtime).
// If there are fewer than 10 overdue checks, they are left as-is so that the
// caller's immediate checkSchedules pass enqueues them directly.
func (u *CheckScheduleUsecase) RescheduleOverdueChecks() (int, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return 0, err
}
now := time.Now()
var overdue []*happydns.CheckerSchedule
for _, s := range schedules {
if s.NextRun.Before(now) {
overdue = append(overdue, s)
}
}
if len(overdue) == 0 {
return 0, nil
}
// Small backlog: let the caller enqueue them directly on the next
// checkSchedules pass rather than deferring them into the future.
if len(overdue) < 10 {
return 0, nil
}
// Spread overdue checks over a small window proportional to their count,
// capped at MinimumCheckInterval, to prevent all of them from running at once.
spreadWindow := time.Duration(len(overdue)) * 5 * time.Second
if spreadWindow > MinimumCheckInterval {
spreadWindow = MinimumCheckInterval
}
return u.rescheduleChecks(overdue, func(s *happydns.CheckerSchedule) time.Duration {
return spreadWindow
})
}
// DeleteSchedulesForTarget removes all schedules for a target
func (u *CheckScheduleUsecase) DeleteSchedulesForTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier, insideType *happydns.CheckScopeType, insideId *happydns.Identifier, ownerId happydns.Identifier) error {
schedules, err := u.storage.ListCheckerSchedulesByTarget(targetType, targetId, insideType, insideId)
if err != nil {
return err
}
for _, schedule := range schedules {
if !schedule.OwnerId.Equals(ownerId) {
continue
}
if err := u.storage.DeleteCheckerSchedule(schedule.Id); err != nil {
return err
}
}
return nil
}
// DiscoverAndEnsureSchedules creates default enabled schedules for all (plugin, domain)
// pairs that don't yet have an explicit schedule record. This implements the opt-out
// model: checks run automatically unless a schedule with Enabled=false has been saved.
// Non-fatal per-domain errors are collected and returned together.
func (u *CheckScheduleUsecase) DiscoverAndEnsureSchedules() error {
if u.domainLister == nil || u.checkerUsecase == nil {
return nil
}
plugins, err := u.checkerUsecase.ListCheckers()
if err != nil {
return fmt.Errorf("listing check plugins for discovery: %w", err)
}
iter, err := u.domainLister.ListAllDomains()
if err != nil {
return fmt.Errorf("listing domains for schedule discovery: %w", err)
}
defer iter.Close()
var errs []error
for iter.Next() {
domain := iter.Item()
if domain == nil {
continue
}
for checkerName, p := range *plugins {
if !p.Availability().ApplyToDomain {
continue
}
schedules, err := u.ListSchedulesByTarget(happydns.CheckScopeDomain, domain.Id, nil, nil)
if err != nil {
errs = append(errs, fmt.Errorf("listing schedules for domain %s: %w", domain.Id, err))
continue
}
hasSchedule := false
for _, sched := range schedules {
if sched.CheckerName == checkerName {
hasSchedule = true
break
}
}
if !hasSchedule {
if err := u.CreateSchedule(&happydns.CheckerSchedule{
CheckerName: checkerName,
OwnerId: domain.Owner,
TargetType: happydns.CheckScopeDomain,
TargetId: domain.Id,
Enabled: true,
}); err != nil {
errs = append(errs, fmt.Errorf("auto-creating schedule for domain %s / plugin %s: %w",
domain.Id, checkerName, err))
}
}
}
}
return errors.Join(errs...)
}

View file

@ -0,0 +1,890 @@
// 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 checkresult_test
import (
"context"
"fmt"
"testing"
"time"
"git.happydns.org/happyDomain/internal/storage"
checkresultUC "git.happydns.org/happyDomain/internal/usecase/checkresult"
"git.happydns.org/happyDomain/model"
)
// ---------------------------------------------------------------------------
// mockCheckerUsecase minimal CheckerUsecase backed by a fixed map.
// ---------------------------------------------------------------------------
type mockCheckerUsecase struct {
checkers map[string]happydns.Checker
}
func (m *mockCheckerUsecase) ListCheckers() (*map[string]happydns.Checker, error) {
return &m.checkers, nil
}
func (m *mockCheckerUsecase) GetChecker(name string) (happydns.Checker, error) {
c, ok := m.checkers[name]
if !ok {
return nil, fmt.Errorf("checker not found: %s", name)
}
return c, nil
}
func (m *mockCheckerUsecase) GetCheckerOptions(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (*happydns.CheckerOptions, error) {
return nil, nil
}
func (m *mockCheckerUsecase) BuildMergedCheckerOptions(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckerOptions, error) {
return runOpts, nil
}
func (m *mockCheckerUsecase) GetStoredCheckerOptionsNoDefault(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (happydns.CheckerOptions, error) {
return make(happydns.CheckerOptions), nil
}
func (m *mockCheckerUsecase) SetCheckerOptions(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
return nil
}
func (m *mockCheckerUsecase) OverwriteSomeCheckerOptions(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
return nil
}
// ---------------------------------------------------------------------------
// mockDomainChecker Checker with configurable Availability.
// ---------------------------------------------------------------------------
type mockDomainChecker struct {
name string
applyDomain bool
applyService bool
}
func (m *mockDomainChecker) ID() string { return m.name }
func (m *mockDomainChecker) Name() string { return m.name }
func (m *mockDomainChecker) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{ApplyToDomain: m.applyDomain, ApplyToService: m.applyService}
}
func (m *mockDomainChecker) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{}
}
func (m *mockDomainChecker) RunCheck(_ context.Context, opts happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
return nil, nil
}
// ---------------------------------------------------------------------------
// Constructor helper
// ---------------------------------------------------------------------------
func newTestCheckScheduleUsecase(db storage.Storage, checkerUC happydns.CheckerUsecase) *checkresultUC.CheckScheduleUsecase {
return checkresultUC.NewCheckScheduleUsecase(db, &happydns.Options{}, db, checkerUC)
}
// seedSchedule creates and stores a schedule in the db, returning it with its assigned ID.
func seedSchedule(t *testing.T, db storage.Storage, interval time.Duration) *happydns.CheckerSchedule {
t.Helper()
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
s := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: interval,
Enabled: true,
NextRun: time.Now().Add(interval),
}
if err := db.CreateCheckerSchedule(s); err != nil {
t.Fatalf("failed to seed schedule: %v", err)
}
return s
}
// ---------------------------------------------------------------------------
// CreateSchedule tests
// ---------------------------------------------------------------------------
func Test_CreateSchedule_DefaultInterval_Domain(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
schedule := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Enabled: true,
}
if err := uc.CreateSchedule(schedule); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if schedule.Interval != checkresultUC.DefaultDomainCheckInterval {
t.Errorf("expected default domain interval %v, got %v", checkresultUC.DefaultDomainCheckInterval, schedule.Interval)
}
}
func Test_CreateSchedule_DefaultInterval_Service(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
schedule := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeService,
TargetId: targetId,
Enabled: true,
}
if err := uc.CreateSchedule(schedule); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if schedule.Interval != checkresultUC.DefaultServiceCheckInterval {
t.Errorf("expected default service interval %v, got %v", checkresultUC.DefaultServiceCheckInterval, schedule.Interval)
}
}
func Test_CreateSchedule_MinimumIntervalRejected(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
schedule := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: 4 * time.Minute,
Enabled: true,
}
if err := uc.CreateSchedule(schedule); err == nil {
t.Fatal("expected error for interval below minimum (4 minutes)")
}
}
func Test_CreateSchedule_ExactMinimumAccepted(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
schedule := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: checkresultUC.MinimumCheckInterval,
Enabled: true,
}
if err := uc.CreateSchedule(schedule); err != nil {
t.Errorf("expected no error for exactly minimum interval, got: %v", err)
}
}
func Test_CreateSchedule_NextRunSetWhenZero(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
interval := 30 * time.Minute
schedule := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: interval,
Enabled: true,
}
before := time.Now()
if err := uc.CreateSchedule(schedule); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if schedule.NextRun.IsZero() {
t.Fatal("expected NextRun to be set")
}
// NextRun = now + rand(0, interval), so NextRun is in [before, before+interval).
if schedule.NextRun.Before(before) || schedule.NextRun.After(before.Add(interval)) {
t.Errorf("NextRun %v not in expected range [%v, %v+interval]", schedule.NextRun, before, before)
}
}
func Test_CreateSchedule_NextRunPreserved(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
specificNextRun := time.Now().Add(3 * time.Hour)
schedule := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: 30 * time.Minute,
Enabled: true,
NextRun: specificNextRun,
}
if err := uc.CreateSchedule(schedule); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !schedule.NextRun.Equal(specificNextRun) {
t.Errorf("expected NextRun to be preserved as %v, got %v", specificNextRun, schedule.NextRun)
}
}
// ---------------------------------------------------------------------------
// UpdateSchedule tests
// ---------------------------------------------------------------------------
func Test_UpdateSchedule_PreservesLastRun(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
lastRun := time.Now().Add(-30 * time.Minute)
existing.LastRun = &lastRun
// Store LastRun into the db.
if err := db.UpdateCheckerSchedule(existing); err != nil {
t.Fatalf("failed to persist LastRun: %v", err)
}
// Update without setting LastRun → should be preserved from existing.
update := *existing
update.LastRun = nil
if err := uc.UpdateSchedule(&update); err != nil {
t.Fatalf("unexpected error: %v", err)
}
stored, err := db.GetCheckerSchedule(existing.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
if stored.LastRun == nil {
t.Error("expected LastRun to be preserved from existing schedule")
} else if !stored.LastRun.Equal(lastRun) {
t.Errorf("expected LastRun %v, got %v", lastRun, *stored.LastRun)
}
}
func Test_UpdateSchedule_RecalculatesNextRun(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
lastRun := time.Now().Add(-20 * time.Minute)
existing.LastRun = &lastRun
if err := db.UpdateCheckerSchedule(existing); err != nil {
t.Fatalf("failed to persist LastRun: %v", err)
}
newInterval := 2 * time.Hour
update := *existing
update.Interval = newInterval
if err := uc.UpdateSchedule(&update); err != nil {
t.Fatalf("unexpected error: %v", err)
}
stored, err := db.GetCheckerSchedule(existing.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
expectedNextRun := lastRun.Add(newInterval)
if !stored.NextRun.Equal(expectedNextRun) {
t.Errorf("expected NextRun=%v (LastRun+newInterval), got %v", expectedNextRun, stored.NextRun)
}
}
func Test_UpdateSchedule_NextRunFromNowWhenNoLastRun(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
// Ensure no LastRun in stored version.
existing.LastRun = nil
if err := db.UpdateCheckerSchedule(existing); err != nil {
t.Fatalf("failed to persist cleared LastRun: %v", err)
}
newInterval := 2 * time.Hour
update := *existing
update.Interval = newInterval
before := time.Now()
if err := uc.UpdateSchedule(&update); err != nil {
t.Fatalf("unexpected error: %v", err)
}
after := time.Now()
stored, err := db.GetCheckerSchedule(existing.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
lowerBound := before.Add(newInterval)
upperBound := after.Add(newInterval)
if stored.NextRun.Before(lowerBound) || stored.NextRun.After(upperBound) {
t.Errorf("expected NextRun in [%v, %v], got %v", lowerBound, upperBound, stored.NextRun)
}
}
func Test_UpdateSchedule_MinimumIntervalRejected(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
update := *existing
update.Interval = 2 * time.Minute
if err := uc.UpdateSchedule(&update); err == nil {
t.Fatal("expected error for interval below minimum")
}
}
// ---------------------------------------------------------------------------
// EnableSchedule / DisableSchedule tests
// ---------------------------------------------------------------------------
func Test_EnableSchedule_SetsEnabled(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
existing.Enabled = false
existing.NextRun = time.Now().Add(time.Hour) // future, no reset needed
if err := db.UpdateCheckerSchedule(existing); err != nil {
t.Fatalf("failed to persist disabled schedule: %v", err)
}
if err := uc.EnableSchedule(existing.Id); err != nil {
t.Fatalf("unexpected error: %v", err)
}
stored, err := db.GetCheckerSchedule(existing.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
if !stored.Enabled {
t.Error("expected Enabled=true after EnableSchedule")
}
}
func Test_EnableSchedule_ResetsNextRunIfPast(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
existing.Enabled = false
existing.NextRun = time.Now().Add(-time.Hour) // in the past
if err := db.UpdateCheckerSchedule(existing); err != nil {
t.Fatalf("failed to persist past NextRun: %v", err)
}
before := time.Now()
if err := uc.EnableSchedule(existing.Id); err != nil {
t.Fatalf("unexpected error: %v", err)
}
after := time.Now()
stored, err := db.GetCheckerSchedule(existing.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
lowerBound := before.Add(existing.Interval)
upperBound := after.Add(existing.Interval)
if stored.NextRun.Before(lowerBound) || stored.NextRun.After(upperBound) {
t.Errorf("expected NextRun in [%v, %v] after enable, got %v", lowerBound, upperBound, stored.NextRun)
}
}
func Test_DisableSchedule_SetsDisabled(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
// It's already Enabled=true from seedSchedule.
if err := uc.DisableSchedule(existing.Id); err != nil {
t.Fatalf("unexpected error: %v", err)
}
stored, err := db.GetCheckerSchedule(existing.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
if stored.Enabled {
t.Error("expected Enabled=false after DisableSchedule")
}
}
// ---------------------------------------------------------------------------
// ListDueSchedules tests
// ---------------------------------------------------------------------------
func Test_ListDueSchedules_FiltersDisabledAndFuture(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
pastTime := time.Now().Add(-time.Minute)
futureTime := time.Now().Add(time.Hour)
makeAndStore := func(enabled bool, nextRun time.Time) *happydns.CheckerSchedule {
s := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: time.Hour,
Enabled: enabled,
NextRun: nextRun,
}
if err := db.CreateCheckerSchedule(s); err != nil {
t.Fatalf("failed to create schedule: %v", err)
}
return s
}
enabledDue := makeAndStore(true, pastTime)
_ = makeAndStore(false, pastTime) // disabled + past → not returned
_ = makeAndStore(true, futureTime) // enabled + future → not returned
due, err := uc.ListDueSchedules()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(due) != 1 {
t.Errorf("expected 1 due schedule, got %d", len(due))
}
if len(due) > 0 && !due[0].Id.Equals(enabledDue.Id) {
t.Errorf("expected the enabled+past schedule to be returned")
}
}
func Test_ListDueSchedules_Empty(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
due, err := uc.ListDueSchedules()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(due) != 0 {
t.Errorf("expected 0 due schedules, got %d", len(due))
}
}
// ---------------------------------------------------------------------------
// ListUpcomingSchedules tests
// ---------------------------------------------------------------------------
func Test_ListUpcomingSchedules_SortedAscending(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
now := time.Now()
// Insert in reverse order (far future first) to ensure sorting is needed.
for i := 3; i >= 1; i-- {
s := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: time.Hour,
Enabled: true,
NextRun: now.Add(time.Duration(i) * time.Hour),
}
if err := db.CreateCheckerSchedule(s); err != nil {
t.Fatalf("failed to create schedule: %v", err)
}
}
upcoming, err := uc.ListUpcomingSchedules(0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i := 1; i < len(upcoming); i++ {
if upcoming[i].NextRun.Before(upcoming[i-1].NextRun) {
t.Errorf("schedules not in ascending order at index %d: %v > %v",
i, upcoming[i-1].NextRun, upcoming[i].NextRun)
}
}
}
func Test_ListUpcomingSchedules_LimitApplied(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
now := time.Now()
for i := range 5 {
s := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: time.Hour,
Enabled: true,
NextRun: now.Add(time.Duration(i) * time.Hour),
}
if err := db.CreateCheckerSchedule(s); err != nil {
t.Fatalf("failed to create schedule: %v", err)
}
}
upcoming, err := uc.ListUpcomingSchedules(3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(upcoming) != 3 {
t.Errorf("expected 3 schedules with limit=3, got %d", len(upcoming))
}
}
// ---------------------------------------------------------------------------
// ValidateScheduleOwnership tests
// ---------------------------------------------------------------------------
func Test_ValidateScheduleOwnership_Match(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
if err := uc.ValidateScheduleOwnership(existing.Id, existing.OwnerId); err != nil {
t.Errorf("expected no error for matching owner, got: %v", err)
}
}
func Test_ValidateScheduleOwnership_Mismatch(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
existing := seedSchedule(t, db, time.Hour)
wrongUserId, _ := happydns.NewRandomIdentifier()
if err := uc.ValidateScheduleOwnership(existing.Id, wrongUserId); err == nil {
t.Fatal("expected error for wrong owner")
}
}
// ---------------------------------------------------------------------------
// RescheduleOverdueChecks tests
// ---------------------------------------------------------------------------
func createOverdueSchedules(t *testing.T, db storage.Storage, n int) []*happydns.CheckerSchedule {
t.Helper()
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
pastTime := time.Now().Add(-2 * time.Hour)
schedules := make([]*happydns.CheckerSchedule, n)
for i := range n {
s := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: time.Hour,
Enabled: true,
NextRun: pastTime,
}
if err := db.CreateCheckerSchedule(s); err != nil {
t.Fatalf("failed to create overdue schedule: %v", err)
}
schedules[i] = s
}
return schedules
}
func Test_RescheduleOverdueChecks_FewOverdue_NoChange(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
schedules := createOverdueSchedules(t, db, 5)
originalNextRuns := make([]time.Time, len(schedules))
for i, s := range schedules {
originalNextRuns[i] = s.NextRun
}
count, err := uc.RescheduleOverdueChecks()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 0 {
t.Errorf("expected count=0 for fewer than 10 overdue schedules, got %d", count)
}
// NextRun should not have changed.
for i, s := range schedules {
stored, err := db.GetCheckerSchedule(s.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule: %v", err)
}
if !stored.NextRun.Equal(originalNextRuns[i]) {
t.Errorf("schedule[%d] NextRun changed when it should not have", i)
}
}
}
func Test_RescheduleOverdueChecks_ManyOverdue_Rescheduled(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
schedules := createOverdueSchedules(t, db, 15)
before := time.Now()
count, err := uc.RescheduleOverdueChecks()
after := time.Now()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 15 {
t.Errorf("expected count=15, got %d", count)
}
// All schedules should now have NextRun in [before, before+MinimumCheckInterval].
for i, s := range schedules {
stored, err := db.GetCheckerSchedule(s.Id)
if err != nil {
t.Fatalf("failed to retrieve schedule[%d]: %v", i, err)
}
if !stored.NextRun.After(before) {
t.Errorf("schedule[%d] NextRun %v should be after %v", i, stored.NextRun, before)
}
upperBound := after.Add(checkresultUC.MinimumCheckInterval)
if stored.NextRun.After(upperBound) {
t.Errorf("schedule[%d] NextRun %v should not exceed %v", i, stored.NextRun, upperBound)
}
}
}
func Test_RescheduleOverdueChecks_FutureSchedulesIgnored(t *testing.T) {
db := newTestDB(t)
uc := newTestCheckScheduleUsecase(db, nil)
overdue := createOverdueSchedules(t, db, 15)
_ = overdue // created in db
// Add 3 future enabled schedules.
ownerId, _ := happydns.NewRandomIdentifier()
targetId, _ := happydns.NewRandomIdentifier()
futureTime := time.Now().Add(2 * time.Hour)
var futureSchedules []*happydns.CheckerSchedule
for range 3 {
s := &happydns.CheckerSchedule{
CheckerName: "checker",
OwnerId: ownerId,
TargetType: happydns.CheckScopeDomain,
TargetId: targetId,
Interval: time.Hour,
Enabled: true,
NextRun: futureTime,
}
if err := db.CreateCheckerSchedule(s); err != nil {
t.Fatalf("failed to create future schedule: %v", err)
}
futureSchedules = append(futureSchedules, s)
}
if _, err := uc.RescheduleOverdueChecks(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Future schedules should retain their original NextRun.
for i, s := range futureSchedules {
stored, err := db.GetCheckerSchedule(s.Id)
if err != nil {
t.Fatalf("failed to retrieve future schedule[%d]: %v", i, err)
}
if !stored.NextRun.Equal(futureTime) {
t.Errorf("future schedule[%d] NextRun changed from %v to %v", i, futureTime, stored.NextRun)
}
}
}
// ---------------------------------------------------------------------------
// DiscoverAndEnsureSchedules tests
// ---------------------------------------------------------------------------
func createDomain(t *testing.T, db storage.Storage, name string) *happydns.Domain {
t.Helper()
ownerId, _ := happydns.NewRandomIdentifier()
domain := &happydns.Domain{
Owner: ownerId,
DomainName: name,
}
if err := db.CreateDomain(domain); err != nil {
t.Fatalf("failed to create domain %s: %v", name, err)
}
return domain
}
func Test_DiscoverAndEnsureSchedules_CreatesForMissingPlugin(t *testing.T) {
db := newTestDB(t)
domain := createDomain(t, db, "example.com.")
checkerUC := &mockCheckerUsecase{
checkers: map[string]happydns.Checker{
"domain-checker": &mockDomainChecker{name: "domain-checker", applyDomain: true},
},
}
uc := newTestCheckScheduleUsecase(db, checkerUC)
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
schedules, err := db.ListCheckerSchedulesByTarget(happydns.CheckScopeDomain, domain.Id, nil, nil)
if err != nil {
t.Fatalf("failed to list schedules: %v", err)
}
if len(schedules) != 1 {
t.Errorf("expected 1 schedule created, got %d", len(schedules))
}
}
func Test_DiscoverAndEnsureSchedules_SkipsExistingSchedule(t *testing.T) {
db := newTestDB(t)
domain := createDomain(t, db, "example.com.")
checkerUC := &mockCheckerUsecase{
checkers: map[string]happydns.Checker{
"domain-checker": &mockDomainChecker{name: "domain-checker", applyDomain: true},
},
}
// Pre-seed a schedule for this domain + checker.
pre := &happydns.CheckerSchedule{
CheckerName: "domain-checker",
OwnerId: domain.Owner,
TargetType: happydns.CheckScopeDomain,
TargetId: domain.Id,
Interval: 24 * time.Hour,
Enabled: true,
NextRun: time.Now().Add(time.Hour),
}
if err := db.CreateCheckerSchedule(pre); err != nil {
t.Fatalf("failed to seed schedule: %v", err)
}
uc := newTestCheckScheduleUsecase(db, checkerUC)
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
schedules, err := db.ListCheckerSchedulesByTarget(happydns.CheckScopeDomain, domain.Id, nil, nil)
if err != nil {
t.Fatalf("failed to list schedules: %v", err)
}
if len(schedules) != 1 {
t.Errorf("expected 1 schedule (no duplicate), got %d", len(schedules))
}
}
func Test_DiscoverAndEnsureSchedules_SkipsServiceOnlyChecker(t *testing.T) {
db := newTestDB(t)
domain := createDomain(t, db, "example.com.")
checkerUC := &mockCheckerUsecase{
checkers: map[string]happydns.Checker{
"service-only": &mockDomainChecker{name: "service-only", applyDomain: false, applyService: true},
},
}
uc := newTestCheckScheduleUsecase(db, checkerUC)
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
schedules, err := db.ListCheckerSchedulesByTarget(happydns.CheckScopeDomain, domain.Id, nil, nil)
if err != nil {
t.Fatalf("failed to list schedules: %v", err)
}
if len(schedules) != 0 {
t.Errorf("expected 0 schedules for service-only checker, got %d", len(schedules))
}
}
func Test_DiscoverAndEnsureSchedules_NilDependencies(t *testing.T) {
db := newTestDB(t)
// Both domainLister and checkerUsecase are nil → returns nil, no panic.
uc := checkresultUC.NewCheckScheduleUsecase(db, &happydns.Options{}, nil, nil)
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
t.Errorf("expected nil error for nil dependencies, got: %v", err)
}
}
func Test_DiscoverAndEnsureSchedules_MultipleDomains(t *testing.T) {
db := newTestDB(t)
createDomain(t, db, "alpha.com.")
createDomain(t, db, "beta.com.")
createDomain(t, db, "gamma.com.")
checkerUC := &mockCheckerUsecase{
checkers: map[string]happydns.Checker{
"checker-1": &mockDomainChecker{name: "checker-1", applyDomain: true},
"checker-2": &mockDomainChecker{name: "checker-2", applyDomain: true},
},
}
uc := newTestCheckScheduleUsecase(db, checkerUC)
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 3 domains × 2 checkers = 6 schedules.
enabled, err := db.ListEnabledCheckerSchedules()
if err != nil {
t.Fatalf("failed to list schedules: %v", err)
}
if len(enabled) != 6 {
t.Errorf("expected 6 schedules (3 domains × 2 checkers), got %d", len(enabled))
}
}

View file

@ -0,0 +1,103 @@
// 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 checkresult
import (
"time"
"git.happydns.org/happyDomain/model"
)
// CheckResultStorage defines the storage interface for check results and related data
type CheckResultStorage interface {
// Check Results
// ListCheckResults retrieves check results for a specific plugin+target combination
ListCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error)
// ListCheckResultsByPlugin retrieves all check results for a plugin across all targets for a user
ListCheckResultsByPlugin(userId happydns.Identifier, checkName string, limit int) ([]*happydns.CheckResult, error)
// ListCheckResultsByUser retrieves all check results for a user
ListCheckResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error)
// GetCheckResult retrieves a specific check result by its ID
GetCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error)
// CreateCheckResult stores a new check result
CreateCheckResult(result *happydns.CheckResult) error
// DeleteCheckResult removes a specific check result
DeleteCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error
// DeleteOldCheckResults removes old check results keeping only the most recent N results
DeleteOldCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, keepCount int) error
// Checker Schedules
// ListEnabledCheckerSchedules retrieves all enabled schedules (for scheduler)
ListEnabledCheckerSchedules() ([]*happydns.CheckerSchedule, error)
// ListCheckerSchedulesByUser retrieves all schedules for a specific user
ListCheckerSchedulesByUser(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error)
// ListCheckerSchedulesByTarget retrieves all schedules for a specific target
ListCheckerSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier, insideType *happydns.CheckScopeType, insideId *happydns.Identifier) ([]*happydns.CheckerSchedule, error)
// GetCheckerSchedule retrieves a specific schedule by ID
GetCheckerSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error)
// CreateCheckerSchedule creates a new check schedule
CreateCheckerSchedule(schedule *happydns.CheckerSchedule) error
// UpdateCheckerSchedule updates an existing schedule
UpdateCheckerSchedule(schedule *happydns.CheckerSchedule) error
// DeleteCheckerSchedule removes a schedule
DeleteCheckerSchedule(scheduleId happydns.Identifier) error
// Check Executions
// ListActiveCheckExecutions retrieves all executions that are pending or running
ListActiveCheckExecutions() ([]*happydns.CheckExecution, error)
// GetCheckExecution retrieves a specific execution by ID
GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error)
// CreateCheckExecution creates a new check execution record
CreateCheckExecution(execution *happydns.CheckExecution) error
// UpdateCheckExecution updates an existing execution record
UpdateCheckExecution(execution *happydns.CheckExecution) error
// DeleteCheckExecution removes an execution record
DeleteCheckExecution(executionId happydns.Identifier) error
// Scheduler State
// CheckSchedulerRun marks that the scheduler has run at current time
CheckSchedulerRun() error
// LastCheckSchedulerRun retrieves the last time the scheduler ran
LastCheckSchedulerRun() (*time.Time, error)
}
// DomainLister provides access to domain listings for schedule discovery.
type DomainLister interface {
ListAllDomains() (happydns.Iterator[happydns.Domain], error)
}

View file

@ -0,0 +1,62 @@
// 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 usecase
import (
"context"
"errors"
"fmt"
"strings"
"git.happydns.org/happyDomain/model"
)
type domainInfoUsecase struct {
getters []happydns.DomainInfoGetter
}
func NewDomainInfoUsecase(getters ...happydns.DomainInfoGetter) happydns.DomainInfoUsecase {
return &domainInfoUsecase{getters: getters}
}
func (diu *domainInfoUsecase) GetDomainInfo(ctx context.Context, fqdn happydns.Origin) (*happydns.DomainInfo, error) {
domain := happydns.Origin(strings.TrimSuffix(string(fqdn), "."))
var lastErr error
for _, getter := range diu.getters {
infos, err := getter(ctx, domain)
if err != nil {
if errors.Is(err, happydns.DomainDoesNotExist) {
return nil, err
}
lastErr = err
continue
}
if infos == nil {
lastErr = fmt.Errorf("no information found")
continue
}
return infos, nil
}
return nil, fmt.Errorf("unable to retrieve RDAP/WHOIS info about the domain name: %w", lastErr)
}

View file

@ -24,6 +24,7 @@ package service
import (
"encoding/json"
"git.happydns.org/happyDomain/internal/forms"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services"
)
@ -41,5 +42,10 @@ func ParseService(msg *happydns.ServiceMessage) (svc *happydns.Service, err erro
}
err = json.Unmarshal(msg.Service, &svc.Service)
if err != nil {
return
}
err = forms.ValidateStructValues(svc.Service)
return
}

226
model/check_result.go Normal file
View file

@ -0,0 +1,226 @@
// 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 happydns
import (
"time"
)
// CheckScopeType represents the scope level at which a check is performed
type CheckScopeType int
const (
CheckScopeInstance CheckScopeType = iota
CheckScopeUser
CheckScopeDomain
CheckScopeZone
CheckScopeService
CheckScopeOnDemand
)
// String returns a string representation of the check scope type
func (t CheckScopeType) String() string {
switch t {
case CheckScopeInstance:
return "instance"
case CheckScopeUser:
return "user"
case CheckScopeDomain:
return "domain"
case CheckScopeService:
return "service"
case CheckScopeOnDemand:
return "ondemand"
default:
return "unknown"
}
}
// CheckExecutionStatus represents the current state of a check execution
type CheckExecutionStatus int
const (
CheckExecutionPending CheckExecutionStatus = iota
CheckExecutionRunning
CheckExecutionCompleted
CheckExecutionFailed
)
// String returns a string representation of the check execution status
func (t CheckExecutionStatus) String() string {
switch t {
case CheckExecutionPending:
return "pending"
case CheckExecutionRunning:
return "running"
case CheckExecutionCompleted:
return "completed"
case CheckExecutionFailed:
return "failed"
default:
return "unknown"
}
}
// CheckResult stores the result of a check execution
type CheckResult struct {
// Id is the unique identifier for this check result
Id Identifier `json:"id" swaggertype:"string"`
// CheckerName identifies which checker was executed
CheckerName string `json:"checker_name"`
// CheckType indicates the scope level of the check
CheckType CheckScopeType `json:"check_type"`
// TargetId is the identifier of the target (User/Domain/Service)
TargetId Identifier `json:"target_id" swaggertype:"string"`
// InsideType indicates the scope level of the parent target
InsideType *CheckScopeType `json:"check_type"`
// InsideId is the identifier of the parent target (User/Domain)
InsideId *Identifier `json:"inside_id" swaggertype:"string"`
// OwnerId is the owner of the check
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
// ExecutedAt is when the check was executed
ExecutedAt time.Time `json:"executed_at"`
// ScheduledCheck indicates if this was a scheduled (true) or on-demand (false) check
ScheduledCheck bool `json:"scheduled_check"`
// Options contains the merged checker configuration used for this check
Options CheckerOptions `json:"options,omitempty"`
// Status is the overall check result status
Status CheckResultStatus `json:"status"`
// StatusLine is a summary message of the check result
StatusLine string `json:"status_line"`
// Report contains the full check report (checker-specific structure)
Report any `json:"report,omitempty"`
// Duration is how long the check took to execute
Duration time.Duration `json:"duration" swaggertype:"integer"`
// Error contains any error message if the execution failed
Error string `json:"error,omitempty"`
}
// CheckExecution tracks an in-progress or completed check execution
type CheckExecution struct {
// Id is the unique identifier for this execution
Id Identifier `json:"id" swaggertype:"string"`
// ScheduleId is the schedule that triggered this execution (nil for on-demand)
ScheduleId *Identifier `json:"schedule_id,omitempty" swaggertype:"string"`
// CheckerName identifies which checker is being executed
CheckerName string `json:"checker_name"`
// OwnerId is the owner of the check
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
// InsideType indicates the scope level of the parent target
InsideType *CheckScopeType `json:"check_type"`
// InsideId is the identifier of the parent target (User/Domain)
InsideId *Identifier `json:"inside_id" swaggertype:"string"`
// TargetType indicates the scope level of the check
TargetType CheckScopeType `json:"target_type"`
// TargetId is the identifier of the target being checked
TargetId Identifier `json:"target_id" swaggertype:"string"`
// Status is the current execution status
Status CheckExecutionStatus `json:"status"`
// StartedAt is when the execution began
StartedAt time.Time `json:"started_at"`
// CompletedAt is when the execution finished (nil if still running)
CompletedAt *time.Time `json:"completed_at,omitempty"`
// ResultId links to the CheckResult (nil if execution not completed)
ResultId *Identifier `json:"result_id,omitempty" swaggertype:"string"`
// Options contains the checker configuration for this execution
Options CheckerOptions `json:"options,omitempty"`
}
// CheckResultUsecase defines business logic for check results
type CheckResultUsecase interface {
// ListCheckerStatuses returns all checkers applicable to scope with their
// schedule and most recent result for the given target.
ListCheckerStatuses(scope CheckScopeType, targetID Identifier, insideScope *CheckScopeType, insideID *Identifier, user *User, domain *Domain, service *Service) ([]CheckerStatus, error)
// ListCheckResultsByTarget retrieves check results for a specific target owned by userId
ListCheckResultsByTarget(checkerName string, targetType CheckScopeType, targetId Identifier, insideScope *CheckScopeType, insideID *Identifier, userId Identifier, limit int) ([]*CheckResult, error)
// ListAllCheckResultsByTarget retrieves all check results for a target across all checkers
ListAllCheckResultsByTarget(targetType CheckScopeType, targetId Identifier, insideScope *CheckScopeType, insideID *Identifier, userId Identifier, limit int) ([]*CheckResult, error)
// GetCheckResult retrieves a specific check result owned by userId
GetCheckResult(checkName string, targetType CheckScopeType, targetId Identifier, resultId Identifier, insideScope *CheckScopeType, insideID *Identifier, userId Identifier) (*CheckResult, error)
// CreateCheckResult stores a new check result and enforces retention policy
CreateCheckResult(result *CheckResult) error
// DeleteCheckResult removes a specific check result owned by userId
DeleteCheckResult(checkName string, targetType CheckScopeType, targetId Identifier, resultId Identifier, insideScope *CheckScopeType, insideID *Identifier, userId Identifier) error
// DeleteAllCheckResults removes all results for a specific checker+target combination owned by userId
DeleteAllCheckResults(checkName string, targetType CheckScopeType, targetId Identifier, insideScope *CheckScopeType, insideID *Identifier, userId Identifier) error
// GetCheckExecution retrieves the status of a check execution
GetCheckExecution(executionId Identifier) (*CheckExecution, error)
// CreateCheckExecution creates a new check execution record
CreateCheckExecution(execution *CheckExecution) error
// UpdateCheckExecution updates an existing check execution
UpdateCheckExecution(execution *CheckExecution) error
// CompleteCheckExecution marks an execution as completed with a result
CompleteCheckExecution(executionId Identifier, resultId Identifier) error
// FailCheckExecution marks an execution as failed
FailCheckExecution(executionId Identifier, errorMsg string) error
// DeleteCompletedExecutions removes execution records older than the given duration.
DeleteCompletedExecutions(olderThan time.Duration) error
// CleanupOldResults removes check results older than the configured retention period.
CleanupOldResults() error
// GetWorstCheckStatus returns the worst (most critical) status from the most
// recent result of each checker for a given target. Returns nil if no results exist.
GetWorstCheckStatus(targetType CheckScopeType, targetId Identifier, insideScope *CheckScopeType, insideID *Identifier, userId Identifier) (*CheckResultStatus, error)
// GetWorstCheckStatusByUser returns a map from target ID string to worst check
// status for all targets of the given type owned by the user, in a single pass.
GetWorstCheckStatusByUser(targetType CheckScopeType, userId Identifier) (map[string]*CheckResultStatus, error)
}

153
model/check_scheduler.go Normal file
View file

@ -0,0 +1,153 @@
// 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 happydns
import (
"time"
)
// SchedulerUsecase defines the interface for triggering on-demand checks
type SchedulerUsecase interface {
Run()
Close()
TriggerOnDemandCheck(checkerName string, targetType CheckScopeType, targetID Identifier, insideScopeType *CheckScopeType, insideID *Identifier, userID Identifier, options CheckerOptions) (Identifier, error)
GetSchedulerStatus() SchedulerStatus
SetEnabled(enabled bool) error
RescheduleUpcomingChecks() (int, error)
}
// CheckerSchedule defines a recurring check schedule
type CheckerSchedule struct {
// Id is the unique identifier for this schedule
Id Identifier `json:"id" swaggertype:"string"`
// CheckerName identifies which checker to execute
CheckerName string `json:"checker_name"`
// OwnerId is the owner of the schedule
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
// InsideType indicates the scope level of the parent target
InsideType *CheckScopeType `json:"check_type"`
// InsideId is the identifier of the parent target (User/Domain)
InsideId *Identifier `json:"inside_id" swaggertype:"string"`
// TargetType indicates what type of target to check
TargetType CheckScopeType `json:"target_type"`
// TargetId is the identifier of the target to check
TargetId Identifier `json:"target_id" swaggertype:"string"`
// Interval is how often to run the check
Interval time.Duration `json:"interval" swaggertype:"integer"`
// Enabled indicates if the schedule is active
Enabled bool `json:"enabled"`
// LastRun is when the check was last executed (nil if never run)
LastRun *time.Time `json:"last_run,omitempty"`
// NextRun is when the check should next be executed
NextRun time.Time `json:"next_run"`
// Options contains checker-specific configuration
Options CheckerOptions `json:"options,omitempty"`
}
// SchedulerStatus holds a snapshot of the scheduler state for monitoring
type SchedulerStatus struct {
// ConfigEnabled indicates if the scheduler is enabled in the configuration file
ConfigEnabled bool `json:"config_enabled"`
// RuntimeEnabled indicates if the scheduler is currently enabled at runtime
RuntimeEnabled bool `json:"runtime_enabled"`
// Running indicates if the scheduler goroutine is currently running
Running bool `json:"running"`
// WorkerCount is the number of worker goroutines
WorkerCount int `json:"worker_count"`
// QueueSize is the number of items currently waiting in the execution queue
QueueSize int `json:"queue_size"`
// ActiveCount is the number of checks currently being executed
ActiveCount int `json:"active_count"`
// NextSchedules contains the upcoming scheduled checks sorted by next run time
NextSchedules []*CheckerSchedule `json:"next_schedules"`
}
// CheckerScheduleUsecase defines business logic for check schedules
type CheckerScheduleUsecase interface {
// ListUserSchedules retrieves all schedules for a specific user
ListUserSchedules(userId Identifier) ([]*CheckerSchedule, error)
// ListSchedulesByTarget retrieves all schedules for a specific target
ListSchedulesByTarget(targetType CheckScopeType, targetId Identifier, insideType *CheckScopeType, insideId *Identifier) ([]*CheckerSchedule, error)
// GetSchedule retrieves a specific schedule by ID
GetSchedule(scheduleId Identifier) (*CheckerSchedule, error)
// CreateSchedule creates a new check schedule with validation
CreateSchedule(schedule *CheckerSchedule) error
// UpdateSchedule updates an existing schedule
UpdateSchedule(schedule *CheckerSchedule) error
// DeleteSchedule removes a schedule
DeleteSchedule(scheduleId Identifier) error
// EnableSchedule enables a schedule
EnableSchedule(scheduleId Identifier) error
// DisableSchedule disables a schedule
DisableSchedule(scheduleId Identifier) error
// UpdateScheduleAfterRun updates a schedule after it has been executed
UpdateScheduleAfterRun(scheduleId Identifier) error
// ListDueSchedules retrieves all enabled schedules that are due to run
ListDueSchedules() ([]*CheckerSchedule, error)
// ValidateScheduleOwnership checks if a user owns a schedule
ValidateScheduleOwnership(scheduleId Identifier, ownerId Identifier) error
// DeleteSchedulesForTarget removes all schedules for a target
DeleteSchedulesForTarget(targetType CheckScopeType, targetId Identifier, insideType *CheckScopeType, insideId *Identifier, ownerId Identifier) error
// ListUpcomingSchedules retrieves the next limit enabled schedules sorted by NextRun ascending.
ListUpcomingSchedules(limit int) ([]*CheckerSchedule, error)
// DiscoverAndEnsureSchedules creates default enabled schedules for all (plugin, domain) pairs
// that do not already have a schedule.
DiscoverAndEnsureSchedules() error
// RescheduleUpcomingChecks randomizes next run times for all enabled schedules
// within their respective intervals to spread load evenly.
RescheduleUpcomingChecks() (int, error)
// RescheduleOverdueTests reschedules overdue tests to run soon, spread over a
// short window to avoid scheduler famine after a suspend or server restart.
RescheduleOverdueChecks() (int, error)
}

200
model/checker.go Normal file
View file

@ -0,0 +1,200 @@
// 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 happydns
import (
"context"
"encoding/json"
"time"
)
// Auto-fill variable identifiers for checker option fields.
const (
// AutoFillDomainName fills the option with the fully qualified domain name
// of the domain being tested (e.g. "example.com.").
AutoFillDomainName = "domain_name"
// AutoFillSubdomain fills the option with the subdomain relative to the zone
// (e.g. "www" for "www.example.com." in zone "example.com."). Only
// applicable for service-scoped tests.
AutoFillSubdomain = "subdomain"
// AutoFillZone fills the option with the zone object. Only applicable
// for domain-scoped and service-scoped tests.
AutoFillZone = "zone"
// AutoFillServiceType fills the option with the service type identifier
// (e.g. "abstract.MatrixIM"). Only applicable for service-scoped tests.
AutoFillServiceType = "service_type"
// AutoFillService fills the option with the service object. Only applicable
// for service-scoped tests.
AutoFillService = "service"
)
const (
CheckResultStatusUnknown CheckResultStatus = iota
CheckResultStatusCritical
CheckResultStatusWarn
CheckResultStatusInfo
CheckResultStatusOK
)
type CheckResultStatus int
func (s CheckResultStatus) String() string {
switch s {
case CheckResultStatusOK:
return "ok"
case CheckResultStatusWarn:
return "warning"
case CheckResultStatusCritical:
return "critical"
case CheckResultStatusInfo:
return "info"
default:
return "unknown"
}
}
type CheckerOptions map[string]any
type Checker interface {
ID() string
Name() string
Availability() CheckerAvailability
Options() CheckerOptionsDocumentation
RunCheck(ctx context.Context, options CheckerOptions, meta map[string]string) (*CheckResult, error)
}
// CheckIntervalSpec describes the scheduling bounds for a checker.
type CheckIntervalSpec struct {
Min time.Duration `json:"min" swaggertype:"integer"`
Max time.Duration `json:"max" swaggertype:"integer"`
Default time.Duration `json:"default" swaggertype:"integer"`
}
// CheckerIntervalProvider is an optional interface checkers can implement
// to declare their preferred scheduling bounds. The scheduler enforces
// Min/Max; Default is used when creating a new schedule with no explicit interval.
type CheckerIntervalProvider interface {
CheckInterval() CheckIntervalSpec
}
// CheckerHTMLReporter is an optional interface checkers can implement
// to render their stored report as a full HTML document (for iframe embedding).
// Detect support with a type assertion: _, ok := checker.(CheckerHTMLReporter)
type CheckerHTMLReporter interface {
// GetHTMLReport generates an HTML document from the JSON-encoded report data
// stored in CheckResult.Report.
// The raw parameter contains the JSON bytes of the Report field as stored.
GetHTMLReport(raw json.RawMessage) (string, error)
}
// MetricPoint represents a single data point in a time series.
type MetricPoint struct {
Timestamp time.Time `json:"timestamp"`
Value float64 `json:"value"`
}
// MetricSeries represents a named time series with a display label and unit.
type MetricSeries struct {
Name string `json:"name"`
Label string `json:"label"`
Unit string `json:"unit"`
Points []MetricPoint `json:"points"`
}
// MetricsReport contains all metric series extracted from check results.
type MetricsReport struct {
Series []MetricSeries `json:"series"`
}
// CheckerMetricsReporter is an optional interface checkers can implement
// to signal that they produce time-series metrics suitable for charting.
// Detect support with a type assertion: _, ok := checker.(CheckerMetricsReporter)
type CheckerMetricsReporter interface {
// ExtractMetrics builds time series from a slice of check results.
// Each result's ExecutedAt becomes the timestamp for that data point.
ExtractMetrics(results []*CheckResult) (*MetricsReport, error)
}
type CheckerResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Availability CheckerAvailability `json:"availability"`
Options CheckerOptionsDocumentation `json:"options"`
Interval *CheckIntervalSpec `json:"interval"`
HasHTMLReport bool `json:"has_html_report,omitempty"`
HasMetrics bool `json:"has_metrics,omitempty"`
}
type SetCheckerOptionsRequest struct {
Options CheckerOptions `json:"options"`
}
type CheckerOptionsPositional struct {
CheckName string
UserId *Identifier
DomainId *Identifier
ServiceId *Identifier
Options CheckerOptions
}
type CheckerAvailability struct {
ApplyToDomain bool `json:"applyToDomain,omitempty"`
ApplyToZone bool `json:"applyToZone,omitempty"`
ApplyToService bool `json:"applyToService,omitempty"`
LimitToProviders []string `json:"limitToProviders,omitempty"`
LimitToServices []string `json:"limitToServices,omitempty"`
}
type CheckerOptionsDocumentation struct {
RunOpts []CheckerOptionDocumentation `json:"runOpts,omitempty"`
ServiceOpts []CheckerOptionDocumentation `json:"serviceOpts,omitempty"`
DomainOpts []CheckerOptionDocumentation `json:"domainOpts,omitempty"`
UserOpts []CheckerOptionDocumentation `json:"userOpts,omitempty"`
AdminOpts []CheckerOptionDocumentation `json:"adminOpts,omitempty"`
}
type CheckerOptionDocumentation Field
// CheckerStatus represents the current status of a checker for a specific target,
// including whether it is enabled, its schedule, and the most recent result.
type CheckerStatus struct {
CheckerName string `json:"checker_name"`
NotDiscovered bool `json:"not_discovered,omitempty"`
Enabled bool `json:"enabled"`
Schedule *CheckerSchedule `json:"schedule,omitempty"`
LastResult *CheckResult `json:"last_result,omitempty"`
}
type CheckerUsecase interface {
BuildMergedCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) (CheckerOptions, error)
GetStoredCheckerOptionsNoDefault(string, *Identifier, *Identifier, *Identifier) (CheckerOptions, error)
GetChecker(string) (Checker, error)
GetCheckerOptions(string, *Identifier, *Identifier, *Identifier) (*CheckerOptions, error)
ListCheckers() (*map[string]Checker, error)
OverwriteSomeCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) error
SetCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) error
}

View file

@ -99,6 +99,20 @@ type Options struct {
// CaptchaLoginThreshold is the number of consecutive login failures before captcha is required.
// 0 means always require captcha at login (when provider is configured).
CaptchaLoginThreshold int
PluginsDirectories []string
// MaxResultsPerCheck is the maximum number of test results to keep per plugin+target combination
MaxResultsPerCheck int
// ResultRetentionDays is how long to keep test results before cleanup
ResultRetentionDays int
// TestWorkers is the number of concurrent test executions allowed
TestWorkers int
// DisableScheduler disables the background test scheduler
DisableScheduler bool
}
// GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL.

View file

@ -97,6 +97,13 @@ type DomainWithZoneMetadata struct {
ZoneMeta map[string]*ZoneMeta `json:"zone_meta"`
}
type DomainWithCheckStatus struct {
*Domain
// LastCheckStatus is the worst status across the most recent result of each
// checker that has run on this domain. Nil if no results exist yet.
LastCheckStatus *CheckResultStatus `json:"last_check_status,omitempty"`
}
type Subdomain string
type Origin string

48
model/domain_info.go Normal file
View file

@ -0,0 +1,48 @@
// 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 happydns
import (
"context"
"errors"
"time"
)
var (
DomainDoesNotExist = errors.New("domain name doesn't exist")
)
type DomainInfo struct {
Name string `json:"name"`
Nameservers []string `json:"nameservers"`
CreationDate *time.Time `json:"creation"`
ExpirationDate *time.Time `json:"expiration"`
Registrar string `json:"registrar"`
RegistrarURL *string `json:"registrar_url"`
Status []string `json:"status"`
}
type DomainInfoGetter func(context.Context, Origin) (*DomainInfo, error)
type DomainInfoUsecase interface {
GetDomainInfo(context.Context, Origin) (*DomainInfo, error)
}

View file

@ -27,15 +27,18 @@ import (
)
var (
ErrAuthUserNotFound = errors.New("user not found")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
ErrAuthUserNotFound = errors.New("user not found")
ErrCheckExecutionNotFound = errors.New("check execution not found")
ErrCheckResultNotFound = errors.New("check result not found")
ErrCheckScheduleNotFound = errors.New("check schedule not found")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
)
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."

View file

@ -104,6 +104,11 @@ type Field struct {
// Description stores an helpfull sentence describing the field.
Description string `json:"description,omitempty"`
// AutoFill indicates the field value is automatically resolved by the
// software based on test context. When set, the value should not be
// entered by the user.
AutoFill string `json:"autoFill,omitempty"`
}
type FormState struct {

View file

@ -27,6 +27,7 @@ import (
"encoding/base64"
"encoding/gob"
"errors"
"slices"
)
const IDENTIFIER_LEN = 16
@ -55,6 +56,10 @@ func (i Identifier) Equals(other Identifier) bool {
return bytes.Equal(i, other)
}
func (i Identifier) Compare(other Identifier) int {
return slices.Compare(i, other)
}
func (i *Identifier) String() string {
return base64.RawURLEncoding.EncodeToString(*i)
}

View file

@ -127,6 +127,13 @@ type ServiceRecord struct {
RR Record `json:"rr,omitempty"`
}
type ServiceWithCheckStatus struct {
*Service
// LastCheckStatus is the worst status across the most recent result of each
// checker that has run on this service. Nil if no results exist yet.
LastCheckStatus *CheckResultStatus `json:"last_check_status,omitempty"`
}
type ServiceUsecase interface {
ListRecords(*Domain, *Zone, *Service) ([]Record, error)
ValidateService(ServiceBody, Subdomain, Origin) ([]byte, error)

View file

@ -61,6 +61,17 @@ type UserSettings struct {
// ShowRRTypes tells if we show equivalent RRTypes in interface (for advanced users).
ShowRRTypes bool `json:"showrrtypes,omitempty"`
// TestRetention overrides instance default for how long to keep test results (days)
TestRetention int `json:"test_retention,omitempty"`
// DomainTestInterval is the default interval for domain-level tests (seconds)
// Default: 86400 (24 hours)
DomainTestInterval int64 `json:"domain_test_interval,omitempty"`
// ServiceTestInterval is the default interval for service-level tests (seconds)
// Default: 3600 (1 hour)
ServiceTestInterval int64 `json:"service_test_interval,omitempty"`
}
func DefaultUserSettings() *UserSettings {

View file

@ -154,6 +154,13 @@ type ZoneServices struct {
Services []*Service `json:"services"`
}
type ZoneWithServicesCheckStatus struct {
*Zone
// ServicesCheckStatus holds the worst check status for each service,
// keyed by service identifier string. Nil/absent if no results exist yet.
ServicesCheckStatus map[string]*CheckResultStatus `json:"services_check_status,omitempty"`
}
type ZoneUsecase interface {
AddRecord(*Zone, string, Record) error
CreateZone(*Zone) error

111
pkg/domaininfo/rdap.go Normal file
View file

@ -0,0 +1,111 @@
// 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 domaininfo
import (
"context"
"time"
"git.happydns.org/happyDomain/model"
"github.com/openrdap/rdap"
)
func GetDomainRDAPInfo(ctx context.Context, domain happydns.Origin) (*happydns.DomainInfo, error) {
client := &rdap.Client{}
req := rdap.NewDomainRequest(string(domain)).WithContext(ctx)
resp, err := client.Do(req)
var domainInfo *rdap.Domain
if err == nil {
var ok bool
domainInfo, ok = resp.Object.(*rdap.Domain)
if !ok {
err = context.DeadlineExceeded // shouldn't happen, but guard anyway
}
}
if err != nil {
if ce, ok := err.(*rdap.ClientError); ok && ce.Type == rdap.ObjectDoesNotExist {
return nil, happydns.DomainDoesNotExist
}
return nil, err
}
// Registrar
registrar := "Unknown"
var registrar_url *string
for _, ent := range domainInfo.Entities {
if ent.Roles != nil {
for _, role := range ent.Roles {
if role == "registrar" && ent.VCard != nil && len(ent.VCard.Get("fn")) > 0 {
registrar = ent.VCard.Get("fn")[0].Value.(string)
if len(ent.VCard.Get("url")) > 0 {
url := ent.VCard.Get("url")[0].Value.(string)
registrar_url = &url
}
}
}
}
}
// Dates
var expiration *time.Time
var creation *time.Time
for _, event := range domainInfo.Events {
if (event.Action == "expiration" || event.Action == "registration") && event.Date != "" {
date, err := time.Parse(time.RFC3339, event.Date)
if err != nil {
return nil, err
}
if event.Action == "expiration" {
expiration = &date
} else if event.Action == "registration" {
creation = &date
}
}
}
// Nameservers
var nameservers []string
for _, nameserver := range domainInfo.Nameservers {
if nameserver.UnicodeName != "" {
nameservers = append(nameservers, nameserver.UnicodeName)
} else {
nameservers = append(nameservers, nameserver.LDHName)
}
}
name := domainInfo.UnicodeName
if name == "" {
name = domainInfo.LDHName
}
return &happydns.DomainInfo{
Name: name,
Nameservers: nameservers,
CreationDate: creation,
ExpirationDate: expiration,
Registrar: registrar,
RegistrarURL: registrar_url,
Status: domainInfo.Status,
}, nil
}

View file

@ -0,0 +1,72 @@
// 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 domaininfo_test
import (
"context"
"errors"
"testing"
happydns "git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/pkg/domaininfo"
)
func TestGetDomainRDAPInfo_KnownDomain(t *testing.T) {
if testing.Short() {
t.Skip("skipping live RDAP integration test")
}
info, err := domaininfo.GetDomainRDAPInfo(context.Background(), happydns.Origin("example.com"))
if err != nil {
t.Fatalf("unexpected error for example.com: %v", err)
}
if info == nil {
t.Fatal("expected non-nil DomainInfo")
}
if info.Name == "" {
t.Error("expected Name to be set")
}
if len(info.Nameservers) == 0 {
t.Error("expected at least one nameserver")
}
if info.ExpirationDate == nil {
t.Error("expected ExpirationDate to be set")
}
if info.CreationDate == nil {
t.Error("expected CreationDate to be set")
}
if info.Registrar == "" {
t.Error("expected Registrar to be set")
}
}
func TestGetDomainRDAPInfo_NonExistentDomain(t *testing.T) {
if testing.Short() {
t.Skip("skipping live RDAP integration test")
}
_, err := domaininfo.GetDomainRDAPInfo(context.Background(), happydns.Origin("this-domain-definitely-does-not-exist-xyz987654321.com"))
if !errors.Is(err, happydns.DomainDoesNotExist) {
t.Errorf("expected DomainDoesNotExist error, got: %v", err)
}
}

75
pkg/domaininfo/whois.go Normal file
View file

@ -0,0 +1,75 @@
// 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 domaininfo
import (
"context"
"errors"
"time"
"git.happydns.org/happyDomain/model"
"github.com/likexian/whois"
"github.com/likexian/whois-parser"
)
func GetDomainWhoisInfo(ctx context.Context, domain happydns.Origin) (*happydns.DomainInfo, error) {
client := whois.NewClient()
// The whois library has no context support; derive a timeout from the
// context deadline so we at least honour it approximately.
if deadline, ok := ctx.Deadline(); ok {
if remaining := time.Until(deadline); remaining > 0 {
client.SetTimeout(remaining)
}
}
raw, err := client.Whois(string(domain))
if err != nil {
return nil, err
}
result, err := whoisparser.Parse(raw)
if err != nil {
if errors.Is(err, whoisparser.ErrNotFoundDomain) {
return nil, happydns.DomainDoesNotExist
}
return nil, err
}
registrar := "Unknown"
var registrar_url *string
if result.Registrar != nil {
registrar = result.Registrar.Name
registrar_url = &result.Registrar.ReferralURL
}
return &happydns.DomainInfo{
Name: result.Domain.Domain,
Nameservers: result.Domain.NameServers,
CreationDate: result.Domain.CreatedDateInTime,
ExpirationDate: result.Domain.ExpirationDateInTime,
Registrar: registrar,
RegistrarURL: registrar_url,
Status: result.Domain.Status,
}, nil
}

View file

@ -0,0 +1,75 @@
// 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 domaininfo_test
import (
"context"
"testing"
happydns "git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/pkg/domaininfo"
)
func TestGetDomainWhoisInfo_KnownDomain(t *testing.T) {
if testing.Short() {
t.Skip("skipping live WHOIS integration test")
}
info, err := domaininfo.GetDomainWhoisInfo(context.Background(), happydns.Origin("example.com"))
if err != nil {
t.Fatalf("unexpected error for example.com: %v", err)
}
if info == nil {
t.Fatal("expected non-nil DomainInfo")
}
if info.Name == "" {
t.Error("expected Name to be set")
}
if len(info.Nameservers) == 0 {
t.Error("expected at least one nameserver")
}
if info.ExpirationDate == nil {
t.Error("expected ExpirationDate to be set")
}
if info.CreationDate == nil {
t.Error("expected CreationDate to be set")
}
if info.Registrar == "" {
t.Error("expected Registrar to be set")
}
}
func TestGetDomainWhoisInfo_NilRegistrarURL(t *testing.T) {
if testing.Short() {
t.Skip("skipping live WHOIS integration test")
}
// example.com's registrar referral URL may be empty; the function should
// not panic and should return a valid DomainInfo regardless.
info, err := domaininfo.GetDomainWhoisInfo(context.Background(), happydns.Origin("example.com"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// RegistrarURL may be nil or non-nil depending on the WHOIS data; just
// confirm the function handled it without panicking.
_ = info.RegistrarURL
}

1
plugins/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.so

336
plugins/README.md Normal file
View file

@ -0,0 +1,336 @@
# Writing a happyDomain Plugin
happyDomain supports external **check plugins** — shared libraries (`.so` files) that run domain health checks and diagnostics. Plugins are loaded at runtime and integrate seamlessly into happyDomain's domain and service testing UI.
## Overview
A plugin is a Go shared library (`-buildmode=plugin`) that exports a single entry point: `NewCheckPlugin`. At startup, happyDomain scans its configured plugin directories, loads each `.so` file it finds, calls `NewCheckPlugin`, and registers the returned checker under the declared name.
A plugin implements the `Checker` interface from `git.happydns.org/happyDomain/model`:
```go
type Checker interface {
ID() string
Name() string
Availability() CheckerAvailability
Options() CheckerOptionsDocumentation
RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error)
}
```
---
## Project Structure
A minimal plugin lives in its own directory with `package main`:
```
myplugin/
├── go.mod
├── Makefile
└── plugin.go (or split across multiple .go files)
```
### go.mod
Your plugin must declare the same module path as its source tree and depend on the happyDomain model:
```
module git.happydns.org/happyDomain/plugins/myplugin
go 1.25
require git.happydns.org/happyDomain v0.0.0
replace git.happydns.org/happyDomain => ../../
```
The `replace` directive points to your local happyDomain checkout, ensuring the plugin is compiled against the exact same types.
> **Important:** A Go plugin and the host program must be built with the same Go toolchain version and the same versions of all shared dependencies. Any mismatch will cause a runtime load error.
---
## The Entry Point
Every plugin must export a `NewCheckPlugin` function with this exact signature:
```go
package main
import "git.happydns.org/happyDomain/model"
func NewCheckPlugin() (string, happydns.Checker, error) {
return "myplugin", &MyPlugin{}, nil
}
```
The first return value is the unique registration name for the checker. You can use the constructor to perform one-time initialisation (read config files, create HTTP clients, etc.) and return an error if the plugin cannot function.
---
## Implementing the Interface
### `ID() string`
Returns the unique string identifier for the checker. This name is used internally to look up the checker and to store its configuration. Use a short, lowercase, collision-resistant name:
```go
func (p *MyPlugin) ID() string {
return "myplugin"
}
```
The value returned here should match the name returned by `NewCheckPlugin`. If two checkers claim the same ID, the second one is silently ignored and a conflict is logged.
---
### `Name() string`
Returns a human-readable display name for the checker:
```go
func (p *MyPlugin) Name() string {
return "My Plugin"
}
```
---
### `Availability() CheckerAvailability`
Declares where the checker applies:
```go
func (p *MyPlugin) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToDomain: true,
ApplyToService: false,
LimitToProviders: []string{}, // empty = all providers
LimitToServices: []string{"abstract.MatrixIM"},
}
}
```
`CheckerAvailability` fields:
| Field | Type | Description |
|---|---|---|
| `ApplyToDomain` | `bool` | Checker can be run against a whole domain |
| `ApplyToService` | `bool` | Checker can be run against a specific service |
| `LimitToProviders` | `[]string` | Restrict to certain DNS provider identifiers (empty = no restriction) |
| `LimitToServices` | `[]string` | Restrict to certain service type identifiers, e.g. `"abstract.MatrixIM"` (empty = no restriction) |
---
### `Options() CheckerOptionsDocumentation`
Declares all configurable options, grouped by **who sets them** and **at which scope**:
```go
func (p *MyPlugin) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
RunOpts: []happydns.CheckerOptionDocumentation{ /* per-run options */ },
ServiceOpts: []happydns.CheckerOptionDocumentation{ /* per-service options */ },
DomainOpts: []happydns.CheckerOptionDocumentation{ /* per-domain options */ },
UserOpts: []happydns.CheckerOptionDocumentation{ /* per-user options */ },
AdminOpts: []happydns.CheckerOptionDocumentation{ /* admin-only options */ },
}
}
```
#### Option scopes
| Field | Who sets it | Typical use |
|---|---|---|
| `RunOpts` | The user at test time | Test-specific parameters (e.g. domain to test) |
| `ServiceOpts` | The user, per service | Configuration scoped to a DNS service |
| `DomainOpts` | The user, per domain | Configuration scoped to a whole domain |
| `UserOpts` | The user, globally | Personal preferences (e.g. language) |
| `AdminOpts` | The instance administrator | Backend URLs, API keys shared by all users |
Options from all scopes are **merged** before `RunCheck` is called, with more-specific scopes overriding less-specific ones.
#### CheckerOptionDocumentation fields
Each option is described by a `CheckerOptionDocumentation` (an alias for `Field`):
| Field | Type | Description |
|---|---|---|
| `Id` | `string` | **Required.** Key used in `CheckerOptions` map |
| `Type` | `string` | Input type: `"string"`, `"select"`, … |
| `Label` | `string` | Human-readable label shown in the UI |
| `Placeholder` | `string` | Input placeholder text |
| `Default` | `any` | Default value pre-filled in the form |
| `Choices` | `[]string` | Available choices for `"select"` type inputs |
| `Required` | `bool` | Whether the field must be filled before running |
| `Secret` | `bool` | Marks the field as sensitive (e.g. API key) |
| `Hide` | `bool` | Hides the field from the user |
| `Textarea` | `bool` | Displays a multiline text area |
| `Description` | `string` | Help text shown below the field |
| `AutoFill` | `string` | Automatically populate the field from context (see below) |
#### Auto-fill variables
When a field's `AutoFill` is set, happyDomain populates it from the test context — the user does not need to fill it in:
| Constant | Value | Filled with |
|---|---|---|
| `happydns.AutoFillDomainName` | `"domain_name"` | The FQDN of the domain under test (e.g. `"example.com."`) |
| `happydns.AutoFillSubdomain` | `"subdomain"` | Subdomain relative to the zone (service-scoped tests only) |
| `happydns.AutoFillServiceType` | `"service_type"` | Service type identifier (service-scoped tests only) |
```go
{
Id: "domainName",
Type: "string",
Label: "Domain name",
AutoFill: happydns.AutoFillDomainName,
Required: true,
},
```
---
### `RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error)`
This is where the actual check happens. `options` is the merged map of all scoped options (keyed by option `Id`). `meta` carries additional context provided by the scheduler (currently reserved for future use).
```go
func (p *MyPlugin) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
domain, ok := options["domainName"].(string)
if !ok || domain == "" {
return nil, fmt.Errorf("domainName is required")
}
// ... perform the check ...
return &happydns.CheckResult{
Status: happydns.CheckResultStatusOK,
StatusLine: "All good",
Report: myDetailedReport,
}, nil
}
```
Return a non-nil `error` for hard failures (network errors, invalid options). Return a `CheckResult` with a `KO` status for expected failures (e.g. the DNS check failed).
#### CheckResult fields set by the plugin
| Field | Type | Description |
|---|---|---|
| `Status` | `CheckResultStatus` | Overall result level |
| `StatusLine` | `string` | Short human-readable summary |
| `Report` | `any` | Arbitrary data (serialised to JSON and stored) |
The remaining fields (`Id`, `CheckerName`, `ExecutedAt`, etc.) are filled in by happyDomain automatically.
#### CheckResultStatus values (ordered worst → best)
| Constant | Meaning |
|---|---|
| `CheckResultStatusKO` | Check failed |
| `CheckResultStatusWarn` | Check passed with warnings |
| `CheckResultStatusInfo` | Informational result |
| `CheckResultStatusOK` | Check fully passed |
---
## Full Example
The matrix federation checker plugin (`matrix/`) illustrates a real-world plugin:
**`main.go`** — exports the entry point:
```go
package main
import "git.happydns.org/happyDomain/model"
func NewCheckPlugin() (string, happydns.Checker, error) {
return "matrixim", &MatrixTester{
TesterURI: "https://federationtester.matrix.org/api/report?server_name=%s",
}, nil
}
```
**`test.go`** — implements the interface on a struct:
```go
func (p *MatrixTester) ID() string { return "matrixim" }
func (p *MatrixTester) Name() string { return "Matrix Federation Tester" }
func (p *MatrixTester) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.MatrixIM"},
}
}
func (p *MatrixTester) Options() happydns.CheckerOptionsDocumentation { /* ... */ }
func (p *MatrixTester) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) { /* ... */ }
```
The built-in Zonemaster checker (`checks/zonemaster.go`) shows a more complex flow: it starts an asynchronous test, polls for completion, and aggregates results across multiple severity levels. Although it is compiled in rather than loaded as a `.so`, it implements the same `Checker` interface and is a useful reference.
---
## Building
Use `-buildmode=plugin`:
```bash
go build -buildmode=plugin -o happydomain-plugin-test-myplugin.so \
git.happydns.org/happyDomain/plugins/myplugin
```
A minimal `Makefile`:
```makefile
PLUGIN_NAME=myplugin
TARGET=../happydomain-plugin-test-$(PLUGIN_NAME).so
all: $(TARGET)
$(TARGET): *.go
go build -buildmode=plugin -o $@ git.happydns.org/happyDomain/plugins/$(PLUGIN_NAME)
```
> **Naming convention:** happyDomain looks for any `.so` file in the plugin directory, but using the prefix `happydomain-plugin-test-` makes the purpose clear.
---
## Deployment
### 1. Copy the `.so` file to a plugin directory
```bash
cp happydomain-plugin-test-myplugin.so /usr/lib/happydomain/plugins/
```
### 2. Configure happyDomain to load that directory
In your `happydomain.conf`:
```
plugins-directories=/usr/lib/happydomain/plugins
```
Or via an environment variable:
```bash
HAPPYDOMAIN_PLUGINS_DIRECTORIES=/usr/lib/happydomain/plugins
```
Multiple directories can be specified as a comma-separated list. happyDomain scans each directory at startup and attempts to load every `.so` file it finds. Loading errors are logged but do not prevent the server from starting.
### 3. Verify
Check the server logs at startup for a line like:
```
Plugin myplugin loaded
```
If a name conflict or load error occurs, a warning is logged with the filename and reason.

7
plugins/matrix/Makefile Normal file
View file

@ -0,0 +1,7 @@
PLUGIN_NAME=matrix
TARGET=../happydomain-plugin-test-$(PLUGIN_NAME).so
all: $(TARGET)
$(TARGET): *.go
go build -buildmode=plugin -o $@ git.happydns.org/happyDomain/plugins/$(PLUGIN_NAME)

11
plugins/matrix/main.go Normal file
View file

@ -0,0 +1,11 @@
package main
import (
"git.happydns.org/happyDomain/model"
)
func NewCheckPlugin() (string, happydns.Checker, error) {
return "matrixim", &MatrixTester{
TesterURI: "https://federationtester.matrix.org/api/report?server_name=%s",
}, nil
}

552
plugins/matrix/test.go Normal file
View file

@ -0,0 +1,552 @@
package main
import (
"context"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"strings"
"git.happydns.org/happyDomain/model"
)
type MatrixTester struct {
TesterURI string
}
func (p *MatrixTester) ID() string {
return "matrixim"
}
func (p *MatrixTester) Name() string {
return "Matrix Federation Tester"
}
func (p *MatrixTester) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.MatrixIM"},
}
}
func (p *MatrixTester) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
RunOpts: []happydns.CheckerOptionDocumentation{
{
Id: "serviceDomain",
Type: "string",
Label: "Matrix domain",
Placeholder: "matrix.org",
Default: "matrix.org",
AutoFill: happydns.AutoFillDomainName,
Required: true,
},
},
AdminOpts: []happydns.CheckerOptionDocumentation{
{
Id: "federationTesterServer",
Type: "string",
Label: "Federation Tester Server",
Placeholder: "https://federationtester.matrix.org/",
Default: "https://federationtester.matrix.org/",
Required: true,
},
},
}
}
type FederationTesterResponse struct {
WellKnownResult struct {
Server string `json:"m.server"`
Result string `json:"result"`
CacheExpiresAt int64 `json:"CacheExpiresAt"`
}
DNSResult struct {
SRVSkipped bool `json:"SRVSkipped"`
SRVCName string `json:"SRVCName"`
SRVRecords []struct {
Target string `json:"Target"`
Port uint16 `json:"Port"`
Priority uint16 `json:"Priority"`
Weight uint16 `json:"Weight"`
} `json:"SRVRecords"`
SRVError *struct {
Message string `json:"Message"`
} `json:"SRVError"`
Hosts map[string]struct {
CName string `json:"CName"`
Addrs []string `json:"Addrs"`
} `json:"Hosts"`
Addrs []string `json:"Addrs"`
}
ConnectionReports map[string]struct {
Certificates []struct {
SubjectCommonName string `json:"SubjectCommonName"`
IssuerCommonName string `json:"IssuerCommonName"`
SHA256Fingerprint string `json:"SHA256Fingerprint"`
DNSNames []string `json:"DNSNames"`
}
Cipher struct {
Version string `json:"Version"`
CipherSuite string `json:"CipherSuite"`
}
Checks struct {
AllChecksOK bool `json:"AllChecksOK"`
MatchingServerName bool `json:"MatchingServerName"`
FutureValidUntilTS bool `json:"FutureValidUntilTS"`
HasEd25519Key bool `json:"HasEd25519Key"`
AllEd25519ChecksOK bool `json:"AllEd25519ChecksOK"`
ValidCertificates bool `json:"ValidCertificates"`
}
Errors []string
}
ConnectionErrors map[string]struct {
Message string
}
Version struct {
Name string `json:"name"`
Version string `json:"version"`
Error string `json:"error,omitempty"`
}
FederationOK bool `json:"FederationOK"`
}
func (p *MatrixTester) RunCheck(ctx context.Context, options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
var domain string
if dn, ok := options["serviceDomain"]; ok {
domain, _ = dn.(string)
}
if domain == "" {
return nil, fmt.Errorf("domain not defined")
}
domain = strings.TrimSuffix(domain, ".")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(p.TesterURI, domain), nil)
if err != nil {
return nil, fmt.Errorf("unable to build the request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("unable to perform the test: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("Sorry, the federation tester is broken. Check on https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, "."))
}
var status happydns.CheckResultStatus
var statusLine string
var federationTest FederationTesterResponse
err = json.NewDecoder(resp.Body).Decode(&federationTest)
if err != nil {
log.Printf("Error in check_matrix_federation, when decoding json: %s", err.Error())
return nil, fmt.Errorf("sorry, the federation tester is broken. Check on https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, "."))
}
if federationTest.FederationOK {
status = happydns.CheckResultStatusOK
statusLine = "Running " + federationTest.Version.Name + " " + federationTest.Version.Version
} else {
status = happydns.CheckResultStatusCritical
if federationTest.DNSResult.SRVError != nil && federationTest.WellKnownResult.Result != "" {
statusLine = fmt.Sprintf("%s OR %s", federationTest.DNSResult.SRVError.Message, federationTest.WellKnownResult.Result)
} else if len(federationTest.ConnectionErrors) > 0 {
var msg strings.Builder
for srv, cerr := range federationTest.ConnectionErrors {
if msg.Len() > 0 {
msg.WriteString("; ")
}
msg.WriteString(srv)
msg.WriteString(": ")
msg.WriteString(cerr.Message)
}
statusLine = fmt.Sprintf("Connection errors: %s", msg.String())
} else if federationTest.WellKnownResult.Server != strings.TrimSuffix(domain, ".") {
statusLine = fmt.Sprintf("Bad homeserver_name: got %s, expected %s.", federationTest.WellKnownResult.Server, strings.TrimSuffix(domain, "."))
} else {
statusLine = fmt.Sprintf("An unimplemented error occurs. Please report this to happydomain team. But know that federation seems to be broken. Check https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, "."))
}
}
return &happydns.CheckResult{
Status: status,
StatusLine: statusLine,
Report: federationTest,
}, nil
}
// ── HTML report ───────────────────────────────────────────────────────────────
type matrixCertData struct {
SubjectCommonName string
IssuerCommonName string
SHA256Fingerprint string
DNSNames []string
}
type matrixConnectionData struct {
Address string
TLSVersion string
CipherSuite string
Certs []matrixCertData
AllChecksOK bool
CheckDetails []matrixCheckItem
Errors []string
Open bool // details element open when checks failed
}
type matrixCheckItem struct {
Label string
OK bool
}
type matrixConnErrData struct {
Address string
Message string
}
type matrixSRVRecord struct {
Target string
Port uint16
Priority uint16
Weight uint16
}
type matrixHostData struct {
Name string
CName string
Addrs []string
}
type matrixTemplateData struct {
FederationOK bool
Version string
VersionError string
WellKnownServer string
WellKnownResult string
SRVSkipped bool
SRVCName string
SRVRecords []matrixSRVRecord
SRVError string
Hosts []matrixHostData
Addrs []string
Connections []matrixConnectionData
ConnectionErrors []matrixConnErrData
}
var matrixHTMLTemplate = template.Must(
template.New("matrix").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Matrix Federation Report</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd {
background: #fff;
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em;
border-radius: 9999px;
font-size: .78rem; font-weight: 700;
letter-spacing: .02em;
}
.ok { background: #d1fae5; color: #065f46; }
.fail { background: #fee2e2; color: #991b1b; }
.version { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
.section {
background: #fff;
border-radius: 8px;
padding: .85rem 1rem;
margin-bottom: .6rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
}
details {
background: #fff;
border-radius: 8px;
margin-bottom: .45rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
overflow: hidden;
}
.section details {
box-shadow: none;
border-radius: 6px;
border: 1px solid #e5e7eb;
margin-bottom: .4rem;
}
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem;
cursor: pointer;
user-select: none;
list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before {
content: "▶";
font-size: .65rem;
color: #9ca3af;
transition: transform .15s;
flex-shrink: 0;
}
details[open] > summary::before { transform: rotate(90deg); }
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; }
th { font-weight: 600; color: #6b7280; }
.check-ok { color: #059669; }
.check-fail { color: #dc2626; }
.errmsg { color: #dc2626; font-size: .85rem; margin: .25rem 0 0; }
.note { color: #6b7280; font-size: .85rem; }
ul { margin: .25rem 0; padding-left: 1.2rem; }
li { margin-bottom: .15rem; }
</style>
</head>
<body>
<div class="hd">
<h1>Matrix Federation</h1>
{{if .FederationOK}}
<span class="badge ok">Federation OK</span>
{{- else}}
<span class="badge fail">Federation FAIL</span>
{{- end}}
{{if .Version}}<div class="version">Server: <code>{{.Version}}</code>{{if .VersionError}} &mdash; {{.VersionError}}{{end}}</div>{{end}}
</div>
{{if .Connections}}
<div class="section">
<h2>Connections ({{len .Connections}})</h2>
{{range .Connections}}
<details{{if .Open}} open{{end}}>
<summary>
<span class="conn-addr">{{.Address}}</span>
{{if .AllChecksOK}}<span class="badge ok">All checks OK</span>{{else}}<span class="badge fail">Checks failed</span>{{end}}
</summary>
<div class="details-body">
{{if or .TLSVersion .CipherSuite}}
<h3>TLS</h3>
<p class="note">{{.TLSVersion}}{{if and .TLSVersion .CipherSuite}} &mdash; {{end}}{{.CipherSuite}}</p>
{{end}}
{{if .Certs}}
<h3>Certificates</h3>
<table>
<tr><th>Subject</th><th>Issuer</th><th>DNS Names</th><th>Fingerprint (SHA-256)</th></tr>
{{range .Certs}}
<tr>
<td><code>{{.SubjectCommonName}}</code></td>
<td><code>{{.IssuerCommonName}}</code></td>
<td>{{range .DNSNames}}<code>{{.}}</code> {{end}}</td>
<td><code>{{.SHA256Fingerprint}}</code></td>
</tr>
{{end}}
</table>
{{end}}
{{if .CheckDetails}}
<h3 style="margin-top:.7rem">Checks</h3>
<table>
{{range .CheckDetails}}
<tr>
<td>{{if .OK}}<span class="check-ok">&#10003;</span>{{else}}<span class="check-fail">&#10007;</span>{{end}}</td>
<td>{{.Label}}</td>
</tr>
{{end}}
</table>
{{end}}
{{range .Errors}}<p class="errmsg">&#9888; {{.}}</p>{{end}}
</div>
</details>
{{end}}
</div>
{{end}}
{{if .ConnectionErrors}}
<div class="section">
<h2>Connection Errors ({{len .ConnectionErrors}})</h2>
{{range .ConnectionErrors}}
<p><code>{{.Address}}</code><br><span class="errmsg">{{.Message}}</span></p>
{{end}}
</div>
{{end}}
<div class="section">
<h2>Well-Known</h2>
{{if .WellKnownServer}}
<p>Server: <code>{{.WellKnownServer}}</code></p>
{{else if .WellKnownResult}}
<p class="note">{{.WellKnownResult}}</p>
{{else}}
<p class="note">Not found.</p>
{{end}}
</div>
<div class="section">
<h2>DNS Resolution</h2>
{{if .SRVSkipped}}
<p class="note">SRV lookup skipped{{if .SRVCName}} (CNAME: <code>{{.SRVCName}}</code>){{end}}</p>
{{else if .SRVError}}
<p class="errmsg">SRV error: {{.SRVError}}</p>
{{else if .SRVRecords}}
<h3>SRV Records</h3>
<table>
<tr><th>Target</th><th>Port</th><th>Priority</th><th>Weight</th></tr>
{{range .SRVRecords}}
<tr>
<td><code>{{.Target}}</code></td>
<td>{{.Port}}</td>
<td>{{.Priority}}</td>
<td>{{.Weight}}</td>
</tr>
{{end}}
</table>
{{else}}
<p class="note">No SRV records found.</p>
{{end}}
{{if .Hosts}}
<h3 style="margin-top:.6rem">Resolved Hosts</h3>
{{range .Hosts}}
<p style="margin:.25rem 0">
<code>{{.Name}}</code>
{{if .CName}} &rarr; <code>{{.CName}}</code>{{end}}
{{if .Addrs}}: {{range .Addrs}}<code>{{.}}</code> {{end}}{{end}}
</p>
{{end}}
{{else if .Addrs}}
<h3 style="margin-top:.6rem">Addresses</h3>
<ul>{{range .Addrs}}<li><code>{{.}}</code></li>{{end}}</ul>
{{end}}
</div>
</body>
</html>`),
)
// GetHTMLReport implements happydns.CheckerHTMLReporter.
func (p *MatrixTester) GetHTMLReport(raw json.RawMessage) (string, error) {
var r FederationTesterResponse
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("failed to unmarshal matrix report: %w", err)
}
data := matrixTemplateData{
FederationOK: r.FederationOK,
WellKnownServer: r.WellKnownResult.Server,
WellKnownResult: r.WellKnownResult.Result,
SRVSkipped: r.DNSResult.SRVSkipped,
SRVCName: r.DNSResult.SRVCName,
Addrs: r.DNSResult.Addrs,
}
// Version
if r.Version.Name != "" || r.Version.Version != "" {
data.Version = strings.TrimSpace(r.Version.Name + " " + r.Version.Version)
}
data.VersionError = r.Version.Error
// SRV records
for _, s := range r.DNSResult.SRVRecords {
data.SRVRecords = append(data.SRVRecords, matrixSRVRecord{
Target: s.Target,
Port: s.Port,
Priority: s.Priority,
Weight: s.Weight,
})
}
// SRV error
if r.DNSResult.SRVError != nil {
data.SRVError = r.DNSResult.SRVError.Message
}
// Hosts
for name, h := range r.DNSResult.Hosts {
data.Hosts = append(data.Hosts, matrixHostData{
Name: name,
CName: h.CName,
Addrs: h.Addrs,
})
}
// Successful connections
for addr, cr := range r.ConnectionReports {
conn := matrixConnectionData{
Address: addr,
TLSVersion: cr.Cipher.Version,
CipherSuite: cr.Cipher.CipherSuite,
AllChecksOK: cr.Checks.AllChecksOK,
Errors: cr.Errors,
Open: !cr.Checks.AllChecksOK,
}
for _, cert := range cr.Certificates {
conn.Certs = append(conn.Certs, matrixCertData{
SubjectCommonName: cert.SubjectCommonName,
IssuerCommonName: cert.IssuerCommonName,
SHA256Fingerprint: cert.SHA256Fingerprint,
DNSNames: cert.DNSNames,
})
}
conn.CheckDetails = []matrixCheckItem{
{"Matching server name", cr.Checks.MatchingServerName},
{"Certificate valid until future", cr.Checks.FutureValidUntilTS},
{"Valid certificates", cr.Checks.ValidCertificates},
{"Has Ed25519 key", cr.Checks.HasEd25519Key},
{"All Ed25519 checks OK", cr.Checks.AllEd25519ChecksOK},
}
data.Connections = append(data.Connections, conn)
}
// Failed connections
for addr, ce := range r.ConnectionErrors {
data.ConnectionErrors = append(data.ConnectionErrors, matrixConnErrData{
Address: addr,
Message: ce.Message,
})
}
var buf strings.Builder
if err := matrixHTMLTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to render matrix HTML report: %w", err)
}
return buf.String(), nil
}

View file

@ -0,0 +1,73 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build ignore
// +build ignore
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services"
)
func main() {
output := flag.String("o", "", "output file path")
flag.Parse()
if *output == "" {
fmt.Fprintf(os.Stderr, "Error: output file path is required\n")
fmt.Fprintf(os.Stderr, "Usage: %s -o <output-file>\n", os.Args[0])
os.Exit(1)
}
fd, err := os.Create(*output)
if err != nil {
panic(err)
}
defer fd.Close()
// Collect ServiceSpecs
services := svcs.ListServices()
sspecs := map[string]happydns.ServiceInfos{}
for k, service := range *services {
sspecs[k] = service.Infos
}
genspecs, err := json.MarshalIndent(sspecs, "", " ")
if err != nil {
panic(err)
}
fmt.Fprint(fd, "// This file is generated by go generate\n\n")
fmt.Fprintln(fd, `import type { ServiceInfos } from "$lib/model/service_specs.svelte";`)
fmt.Fprintf(fd, "export const servicesSpecs: Record<string, ServiceInfos> = %s\n", string(genspecs))
fmt.Printf("Generated %s with %d Services specifications\n", *output, len(sspecs))
}

View file

@ -109,6 +109,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine) {
// Routes to virtual content
router.GET("/auth_users/*_", serveOrReverse("/", cfg))
router.GET("/checkers/*_", serveOrReverse("/", cfg))
router.GET("/domains/*_", serveOrReverse("/", cfg))
router.GET("/providers/*_", serveOrReverse("/", cfg))
router.GET("/sessions/*_", serveOrReverse("/", cfg))

View file

@ -101,6 +101,12 @@
<NavItem>
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
</NavItem>
<NavItem>
<NavLink href="/checkers" active={page && page.url.pathname.startsWith('/checkers')}>Checkers</NavLink>
</NavItem>
<NavItem>
<NavLink href="/scheduler" active={page && page.url.pathname.startsWith('/scheduler')}>Scheduler</NavLink>
</NavItem>
</Nav>
</Collapse>
</Navbar>

View file

@ -0,0 +1,153 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import {
Card,
Col,
Container,
Icon,
Input,
InputGroup,
InputGroupText,
Table,
Row,
Badge,
} from "@sveltestrap/sveltestrap";
import { getChecks } from "$lib/api-admin";
let checkersQ = $state(getChecks());
let searchQuery = $state("");
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col md={8}>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
Checkers
</h1>
<p class="d-flex gap-3 align-items-center text-muted">
<span class="lead"> Manage all checkers </span>
{#await checkersQ then checkersR}
<span>Total: {Object.keys(checkersR.data ?? {}).length} checkers</span>
{/await}
</p>
</Col>
</Row>
<Row class="mb-4">
<Col md={8} lg={6}>
<InputGroup>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
<Input type="text" placeholder="Search checker..." bind:value={searchQuery} />
</InputGroup>
</Col>
</Row>
{#await checkersQ}
Please wait...
{:then checkersR}
{@const checkers = checkersR.data}
<div class="table-responsive">
<Table hover bordered>
<thead>
<tr>
<th>Plugin Name</th>
<th>Availability</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#if !checkers || Object.keys(checkers).length == 0}
<tr>
<td colspan="4" class="text-center text-muted py-2">
No checkers available
</td>
</tr>
{:else}
{#each Object.entries(checkers ?? {}).filter(([name, _info]) => name
.toLowerCase()
.indexOf(searchQuery.toLowerCase()) > -1) as [checkerName, checkerInfo]}
<tr>
<td><strong>{checkerInfo.name || checkerName}</strong></td>
<td>
{#if checkerInfo.availability}
{#if checkerInfo.availability.applyToDomain}
<Badge color="success">Domain</Badge>
{/if}
{#if checkerInfo.availability.applyToZone}
<Badge color="success">Zone</Badge>
{/if}
{#if checkerInfo.availability.limitToProviders && checkerInfo.availability.limitToProviders.length > 0}
<Badge
color="primary"
title={checkerInfo.availability.limitToProviders.join(
", ",
)}
>
Provider-specific
</Badge>
{/if}
{#if checkerInfo.availability.limitToServices && checkerInfo.availability.limitToServices.length > 0}
<Badge
color="info"
title={checkerInfo.availability.limitToServices.join(
", ",
)}
>
Service-specific
</Badge>
{/if}
{:else}
<Badge color="secondary">General</Badge>
{/if}
</td>
<td>
<a
href="/checkers/{checkerName}"
class="btn btn-sm btn-primary"
>
<Icon name="gear-fill"></Icon>
Manage
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading checkers: {error.message}
</p>
</Card>
{/await}
</Container>

View file

@ -0,0 +1,325 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import {
Alert,
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Container,
Form,
FormGroup,
Icon,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from "$app/state";
import { toasts } from "$lib/stores/toasts";
import { getChecksByCnameOptions, putChecksByCnameOptions } from "$lib/api-admin";
import { getCheckStatus } from "$lib/api/checkers";
import Resource from "$lib/components/inputs/Resource.svelte";
import CheckerOptionsGroups from "$lib/components/checkers/CheckerOptionsGroups.svelte";
let cname = $derived(page.params.cname!);
let checkerStatusQ = $derived(getCheckStatus(cname));
let checkerOptionsQ = $derived(getChecksByCnameOptions({ path: { cname } }));
let optionValues = $state<Record<string, any>>({});
let saving = $state(false);
$effect(() => {
checkerOptionsQ.then((optionsR) => {
optionValues = { ...((optionsR.data as Record<string, unknown>) || {}) };
});
});
async function saveOptions() {
saving = true;
try {
await putChecksByCnameOptions({
path: { cname },
body: { options: optionValues },
});
checkerOptionsQ = getChecksByCnameOptions({ path: { cname } });
toasts.addToast({
message: `Plugin options updated successfully`,
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: "Failed to update options: " + error,
timeout: 10000,
});
} finally {
saving = false;
}
}
async function cleanOrphanedOptions(adminOpts: any[]) {
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
const cleanedOptions: Record<string, any> = {};
for (const [key, value] of Object.entries(optionValues)) {
if (validOptIds.has(key)) {
cleanedOptions[key] = value;
}
}
saving = true;
try {
await putChecksByCnameOptions({
path: { cname },
body: { options: cleanedOptions },
});
checkerOptionsQ = getChecksByCnameOptions({ path: { cname } });
toasts.addToast({
message: `Orphaned options removed successfully`,
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: "Failed to clean options: " + error,
timeout: 10000,
});
} finally {
saving = false;
}
}
function getOrphanedOptions(adminOpts: any[]): string[] {
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
}
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<Button color="link" href="/checks" class="mb-2">
<Icon name="arrow-left"></Icon>
Back to checkers
</Button>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
{cname}
</h1>
</Col>
</Row>
{#await checkerStatusQ}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
Loading checker status...
</p>
</Card>
{:then status}
{#if status}
<Row class="mb-4">
<Col md={6}>
<Card>
<CardHeader>
<strong>Checker Information</strong>
</CardHeader>
<CardBody>
<dl class="row mb-0">
<dt class="col-sm-4">Name:</dt>
<dd class="col-sm-8">{status.name}</dd>
<dt class="col-sm-4">Availability:</dt>
<dd class="col-sm-8">
{#if status.availableOn}
<div class="d-flex flex-wrap gap-1">
{#if status.availableOn.applyToDomain}
<Badge color="success">Domain-level</Badge>
{/if}
{#if status.availableOn.applyToZone}
<Badge color="success">Zone-level</Badge>
{/if}
{#if status.availableOn.limitToProviders && status.availableOn.limitToProviders.length > 0}
<Badge color="primary">
Providers: {status.availableOn.limitToProviders.join(
", ",
)}
</Badge>
{/if}
{#if status.availableOn.limitToServices && status.availableOn.limitToServices.length > 0}
<Badge color="info">
Services: {status.availableOn.limitToServices.join(
", ",
)}
</Badge>
{/if}
{#if !status.availableOn.applyToDomain && !status.availableOn.applyToZone && (!status.availableOn.limitToProviders || status.availableOn.limitToProviders.length === 0) && (!status.availableOn.limitToServices || status.availableOn.limitToServices.length === 0)}
<Badge color="secondary">General</Badge>
{/if}
</div>
{:else}
<Badge color="secondary">General</Badge>
{/if}
</dd>
</dl>
</CardBody>
</Card>
</Col>
<Col md={6}>
{#await checkerOptionsQ}
<Card>
<CardBody>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
Loading options...
</p>
</CardBody>
</Card>
{:then _optionsR}
{@const adminOpts = status.options?.adminOpts || []}
{@const readOnlyOptGroups = [
{
key: "userOpts",
label: "User Options",
opts: status.options?.userOpts || [],
},
{
key: "domainOpts",
label: "Domain Options",
opts: status.options?.domainOpts || [],
},
{
key: "serviceOpts",
label: "Service Options",
opts: status.options?.serviceOpts || [],
},
{
key: "runOpts",
label: "Run Options",
opts: status.options?.runOpts || [],
},
]}
{@const hasAnyOpts =
adminOpts.length > 0 ||
readOnlyOptGroups.some((g) => g.opts.length > 0)}
{@const orphanedOpts = getOrphanedOptions(adminOpts)}
{#if orphanedOpts.length > 0}
<Alert color="warning" class="mb-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<Icon name="exclamation-triangle-fill"></Icon>
<strong>Orphaned options detected:</strong>
{orphanedOpts.join(", ")}
</div>
<Button
color="danger"
size="sm"
onclick={() => cleanOrphanedOptions(adminOpts)}
disabled={saving}
>
<Icon name="trash"></Icon>
Clean Up
</Button>
</div>
</Alert>
{/if}
{#if adminOpts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>Admin Options</strong>
</CardHeader>
<CardBody>
<Form on:submit={saveOptions}>
{#each adminOpts as optDoc}
{#if optDoc.id}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={optionValues[optName]}
/>
</FormGroup>
{/if}
{/each}
<div class="d-flex gap-2">
<Button type="submit" color="success" disabled={saving}>
{#if saving}
<span
class="spinner-border spinner-border-sm me-1"
></span>
{/if}
<Icon name="check-circle"></Icon>
Save Changes
</Button>
</div>
</Form>
</CardBody>
</Card>
{/if}
<CheckerOptionsGroups groups={readOnlyOptGroups} />
{#if !hasAnyOpts}
<Card>
<CardBody>
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
This checker has no configurable options.
</Alert>
</CardBody>
</Card>
{/if}
{:catch error}
<Card>
<CardBody>
<Alert color="danger" class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading options: {error.message}
</Alert>
</CardBody>
</Card>
{/await}
</Col>
</Row>
{:else}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
Error: checker data not found
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading checker: {error.message}
</Alert>
{/await}
</Container>

View file

@ -0,0 +1,341 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import { onMount } from "svelte";
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Container,
Icon,
Row,
Spinner,
Table,
} from "@sveltestrap/sveltestrap";
import { toasts } from "$lib/stores/toasts";
import {
getScheduler,
postSchedulerDisable,
postSchedulerEnable,
postSchedulerRescheduleUpcoming,
} from "$lib/api-admin/sdk.gen";
interface CheckerSchedule {
id: string;
checker_name: string;
owner_id: string;
target_type: number;
target_id: string;
interval: number;
enabled: boolean;
last_run?: string;
next_run: string;
}
interface SchedulerStatus {
config_enabled: boolean;
runtime_enabled: boolean;
running: boolean;
worker_count: number;
queue_size: number;
active_count: number;
next_schedules: CheckerSchedule[] | null;
}
let status = $state<SchedulerStatus | null>(null);
let loading = $state(true);
let actionInProgress = $state(false);
let rescheduleInProgress = $state(false);
let error = $state<string | null>(null);
async function fetchStatus() {
loading = true;
error = null;
try {
const { data, error: err } = await getScheduler();
if (err) throw new Error(String(err));
status = data as SchedulerStatus;
} catch (e: any) {
error = e.message ?? "Unknown error";
} finally {
loading = false;
}
}
async function setEnabled(enabled: boolean) {
actionInProgress = true;
const action = enabled ? "enable" : "disable";
try {
const { data, error: err } = await (enabled
? postSchedulerEnable()
: postSchedulerDisable());
if (err) {
toasts.addErrorToast({ message: `Failed to ${action} scheduler: ${err}` });
return;
}
status = data as SchedulerStatus;
toasts.addToast({ message: `Scheduler ${action}d successfully`, color: "success" });
} catch (e: any) {
toasts.addErrorToast({ message: e.message ?? `Failed to ${action} scheduler` });
} finally {
actionInProgress = false;
}
}
async function rescheduleUpcoming() {
rescheduleInProgress = true;
try {
const { data, error: err } = await postSchedulerRescheduleUpcoming();
if (err) {
toasts.addErrorToast({ message: `Failed to reschedule: ${err}` });
return;
}
toasts.addToast({
message: `Rescheduled ${(data as any).rescheduled} schedule(s) successfully`,
color: "success",
});
await fetchStatus();
} catch (e: any) {
toasts.addErrorToast({ message: e.message ?? "Failed to reschedule upcoming checks" });
} finally {
rescheduleInProgress = false;
}
}
function formatDuration(ns: number): string {
const seconds = ns / 1e9;
if (seconds < 60) return `${Math.round(seconds)}s`;
const minutes = seconds / 60;
if (minutes < 60) return `${Math.round(minutes)}m`;
const hours = minutes / 60;
if (hours < 24) return `${Math.round(hours)}h`;
return `${Math.round(hours / 24)}d`;
}
function targetTypeName(t: number): string {
const names: Record<number, string> = {
0: "instance",
1: "user",
2: "domain",
3: "zone",
4: "service",
5: "ondemand",
};
return names[t] ?? "unknown";
}
onMount(fetchStatus);
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<h1 class="display-5">
<Icon name="clock-history"></Icon>
Test Scheduler
</h1>
<p class="text-muted lead">Monitor and control the background test scheduler</p>
</Col>
</Row>
{#if loading}
<div class="d-flex align-items-center gap-2">
<Spinner size="sm" />
<span>Loading scheduler status...</span>
</div>
{:else if error}
<Card color="danger" body>
<Icon name="exclamation-triangle-fill"></Icon>
Error loading scheduler status: {error}
<Button class="ms-3" size="sm" color="light" onclick={fetchStatus}>Retry</Button>
</Card>
{:else if status}
<!-- Status Card -->
<Card class="mb-4">
<CardHeader>
<div class="d-flex justify-content-between align-items-center">
<span><Icon name="info-circle-fill"></Icon> Scheduler Status</span>
<Button size="sm" color="secondary" outline onclick={fetchStatus}>
<Icon name="arrow-clockwise"></Icon> Refresh
</Button>
</div>
</CardHeader>
<CardBody>
<Row class="g-3 mb-3">
<Col sm={6} md={4}>
<div class="text-muted small">Config Enabled</div>
{#if status.config_enabled}
<Badge color="success">Yes</Badge>
{:else}
<Badge color="danger">No</Badge>
{/if}
</Col>
<Col sm={6} md={4}>
<div class="text-muted small">Runtime Enabled</div>
{#if status.runtime_enabled}
<Badge color="success">Yes</Badge>
{:else}
<Badge color="warning">Disabled</Badge>
{/if}
</Col>
<Col sm={6} md={4}>
<div class="text-muted small">Running</div>
{#if status.running}
<Badge color="success"><Icon name="play-fill"></Icon> Running</Badge>
{:else}
<Badge color="secondary"><Icon name="stop-fill"></Icon> Stopped</Badge>
{/if}
</Col>
<Col sm={6} md={4}>
<div class="text-muted small">Workers</div>
<strong>{status.worker_count}</strong>
</Col>
<Col sm={6} md={4}>
<div class="text-muted small">Queue Size</div>
<strong>{status.queue_size}</strong>
</Col>
<Col sm={6} md={4}>
<div class="text-muted small">Active Executions</div>
<strong>{status.active_count}</strong>
</Col>
</Row>
{#if status.config_enabled}
<div class="d-flex gap-2">
{#if status.runtime_enabled}
<Button
color="warning"
disabled={actionInProgress}
onclick={() => setEnabled(false)}
>
{#if actionInProgress}<Spinner size="sm" />{:else}<Icon
name="pause-fill"
></Icon>{/if}
Disable Scheduler
</Button>
{:else}
<Button
color="success"
disabled={actionInProgress}
onclick={() => setEnabled(true)}
>
{#if actionInProgress}<Spinner size="sm" />{:else}<Icon
name="play-fill"
></Icon>{/if}
Enable Scheduler
</Button>
{/if}
<Button
color="secondary"
outline
disabled={rescheduleInProgress}
onclick={rescheduleUpcoming}
>
{#if rescheduleInProgress}<Spinner size="sm" />{:else}<Icon
name="shuffle"
></Icon>{/if}
Spread Upcoming Checks
</Button>
</div>
{:else}
<p class="text-muted mb-0">
<Icon name="lock-fill"></Icon>
The scheduler is disabled in the server configuration and cannot be enabled at
runtime.
</p>
{/if}
</CardBody>
</Card>
<!-- Upcoming Scheduled Checks -->
<Card>
<CardHeader>
<Icon name="calendar-event-fill"></Icon>
Upcoming Scheduled Checks
{#if status.next_schedules}
<Badge color="secondary" class="ms-2">{status.next_schedules.length}</Badge>
{/if}
</CardHeader>
<CardBody class="p-0">
<div class="table-responsive">
<Table hover class="mb-0">
<thead>
<tr>
<th>Plugin</th>
<th>Target Type</th>
<th>Target ID</th>
<th>Interval</th>
<th>Last Run</th>
<th>Next Run</th>
</tr>
</thead>
<tbody>
{#if !status.next_schedules || status.next_schedules.length === 0}
<tr>
<td colspan="6" class="text-center text-muted py-3">
No scheduled checks
</td>
</tr>
{:else}
{#each status.next_schedules as schedule}
<tr>
<td><strong>{schedule.checker_name}</strong></td>
<td
><Badge color="info"
>{targetTypeName(schedule.target_type)}</Badge
></td
>
<td><code class="small">{schedule.target_id}</code></td>
<td>{formatDuration(schedule.interval)}</td>
<td>
{#if schedule.last_run}
{new Date(schedule.last_run).toLocaleString()}
{:else}
<span class="text-muted">Never</span>
{/if}
</td>
<td>
{#if new Date(schedule.next_run) < new Date()}
<span class="text-danger">
<Icon name="exclamation-circle-fill"></Icon>
{new Date(schedule.next_run).toLocaleString()}
</span>
{:else}
{new Date(schedule.next_run).toLocaleString()}
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
</CardBody>
</Card>
{/if}
</Container>

43
web/package-lock.json generated
View file

@ -13,6 +13,9 @@
"@sveltestrap/sveltestrap": "^7.0.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"html-escaper": "^3.0.0",
"sass": "^1.97.0",
@ -864,6 +867,12 @@
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT"
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
@ -2374,6 +2383,29 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chartjs-adapter-date-fns": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=2.8.0",
"date-fns": ">=2.0.0"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@ -2512,6 +2544,17 @@
"node": ">=4"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View file

@ -45,6 +45,9 @@
"@sveltestrap/sveltestrap": "^7.0.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"html-escaper": "^3.0.0",
"sass": "^1.97.0",

View file

@ -131,6 +131,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, captchaVerifi
router.GET("/service-worker.js", serveFile)
// Routes to virtual content
router.GET("/checkers/*_", serveIndex)
router.GET("/domains/*_", serveIndex)
router.GET("/email-validation", serveIndex)
router.GET("/forgotten-password", serveIndex)
@ -291,7 +292,7 @@ func serveOrReverse(forced_url string, cfg *happydns.Options) gin.HandlerFunc {
c.String(http.StatusInternalServerError, "failed to read manifest.json")
return
}
v2 := strings.Replace(strings.Replace(string(v), "\"id\": \"/\"", "\"id\": \""+cfg.BasePath+"\"", 1), "\"start_url\": \"/\"", "\"start_url\": \""+cfg.BasePath+"\"", 1)
v2 := strings.Replace(strings.Replace(string(v), "\"id\": \"/\"", "\"id\": \""+cfg.BasePath+"\"/", 1), "\"start_url\": \"/\"", "\"start_url\": \""+cfg.BasePath+"\"/", 1)
c.Data(http.StatusOK, "application/manifest+json", []byte(v2))
}

582
web/src/lib/api/checkers.ts Normal file
View file

@ -0,0 +1,582 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-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/>.
import {
getChecks,
getChecksByCname,
getChecksByCnameOptions,
postChecksByCnameOptions,
putChecksByCnameOptions,
getChecksByCnameOptionsByOptname,
putChecksByCnameOptionsByOptname,
postDomainsByDomainChecksByCname,
getDomainsByDomainChecksByCnameOptions,
postDomainsByDomainChecksByCnameOptions,
putDomainsByDomainChecksByCnameOptions,
getDomainsByDomainChecks,
getDomainsByDomainChecksByCnameResults,
getDomainsByDomainChecksByCnameResultsByResultId,
getDomainsByDomainChecksByCnameResultsByResultIdReport,
deleteDomainsByDomainChecksByCnameResults,
deleteDomainsByDomainChecksByCnameResultsByResultId,
getDomainsByDomainChecksByCnameExecutionsByExecutionId,
postPluginsTestsSchedules,
putPluginsTestsSchedulesByScheduleId,
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecks,
postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCname,
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameExecutionsByExecutionId,
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameOptions,
postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameOptions,
putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameOptions,
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResults,
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultId,
deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultId,
deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResults,
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdReport,
getDomainsByDomainChecksByCnameMetrics,
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameMetrics,
getDomainsByDomainChecksByCnameResultsByResultIdMetrics,
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdMetrics,
} from "$lib/api-base/sdk.gen";
import { unwrapSdkResponse } from "./errors";
import type {
CheckerList,
CheckerInfo,
CheckerOptions,
AvailableChecker,
CheckerSchedule,
CheckResult,
CheckExecution,
CheckScopeType,
MetricsReport,
} from "$lib/model/checker";
export async function listCheckers(): Promise<CheckerList> {
return unwrapSdkResponse(await getChecks()) as CheckerList;
}
export async function getCheckStatus(checkId: string): Promise<CheckerInfo> {
return unwrapSdkResponse(
await getChecksByCname({
path: { cname: checkId },
}),
) as unknown as CheckerInfo;
}
export async function getCheckOptions(checkId: string): Promise<CheckerOptions> {
return unwrapSdkResponse(
await getChecksByCnameOptions({
path: { cname: checkId },
}),
) as CheckerOptions;
}
export async function addCheckOptions(checkId: string, options: CheckerOptions): Promise<boolean> {
return unwrapSdkResponse(
await postChecksByCnameOptions({
path: { cname: checkId },
body: { options } as any,
}),
) as boolean;
}
export async function updateCheckOptions(
checkId: string,
options: CheckerOptions,
): Promise<boolean> {
return unwrapSdkResponse(
await putChecksByCnameOptions({
path: { cname: checkId },
body: { options } as any,
}),
) as boolean;
}
export async function getCheckOption(checkId: string, optionName: string): Promise<any> {
return unwrapSdkResponse(
await getChecksByCnameOptionsByOptname({
path: { cname: checkId, optname: optionName },
}),
);
}
export async function setcheckOption(
checkId: string,
optionName: string,
value: any,
): Promise<boolean> {
return unwrapSdkResponse(
await putChecksByCnameOptionsByOptname({
path: { cname: checkId, optname: optionName },
body: value as any,
}),
) as boolean;
}
export async function getDomainCheckOptions(
domainId: string,
checkId: string,
): Promise<CheckerOptions> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameOptions({
path: { domain: domainId, cname: checkId },
}),
) as CheckerOptions;
}
export async function addDomainCheckOptions(
domainId: string,
checkId: string,
options: CheckerOptions,
): Promise<boolean> {
return unwrapSdkResponse(
await postDomainsByDomainChecksByCnameOptions({
path: { domain: domainId, cname: checkId },
body: { options } as any,
}),
) as boolean;
}
export async function updateDomainCheckOptions(
domainId: string,
checkId: string,
options: CheckerOptions,
): Promise<boolean> {
return unwrapSdkResponse(
await putDomainsByDomainChecksByCnameOptions({
path: { domain: domainId, cname: checkId },
body: { options } as any,
}),
) as boolean;
}
export async function triggerCheck(
domainId: string,
checkId: string,
options?: CheckerOptions,
): Promise<{ execution_id?: string }> {
const filteredOptions = options
? Object.fromEntries(
Object.entries(options).filter(([, v]) => v !== "" && v !== null && v !== undefined),
)
: undefined;
return unwrapSdkResponse(
await postDomainsByDomainChecksByCname({
path: { domain: domainId, cname: checkId },
body: { options: filteredOptions } as any,
}),
) as { execution_id?: string };
}
export async function listAvailableCheckers(domainId: string): Promise<AvailableChecker[]> {
return unwrapSdkResponse(
await getDomainsByDomainChecks({
path: { domain: domainId },
}),
) as unknown as AvailableChecker[];
}
export async function createCheckSchedule(data: {
checker_name: string;
target_type: CheckScopeType;
target_id: string;
interval: number;
enabled: boolean;
options?: CheckerOptions;
}): Promise<CheckerSchedule> {
return unwrapSdkResponse(
await postPluginsTestsSchedules({
body: {
checker_name: data.checker_name,
target_type: data.target_type,
target_id: data.target_id,
interval: data.interval,
enabled: data.enabled,
options: data.options,
},
}),
);
}
export async function updateCheckSchedule(
scheduleId: string,
schedule: CheckerSchedule,
): Promise<CheckerSchedule> {
return unwrapSdkResponse(
await putPluginsTestsSchedulesByScheduleId({
path: { schedule_id: scheduleId },
body: {
id: schedule.id,
checker_name: schedule.checker_name,
target_type: schedule.target_type,
target_id: schedule.target_id,
interval: schedule.interval,
enabled: schedule.enabled,
last_run: schedule.last_run,
next_run: schedule.next_run,
options: schedule.options,
},
}),
);
}
export async function getCheckResult(
domainId: string,
checkName: string,
resultId: string,
): Promise<CheckResult> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameResultsByResultId({
path: { domain: domainId, cname: checkName, result_id: resultId },
}),
) as unknown as CheckResult;
}
export async function deleteCheckResult(
domainId: string,
checkName: string,
resultId: string,
): Promise<void> {
await deleteDomainsByDomainChecksByCnameResultsByResultId({
path: { domain: domainId, cname: checkName, result_id: resultId },
});
}
export async function listCheckResults(
domainId: string,
checkName: string,
): Promise<CheckResult[]> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameResults({
path: { domain: domainId, cname: checkName },
}),
) as unknown as CheckResult[];
}
export async function deleteAllCheckResults(domainId: string, checkName: string): Promise<void> {
await deleteDomainsByDomainChecksByCnameResults({
path: { domain: domainId, cname: checkName },
});
}
export async function getCheckResultHTMLReport(
domainId: string,
checkName: string,
resultId: string,
): Promise<string> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameResultsByResultIdReport({
path: { domain: domainId, cname: checkName, result_id: resultId },
}),
) as string;
}
export async function getCheckExecution(
domainId: string,
checkName: string,
executionId: string,
): Promise<CheckExecution> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameExecutionsByExecutionId({
path: { domain: domainId, cname: checkName, execution_id: executionId },
}),
) as unknown as CheckExecution;
}
// --- Service-scoped check API functions ---
export async function listServiceAvailableCheckers(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
): Promise<AvailableChecker[]> {
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecks({
path: { domain: domainId, zoneid: zoneId, subdomain, serviceid },
}),
) as unknown as AvailableChecker[];
}
export async function triggerServiceCheck(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkId: string,
options?: CheckerOptions,
): Promise<{ execution_id?: string }> {
const filteredOptions = options
? Object.fromEntries(
Object.entries(options).filter(([, v]) => v !== "" && v !== null && v !== undefined),
)
: undefined;
return unwrapSdkResponse(
await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCname({
path: { domain: domainId, zoneid: zoneId, subdomain, serviceid, cname: checkId } as any,
body: { options: filteredOptions } as any,
}),
) as { execution_id?: string };
}
export async function getServiceCheckExecution(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkName: string,
executionId: string,
): Promise<CheckExecution> {
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameExecutionsByExecutionId(
{
path: {
domain: domainId,
zoneid: zoneId,
subdomain,
serviceid,
cname: checkName,
execution_id: executionId,
} as any,
},
),
) as unknown as CheckExecution;
}
export async function getServiceCheckOptions(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkId: string,
): Promise<CheckerOptions> {
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameOptions({
path: { domain: domainId, zoneid: zoneId, subdomain, serviceid, cname: checkId } as any,
}),
) as CheckerOptions;
}
export async function addServiceCheckOptions(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkId: string,
options: CheckerOptions,
): Promise<boolean> {
return unwrapSdkResponse(
await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameOptions({
path: { domain: domainId, zoneid: zoneId, subdomain, serviceid, cname: checkId } as any,
body: { options } as any,
}),
) as boolean;
}
export async function updateServiceCheckOptions(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkId: string,
options: CheckerOptions,
): Promise<boolean> {
return unwrapSdkResponse(
await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameOptions({
path: { domain: domainId, zoneid: zoneId, subdomain, serviceid, cname: checkId } as any,
body: { options } as any,
}),
) as boolean;
}
export async function listServiceCheckResults(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkName: string,
): Promise<CheckResult[]> {
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResults({
path: {
domain: domainId,
zoneid: zoneId,
subdomain,
serviceid,
cname: checkName,
} as any,
}),
) as unknown as CheckResult[];
}
export async function getServiceCheckResult(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkName: string,
resultId: string,
): Promise<CheckResult> {
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultId(
{
path: {
domain: domainId,
zoneid: zoneId,
subdomain,
serviceid,
cname: checkName,
result_id: resultId,
} as any,
},
),
) as unknown as CheckResult;
}
export async function deleteServiceCheckResult(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkName: string,
resultId: string,
): Promise<void> {
await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultId(
{
path: {
domain: domainId,
zoneid: zoneId,
subdomain,
serviceid,
cname: checkName,
result_id: resultId,
} as any,
},
);
}
export async function deleteAllServiceCheckResults(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkName: string,
): Promise<void> {
await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResults({
path: { domain: domainId, zoneid: zoneId, subdomain, serviceid, cname: checkName } as any,
});
}
export async function getServiceCheckResultHTMLReport(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkName: string,
resultId: string,
): Promise<string> {
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdReport(
{
path: {
domain: domainId,
zoneid: zoneId,
subdomain,
serviceid,
cname: checkName,
result_id: resultId,
} as any,
},
),
) as string;
}
// --- Metrics API functions ---
export async function getCheckMetrics(
domainId: string,
checkName: string,
limit: number = 100,
): Promise<MetricsReport> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameMetrics({
path: { domain: domainId, cname: checkName },
query: { limit },
}),
) as unknown as MetricsReport;
}
export async function getServiceCheckMetrics(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkName: string,
limit: number = 100,
): Promise<MetricsReport> {
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameMetrics({
path: {
domain: domainId,
zoneid: zoneId,
subdomain,
serviceid,
cname: checkName,
} as any,
query: { limit },
}),
) as unknown as MetricsReport;
}
export async function getCheckResultMetrics(
domainId: string,
checkName: string,
resultId: string,
): Promise<MetricsReport> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameResultsByResultIdMetrics({
path: { domain: domainId, cname: checkName, result_id: resultId },
}),
) as unknown as MetricsReport;
}
export async function getServiceCheckResultMetrics(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkName: string,
resultId: string,
): Promise<MetricsReport> {
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdMetrics(
{
path: {
domain: domainId,
zoneid: zoneId,
subdomain,
serviceid,
cname: checkName,
result_id: resultId,
} as any,
},
),
) as unknown as MetricsReport;
}

View file

@ -0,0 +1,30 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-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/>.
import { postDomaininfoByDomain } from "$lib/api-base/sdk.gen";
import type { DomainInfo } from "$lib/model/domaininfo";
import { unwrapSdkResponse } from "./errors";
export async function getDomainInfo(domain: string): Promise<DomainInfo> {
return unwrapSdkResponse(
await postDomaininfoByDomain({ path: { domain } }),
) as DomainInfo;
}

View file

@ -0,0 +1,227 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import {
Badge,
Card,
CardBody,
CardHeader,
CardTitle,
Col,
ListGroup,
ListGroupItem,
Row,
} from "@sveltestrap/sveltestrap";
import type { DomainInfo } from "$lib/model/domaininfo";
import { t } from "$lib/translations";
interface Props {
info: DomainInfo;
domain: string;
}
let { info, domain }: Props = $props();
function statusColor(code: string): string {
const lc = code.toLowerCase();
if (lc === "ok" || lc === "active") return "success";
if (lc.includes("hold")) return "danger";
if (lc.includes("prohibited") || lc.includes("pending")) return "info";
return "secondary";
}
function expirationProgress(expiration?: string): number {
if (!expiration) return 0;
const days = daysUntilExpiration(expiration);
if (days <= 0) return 0;
return Math.min(100, Math.round((days / 365) * 100));
}
function expirationColor(expiration?: string): string {
if (!expiration) return "secondary";
const days = Math.round((new Date(expiration).getTime() - Date.now()) / 86400000);
if (days < 0) return "danger";
if (days < 30) return "danger";
if (days < 90) return "warning";
return "success";
}
function daysUntilExpiration(expiration?: string): number {
if (!expiration) return 0;
return Math.round((new Date(expiration).getTime() - Date.now()) / 86400000);
}
function formatDate(iso?: string): string {
if (!iso) return "";
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
}
</script>
<h2 class="display-7 fw-bold mt-3 mb-1 font-monospace">
{info.name ?? domain}
</h2>
<!-- Status badges -->
{#if info.status && info.status.length > 0}
<div class="mb-4">
<p class="text-muted small mb-1">{$t("domaininfo.status")}</p>
<div class="d-flex flex-wrap gap-2">
{#each info.status as code}
<Badge
color={statusColor(code)}
title={$t(`domaininfo.status-descriptions.${code}`) || code}
>
{code}
</Badge>
{/each}
</div>
{#if info.status.length === 1}
{@const desc = $t(
`domaininfo.status-descriptions.${info.status[0]}`,
)}
{#if desc && !desc.startsWith("domaininfo.")}
<p class="text-muted small mt-1 mb-0">{desc}</p>
{/if}
{/if}
</div>
{/if}
<!-- Dates card -->
<div class="card mb-4">
<div class="card-body">
<Row>
<Col sm="6" class="mb-3 mb-sm-0">
<p class="text-muted small mb-1">
<i class="bi bi-calendar-check me-1"></i>
{$t("domaininfo.creation-date")}
</p>
{#if info.creation}
<p class="fw-semibold mb-0">{formatDate(info.creation)}</p>
{:else}
<p class="text-muted mb-0">
{$t("domaininfo.no-creation")}
</p>
{/if}
</Col>
<Col sm="6">
<p class="text-muted small mb-1">
<i class="bi bi-calendar-x me-1"></i>
{$t("domaininfo.expiration-date")}
</p>
{#if info.expiration}
{@const days = daysUntilExpiration(info.expiration)}
{@const color = expirationColor(info.expiration)}
{@const expiresLabel = days < 0 ? $t("domaininfo.expired", { days: Math.abs(days) }) : days === 0 ? $t("domaininfo.expires-today") : $t("domaininfo.expires-in", { days })}
<p class="fw-semibold mb-1">
{formatDate(info.expiration)}
</p>
<div
class="progress mb-1"
style="height: 6px;"
title={expiresLabel}
>
<div
class="progress-bar bg-{color}"
role="progressbar"
style="width: {expirationProgress(info.expiration)}%"
></div>
</div>
<p class="text-{color} small mb-0">
{#if days < 0}
{$t("domaininfo.expired", { days: Math.abs(days) })}
{:else if days === 0}
{$t("domaininfo.expires-today")}
{:else}
{$t("domaininfo.expires-in", { days })}
{/if}
</p>
{:else}
<p class="text-muted mb-0">
{$t("domaininfo.no-expiration")}
</p>
{/if}
</Col>
</Row>
</div>
</div>
<Row>
<Col md={6}>
<!-- Registrar card -->
<Card class="mb-4">
<CardHeader>
<CardTitle class="h6 mb-0 fw-bold">
<i class="bi bi-building me-1"></i>
{$t("domaininfo.registrar")}
</CardTitle>
</CardHeader>
<CardBody>
{#if info.registrar}
<p class="fw-semibold mb-1">{info.registrar}</p>
{#if info.registrar_url}
<a
href={info.registrar_url}
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-secondary btn-sm"
>
<i class="bi bi-box-arrow-up-right me-1"></i>
{$t("domaininfo.registrar-url")}
</a>
{/if}
{:else}
<p class="text-muted mb-0">
{$t("domaininfo.no-registrar")}
</p>
{/if}
</CardBody>
</Card>
</Col>
<!-- Nameservers card -->
{#if info.nameservers && info.nameservers.length > 0}
<Col md={6}>
<Card class="mb-4">
<CardHeader>
<CardTitle class="h6 mb-0 fw-bold">
<i class="bi bi-hdd-network me-1"></i>
{$t("domaininfo.nameservers")}
</CardTitle>
</CardHeader>
<ListGroup flush>
{#each info.nameservers as ns}
<ListGroupItem class="font-monospace py-2"
>{ns}</ListGroupItem
>
{/each}
</ListGroup>
</Card>
</Col>
{/if}
</Row>

View file

@ -76,6 +76,7 @@
<Navbar
class="{className} {$userSession.id ? 'p-0' : ''}"
style="z-index: 100"
id="nav"
container
expand="xs"
light
@ -137,6 +138,20 @@
>
{$t("menu.dns-resolver")}
</DropdownItem>
<DropdownItem
active={page.route && page.route.id == "/whois/[[domain]]"}
href="/whois"
>
{$t("menu.whois")}
</DropdownItem>
<DropdownItem
active={page.route &&
(page.route.id == "/checkers" ||
page.route.id?.startsWith("/checkers/"))}
href="/checkers"
>
{$t("menu.checkers")}
</DropdownItem>
<DropdownItem divider />
<DropdownItem active={page.route && page.route.id == "/me"} href="/me">
{$t("menu.my-account")}

View file

@ -22,6 +22,7 @@
-->
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/state";
import { fly } from "svelte/transition";
@ -30,8 +31,12 @@
import { userSession } from "$lib/stores/usersession";
import { t, locale } from "$lib/translations";
const instancename = encodeURIComponent(window.location.hostname);
let instancename = $state("");
let showCard = $state(false);
onMount(() => {
instancename = encodeURIComponent(window.location.hostname);
});
</script>
{#if showCard}
@ -39,7 +44,7 @@
role="presentation"
style="background-color: #0007; position: fixed; width: 100vw; height: 100vh; top:0; left: 0; z-index: 1050"
onclick={() => (showCard = false)}
></div>
></div>
<div
class="card"
style="position: fixed; bottom: calc(7vh + max(1.7vw, 1.7vh)); right: calc(4vw + max(1.7vw, 1.7vh)); z-index: 1052; max-width: 400px;"
@ -76,7 +81,9 @@
<a
href="https://framaforms.org/quel-est-votre-avis-sur-happydns-1610366701?u={$userSession.id
? $userSession.id
: 0}&amp;i={instancename}{page.route ? ('&p=' + page.route.id) : ''}&amp;l={$locale}"
: 0}&amp;i={instancename}{page.route
? '&p=' + page.route.id
: ''}&amp;l={$locale}"
target="_blank"
rel="noreferrer"
class="btn btn-lg btn-light flex-fill fw-bolder"

View file

@ -0,0 +1,158 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import {
Chart,
LineController,
LineElement,
PointElement,
LinearScale,
TimeScale,
Legend,
Tooltip,
Filler,
} from "chart.js";
import "chartjs-adapter-date-fns";
import type { MetricsReport } from "$lib/model/checker";
Chart.register(
LineController,
LineElement,
PointElement,
LinearScale,
TimeScale,
Legend,
Tooltip,
Filler,
);
interface Props {
report: MetricsReport;
}
let { report }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
const COLORS = [
"#0d6efd",
"#dc3545",
"#198754",
"#ffc107",
"#6610f2",
"#0dcaf0",
"#fd7e14",
"#d63384",
];
function buildChart() {
if (chart) {
chart.destroy();
chart = null;
}
if (!canvas || !report?.series?.length) return;
const units = [...new Set(report.series.map((s) => s.unit))];
const hasRightAxis = units.length > 1;
const rightUnit = hasRightAxis ? units[1] : null;
const datasets = report.series.map((series, i) => ({
label: series.label,
data: series.points.map((p) => ({
x: new Date(p.timestamp).getTime(),
y: p.value,
})),
borderColor: COLORS[i % COLORS.length],
backgroundColor: COLORS[i % COLORS.length] + "20",
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.3,
yAxisID: hasRightAxis && series.unit === rightUnit ? "y1" : "y",
}));
const scales: Record<string, any> = {
x: {
type: "time" as const,
time: { tooltipFormat: "PPpp" },
title: { display: false },
},
y: {
type: "linear" as const,
position: "left" as const,
title: { display: true, text: units[0] || "" },
beginAtZero: true,
},
};
if (hasRightAxis && rightUnit) {
scales.y1 = {
type: "linear" as const,
position: "right" as const,
title: { display: true, text: rightUnit },
beginAtZero: true,
grid: { drawOnChartArea: false },
};
}
chart = new Chart(canvas, {
type: "line",
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
scales,
plugins: {
legend: { position: "bottom" },
tooltip: { mode: "index", intersect: false },
},
},
});
}
onMount(() => {
buildChart();
});
$effect(() => {
// Re-build chart when report changes
if (report && canvas) {
buildChart();
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
chart = null;
}
});
</script>
<div class="chart-container" style="position: relative; height: 350px; width: 100%;">
<canvas bind:this={canvas}></canvas>
</div>

View file

@ -0,0 +1,346 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
// SvelteKit imports
import { navigate } from "$lib/stores/config";
// Component imports
import {
Badge,
Button,
ButtonGroup,
Card,
CardHeader,
CardTitle,
Icon,
Spinner,
Table,
} from "@sveltestrap/sveltestrap";
// Store imports
import { currentCheckResult, currentCheckInfo, showHTMLReport, reportViewMode } from "$lib/stores/checkers";
import { toasts } from "$lib/stores/toasts";
// API imports
import {
deleteCheckResult,
deleteServiceCheckResult,
getCheckResultHTMLReport,
getServiceCheckResultHTMLReport,
triggerCheck,
triggerServiceCheck,
} from "$lib/api/checkers";
// Utility imports
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
import { t } from "$lib/translations";
// Model imports
import type { Domain } from "$lib/model/domain";
// Props
interface Props {
domain: Domain;
cname: string;
rid: string;
checksBase: string;
serviceContext?: {
zoneId: string;
subdomain: string;
serviceid: string;
};
}
let { domain, cname, rid, checksBase, serviceContext }: Props = $props();
// Local state
let isRelaunching = $state(false);
// Functions
async function handleRelaunch() {
if (!$currentCheckResult) return;
isRelaunching = true;
try {
if (serviceContext) {
await triggerServiceCheck(
domain.id,
serviceContext.zoneId,
serviceContext.subdomain,
serviceContext.serviceid,
cname,
$currentCheckResult.options,
);
} else {
await triggerCheck(domain.id, cname, $currentCheckResult.options);
}
navigate(`${checksBase}/${encodeURIComponent(cname)}/results`);
} catch (error: any) {
toasts.addErrorToast({
message: error.message || $t("checkers.result.relaunch-failed"),
});
} finally {
isRelaunching = false;
}
}
async function handleDelete() {
if (!confirm($t("checkers.result.delete-confirm"))) return;
try {
if (serviceContext) {
await deleteServiceCheckResult(
domain.id,
serviceContext.zoneId,
serviceContext.subdomain,
serviceContext.serviceid,
cname,
rid,
);
} else {
await deleteCheckResult(domain.id, cname, rid);
}
navigate(`${checksBase}/${encodeURIComponent(cname)}`);
} catch (error: any) {
toasts.addErrorToast({ message: error.message || $t("checkers.result.delete-failed") });
}
}
function downloadBlob(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function downloadHTML() {
let html: string;
if (serviceContext) {
html = await getServiceCheckResultHTMLReport(
domain.id,
serviceContext.zoneId,
serviceContext.subdomain,
serviceContext.serviceid,
cname,
rid,
);
} else {
html = await getCheckResultHTMLReport(domain.id, cname, rid);
}
downloadBlob(html, `${cname}-${rid}.html`, "text/html");
}
function downloadJSON() {
if (!$currentCheckResult) return;
downloadBlob(
JSON.stringify($currentCheckResult.report, null, 2),
`${cname}-${rid}.json`,
"application/json",
);
}
</script>
{#if $currentCheckResult}
<Card class="mt-3">
<CardHeader class="px-2">
<div class="d-flex justify-content-between align-items-center">
<strong class="text-truncate">{$currentCheckInfo?.name || cname}</strong>
{#if $currentCheckResult.scheduled_check}
<Badge color="info" class="flex-shrink-0">
<Icon name="clock"></Icon>
{$t("checkers.result.type.scheduled")}
</Badge>
{:else}
<Badge color="secondary" class="flex-shrink-0">
<Icon name="hand-index"></Icon>
{$t("checkers.result.type.manual")}
</Badge>
{/if}
</div>
</CardHeader>
<div class="overflow-x-auto rounded-2">
<Table borderless size="sm" class="mb-0">
<tbody>
<tr>
<th style="width: 80px; white-space: nowrap">
{$t("checkers.result.field.executed-at")}
</th>
<td>{formatCheckDate($currentCheckResult.executed_at, "short", $t)}</td>
</tr>
<tr>
<th>{$t("checkers.result.field.duration")}</th>
<td>{formatDuration($currentCheckResult.duration, $t)}</td>
</tr>
<tr>
<th>{$t("checkers.result.field.status")}</th>
<td>
<Badge color={getStatusColor($currentCheckResult.status)}>
{$t(getStatusKey($currentCheckResult.status))}
</Badge>
</td>
</tr>
<tr>
<th>{$t("checkers.result.field.status-message")}</th>
<td class="text-truncate" style="max-width: 0">
{$currentCheckResult.status_line}
</td>
</tr>
{#if $currentCheckResult.error}
<tr>
<th>{$t("checkers.result.field.error")}</th>
<td class="text-danger text-truncate" style="max-width: 0">
{$currentCheckResult.error}
</td>
</tr>
{/if}
</tbody>
</Table>
</div>
</Card>
{#if $currentCheckInfo?.options && $currentCheckResult.options && Object.keys($currentCheckResult.options).length > 0}
<Card class="mt-3">
<CardHeader>
<CardTitle class="h6 mb-0">
<Icon name="sliders"></Icon>
{$t("checkers.result.check-options")}
</CardTitle>
</CardHeader>
<div class="overflow-x-auto rounded-2">
<Table borderless size="sm" class="mb-0">
<tbody>
{#each Object.entries($currentCheckInfo.options) as [optKey, optVals]}
{#each optVals as option}
{@const value =
(option.id
? $currentCheckResult.options[option.id]
: undefined) ||
option.default ||
option.placeholder ||
""}
<tr>
<th
class="text-truncate"
style="max-width: 90px"
title={option.label}
>
{option.label}:
</th>
<td class:text-truncate={typeof value !== "object"}>
{#if typeof value === "object"}
<pre class="mb-0" style="font-size: 0.75em"><code
>{JSON.stringify(value, null, 2)}</code
></pre>
{:else}
{value}
{/if}
</td>
</tr>
{/each}
{/each}
</tbody>
</Table>
</div>
</Card>
{/if}
<div class="my-3 flex-fill"></div>
{#if $currentCheckInfo?.has_html_report || $currentCheckInfo?.has_metrics || $currentCheckResult.report != null}
{#if $currentCheckInfo?.has_metrics || $currentCheckInfo?.has_html_report}
<ButtonGroup class="w-100 mb-2">
{#if $currentCheckInfo?.has_metrics}
<Button
size="sm"
color="secondary"
outline
active={$reportViewMode === "metrics"}
onclick={() => { reportViewMode.set("metrics"); showHTMLReport.set(false); }}
>
<Icon name="graph-up"></Icon>
{$t("checkers.result.view-metrics")}
</Button>
{/if}
{#if $currentCheckInfo?.has_html_report}
<Button
size="sm"
color="secondary"
outline
active={$reportViewMode === "html" || (!$currentCheckInfo?.has_metrics && $showHTMLReport)}
onclick={() => { reportViewMode.set("html"); showHTMLReport.set(true); }}
>
<Icon name="file-earmark-richtext"></Icon>
{$t("checkers.result.view-html")}
</Button>
{/if}
<Button
size="sm"
color="secondary"
outline
active={$reportViewMode === "json" || (!$currentCheckInfo?.has_metrics && !$currentCheckInfo?.has_html_report)}
onclick={() => { reportViewMode.set("json"); showHTMLReport.set(false); }}
>
<Icon name="braces"></Icon>
{$t("checkers.result.view-json")}
</Button>
</ButtonGroup>
{/if}
<ButtonGroup class="w-100">
{#if $currentCheckInfo?.has_html_report}
<Button size="sm" color="outline-secondary" onclick={downloadHTML}>
<Icon name="download"></Icon>
{$t("checkers.result.download-html")}
</Button>
{/if}
{#if $currentCheckResult.report != null}
<Button size="sm" color="outline-secondary" onclick={downloadJSON}>
<Icon name="download"></Icon>
{$t("checkers.result.download-json")}
</Button>
{/if}
</ButtonGroup>
{/if}
{:else}
<div class="flex-fill"></div>
{/if}
<div class="mt-2 d-flex gap-2">
<Button
class="flex-fill"
color="primary"
outline
onclick={handleRelaunch}
disabled={!$currentCheckResult || isRelaunching}
>
{#if isRelaunching}
<Spinner size="sm" />
{:else}
<Icon name="arrow-repeat"></Icon>
{/if}
{$t("checkers.result.relaunch")}
</Button>
<Button color="danger" outline onclick={handleDelete} disabled={!$currentCheckResult}>
<Icon name="trash"></Icon>
</Button>
</div>

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