Compare commits
35 commits
d19f7430f2
...
505a1080db
| Author | SHA1 | Date | |
|---|---|---|---|
| 505a1080db | |||
| a0d31a09a0 | |||
| 403a2c7d64 | |||
| 825c86d63b | |||
| 896416879a | |||
| 0695772486 | |||
| f33217bc04 | |||
| c66c5db4a4 | |||
| e1b13b9e5c | |||
| f804404ac6 | |||
| 32c09bbb86 | |||
| e862e24c52 | |||
| e5ccfad7d7 | |||
| 3f45d752b4 | |||
| d8b464e8e2 | |||
| 8828de6e04 | |||
| 6fb396ff49 | |||
| 7b094d919c | |||
| e72d577c27 | |||
| 1a0a8e8619 | |||
| 4ea1f08da8 | |||
| dea0175fab | |||
| 796ac6d811 | |||
| 2d3944d83d | |||
| dd811c9341 | |||
| e611efbad7 | |||
| 12ed7e4164 | |||
| 47a8289eb3 | |||
| d5719a0272 | |||
| 4fdb466a52 | |||
| d70d8868dc | |||
| ecf30c345a | |||
| 32756535b8 | |||
| 90e6313a6e | |||
| 4f9a308a2d |
167 changed files with 18589 additions and 376 deletions
113
checks/domain-expiration.go
Normal file
113
checks/domain-expiration.go
Normal 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
95
checks/interface.go
Normal 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
326
checks/ping.go
Normal 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
615
checks/zonemaster.go
Normal 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}} · {{end -}}
|
||||
{{- if .HashID}}ID: <code>{{.HashID}}</code>{{end -}}
|
||||
</div>
|
||||
<div class="totals">
|
||||
{{- range .Totals}}
|
||||
<span class="badge {{badgeClass .Level}}">{{.Level}} {{.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
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
11
go.mod
|
|
@ -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
21
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
211
internal/api-admin/controller/check_controller.go
Normal file
211
internal/api-admin/controller/check_controller.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
102
internal/api-admin/controller/scheduler_controller.go
Normal file
102
internal/api-admin/controller/scheduler_controller.go
Normal 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})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
51
internal/api-admin/route/check.go
Normal file
51
internal/api-admin/route/check.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
38
internal/api-admin/route/scheduler.go
Normal file
38
internal/api-admin/route/scheduler.go
Normal 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)
|
||||
}
|
||||
170
internal/api/controller/check_base_controller.go
Normal file
170
internal/api/controller/check_base_controller.go
Normal 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)
|
||||
}
|
||||
247
internal/api/controller/check_controller.go
Normal file
247
internal/api/controller/check_controller.go
Normal 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)
|
||||
}
|
||||
592
internal/api/controller/checkresult_controller.go
Normal file
592
internal/api/controller/checkresult_controller.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
74
internal/api/controller/domain_info.go
Normal file
74
internal/api/controller/domain_info.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
231
internal/api/controller/testschedule_controller.go
Normal file
231
internal/api/controller/testschedule_controller.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
93
internal/api/route/checker.go
Normal file
93
internal/api/route/checker.go
Normal 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)
|
||||
}
|
||||
46
internal/api/route/checkresults.go
Normal file
46
internal/api/route/checkresults.go
Normal 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)
|
||||
}
|
||||
}
|
||||
47
internal/api/route/checkschedule.go
Normal file
47
internal/api/route/checkschedule.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
37
internal/api/route/domain_info.go
Normal file
37
internal/api/route/domain_info.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
641
internal/app/checkscheduler.go
Normal file
641
internal/app/checkscheduler.go
Normal 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)
|
||||
}
|
||||
58
internal/app/disabledscheduler.go
Normal file
58
internal/app/disabledscheduler.go
Normal 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")
|
||||
}
|
||||
624
internal/app/instrumented_storage.go
Normal file
624
internal/app/instrumented_storage.go
Normal 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
134
internal/app/plugins.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
101
internal/metrics/collector.go
Normal file
101
internal/metrics/collector.go
Normal 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
54
internal/metrics/http.go
Normal 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
104
internal/metrics/metrics.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
185
internal/storage/kvtpl/check.go
Normal file
185
internal/storage/kvtpl/check.go
Normal 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
|
||||
}
|
||||
453
internal/storage/kvtpl/checkresult.go
Normal file
453
internal/storage/kvtpl/checkresult.go
Normal 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
|
||||
}
|
||||
84
internal/storage/stats_provider.go
Normal file
84
internal/storage/stats_provider.go
Normal 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
|
||||
}
|
||||
450
internal/usecase/check/check_options_test.go
Normal file
450
internal/usecase/check/check_options_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
62
internal/usecase/check/check_storage.go
Normal file
62
internal/usecase/check/check_storage.go
Normal 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)
|
||||
}
|
||||
331
internal/usecase/check/check_usecase.go
Normal file
331
internal/usecase/check/check_usecase.go
Normal 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)
|
||||
}
|
||||
85
internal/usecase/check/check_usecase_test.go
Normal file
85
internal/usecase/check/check_usecase_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
434
internal/usecase/checkresult/checkresult_usecase.go
Normal file
434
internal/usecase/checkresult/checkresult_usecase.go
Normal 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
|
||||
}
|
||||
490
internal/usecase/checkresult/checkresult_usecase_test.go
Normal file
490
internal/usecase/checkresult/checkresult_usecase_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
465
internal/usecase/checkresult/checkschedule_usecase.go
Normal file
465
internal/usecase/checkresult/checkschedule_usecase.go
Normal 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...)
|
||||
}
|
||||
890
internal/usecase/checkresult/checkschedule_usecase_test.go
Normal file
890
internal/usecase/checkresult/checkschedule_usecase_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
103
internal/usecase/checkresult/storage.go
Normal file
103
internal/usecase/checkresult/storage.go
Normal 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)
|
||||
}
|
||||
62
internal/usecase/domain_info_usecase.go
Normal file
62
internal/usecase/domain_info_usecase.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
226
model/check_result.go
Normal 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
153
model/check_scheduler.go
Normal 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
200
model/checker.go
Normal 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
48
model/domain_info.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
111
pkg/domaininfo/rdap.go
Normal 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
|
||||
}
|
||||
72
pkg/domaininfo/rdap_test.go
Normal file
72
pkg/domaininfo/rdap_test.go
Normal 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
75
pkg/domaininfo/whois.go
Normal 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
|
||||
}
|
||||
75
pkg/domaininfo/whois_test.go
Normal file
75
pkg/domaininfo/whois_test.go
Normal 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
1
plugins/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
*.so
|
||||
336
plugins/README.md
Normal file
336
plugins/README.md
Normal 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
7
plugins/matrix/Makefile
Normal 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
11
plugins/matrix/main.go
Normal 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
552
plugins/matrix/test.go
Normal 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}} — {{.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}} — {{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">✓</span>{{else}}<span class="check-fail">✗</span>{{end}}</td>
|
||||
<td>{{.Label}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{range .Errors}}<p class="errmsg">⚠ {{.}}</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}} → <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
|
||||
}
|
||||
73
tools/gen_service_specs.go
Normal file
73
tools/gen_service_specs.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
153
web-admin/src/routes/checkers/+page.svelte
Normal file
153
web-admin/src/routes/checkers/+page.svelte
Normal 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>
|
||||
325
web-admin/src/routes/checkers/[cname]/+page.svelte
Normal file
325
web-admin/src/routes/checkers/[cname]/+page.svelte
Normal 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>
|
||||
341
web-admin/src/routes/scheduler/+page.svelte
Normal file
341
web-admin/src/routes/scheduler/+page.svelte
Normal 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
43
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
582
web/src/lib/api/checkers.ts
Normal 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;
|
||||
}
|
||||
30
web/src/lib/api/domaininfo.ts
Normal file
30
web/src/lib/api/domaininfo.ts
Normal 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;
|
||||
}
|
||||
227
web/src/lib/components/DomainInfoDisplay.svelte
Normal file
227
web/src/lib/components/DomainInfoDisplay.svelte
Normal 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>
|
||||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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}&i={instancename}{page.route ? ('&p=' + page.route.id) : ''}&l={$locale}"
|
||||
: 0}&i={instancename}{page.route
|
||||
? '&p=' + page.route.id
|
||||
: ''}&l={$locale}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="btn btn-lg btn-light flex-fill fw-bolder"
|
||||
|
|
|
|||
158
web/src/lib/components/checkers/CheckMetricsChart.svelte
Normal file
158
web/src/lib/components/checkers/CheckMetricsChart.svelte
Normal 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>
|
||||
346
web/src/lib/components/checkers/CheckResultSidebar.svelte
Normal file
346
web/src/lib/components/checkers/CheckResultSidebar.svelte
Normal 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
Loading…
Add table
Add a link
Reference in a new issue