Compare commits

..

1 commit

Author SHA1 Message Date
e4abb59e83 web: Add optional breadcrumb to PageTitle
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-16 02:28:47 +07:00
228 changed files with 418 additions and 31013 deletions

View file

@ -1,256 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
"context"
"fmt"
"strings"
"time"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// redactedPatterns is the list of substrings that, when found in a contact
// field, indicate the data is privacy-protected rather than meaningful.
var redactedPatterns = []string{
"redacted",
"privacy",
"not disclosed",
"whoisguard",
"withheld",
"data protected",
"contact privacy",
}
// domainContactRule compares the registered domain contacts (registrant,
// admin, tech) against user-supplied expected values, with redaction
// detection for privacy-protected domains.
type domainContactRule struct{}
func (r *domainContactRule) Name() string {
return "domain_contact_check"
}
func (r *domainContactRule) Description() string {
return "Verifies that domain contacts (name/organization/email) match expected values"
}
// validRoles enumerates the contact roles supported by the WHOIS observation.
var validRoles = map[string]bool{
"registrant": true,
"admin": true,
"tech": true,
}
func (r *domainContactRule) ValidateOptions(opts happydns.CheckerOptions) error {
for _, key := range []string{"expectedName", "expectedOrganization", "expectedEmail", "checkRoles"} {
if v, ok := opts[key]; ok {
if _, ok := v.(string); !ok {
return fmt.Errorf("%s must be a string", key)
}
}
}
if v, ok := opts["checkRoles"].(string); ok && v != "" {
hasOne := false
for _, p := range strings.Split(v, ",") {
role := strings.TrimSpace(p)
if role == "" {
continue
}
if !validRoles[role] {
return fmt.Errorf("checkRoles: unknown role %q (allowed: registrant, admin, tech)", role)
}
hasOne = true
}
if !hasOne {
return fmt.Errorf("checkRoles must contain at least one role")
}
}
return nil
}
func (r *domainContactRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
var whois WHOISData
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
return happydns.CheckState{
Status: happydns.StatusError,
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
Code: "contact_error",
}
}
expectedName, _ := opts["expectedName"].(string)
expectedOrg, _ := opts["expectedOrganization"].(string)
expectedEmail, _ := opts["expectedEmail"].(string)
if expectedName == "" && expectedOrg == "" && expectedEmail == "" {
return happydns.CheckState{
Status: happydns.StatusUnknown,
Message: "No expected contact values configured",
Code: "contact_skipped",
}
}
checkRolesStr := "registrant"
if v, ok := opts["checkRoles"].(string); ok && v != "" {
checkRolesStr = v
}
var roles []string
for s := range strings.SplitSeq(checkRolesStr, ",") {
s = strings.TrimSpace(s)
if s != "" {
roles = append(roles, s)
}
}
if len(roles) == 0 {
return happydns.CheckState{
Status: happydns.StatusUnknown,
Message: "No contact roles to check",
Code: "contact_skipped",
}
}
worst := happydns.StatusOK
var lines []string
for _, role := range roles {
contact, found := whois.Contacts[role]
if !found || contact == nil {
lines = append(lines, fmt.Sprintf("%s: contact not found", role))
worst = worseStatus(worst, happydns.StatusWarn)
continue
}
if isRedacted(contact) {
lines = append(lines, fmt.Sprintf("%s: contact info is redacted/private", role))
worst = worseStatus(worst, happydns.StatusInfo)
continue
}
var mismatches []string
if expectedName != "" && !strings.EqualFold(expectedName, contact.Name) {
mismatches = append(mismatches, fmt.Sprintf("name: got %q, expected %q", contact.Name, expectedName))
}
if expectedOrg != "" && !strings.EqualFold(expectedOrg, contact.Organization) {
mismatches = append(mismatches, fmt.Sprintf("organization: got %q, expected %q", contact.Organization, expectedOrg))
}
if expectedEmail != "" && !strings.EqualFold(expectedEmail, contact.Email) {
mismatches = append(mismatches, fmt.Sprintf("email: got %q, expected %q", contact.Email, expectedEmail))
}
if len(mismatches) > 0 {
lines = append(lines, fmt.Sprintf("%s: %s", role, strings.Join(mismatches, ", ")))
worst = worseStatus(worst, happydns.StatusWarn)
} else {
lines = append(lines, fmt.Sprintf("%s: contact info matches", role))
}
}
return happydns.CheckState{
Status: worst,
Message: strings.Join(lines, "; "),
Code: "contact_result",
}
}
// isRedacted reports whether a contact's fields look privacy-protected.
func isRedacted(c *happydns.ContactInfo) bool {
for _, field := range []string{c.Name, c.Organization, c.Email} {
lower := strings.ToLower(field)
for _, pattern := range redactedPatterns {
if strings.Contains(lower, pattern) {
return true
}
}
}
return false
}
// worseStatus returns the more severe of two statuses. The SDK orders
// statuses as Unknown < OK < Info < Warn < Crit < Error, so the higher
// numeric value is the more severe one.
func worseStatus(a, b happydns.Status) happydns.Status {
if b > a {
return b
}
return a
}
func init() {
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "domain_contact",
Name: "Domain Contact Consistency",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
Options: happydns.CheckerOptionsDocumentation{
DomainOpts: []happydns.CheckerOptionDocumentation{
{
Id: "domainName",
Type: "string",
AutoFill: happydns.AutoFillDomainName,
Hide: true,
},
{
Id: "expectedName",
Type: "string",
Label: "Expected registrant name",
Description: "If set, the configured roles must report this exact name (case-insensitive).",
},
{
Id: "expectedOrganization",
Type: "string",
Label: "Expected organization",
Description: "If set, the configured roles must report this exact organization (case-insensitive).",
},
{
Id: "expectedEmail",
Type: "string",
Label: "Expected email",
Description: "If set, the configured roles must report this exact email (case-insensitive).",
},
{
Id: "checkRoles",
Type: "string",
Label: "Contact roles to check",
Description: "Comma-separated list of roles among: registrant, admin, tech.",
Default: "registrant",
Placeholder: "registrant",
},
},
},
Rules: []happydns.CheckRule{
&domainContactRule{},
},
Interval: &happydns.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
})
}

View file

@ -1,211 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
"context"
"strings"
"testing"
"git.happydns.org/happyDomain/model"
)
func contactsFixture() map[string]*happydns.ContactInfo {
return map[string]*happydns.ContactInfo{
"registrant": {
Name: "Alice Example",
Organization: "Example Inc",
Email: "alice@example.com",
},
"admin": {
Name: "REDACTED FOR PRIVACY",
Organization: "REDACTED FOR PRIVACY",
Email: "redacted@example.com",
},
"tech": {
Name: "Bob Tech",
Organization: "Example Inc",
Email: "bob@example.com",
},
}
}
func TestDomainContactRule_Evaluate(t *testing.T) {
rule := &domainContactRule{}
obs := newWhoisObs(&WHOISData{Contacts: contactsFixture()})
cases := []struct {
name string
opts happydns.CheckerOptions
want happydns.Status
code string
}{
{
name: "no expectations",
opts: nil,
want: happydns.StatusUnknown,
code: "contact_skipped",
},
{
name: "registrant matches",
opts: happydns.CheckerOptions{
"expectedName": "Alice Example",
"expectedEmail": "alice@example.com",
},
want: happydns.StatusOK,
code: "contact_result",
},
{
name: "registrant name mismatch",
opts: happydns.CheckerOptions{
"expectedName": "Carol Other",
},
want: happydns.StatusWarn,
code: "contact_result",
},
{
name: "admin role is redacted",
opts: happydns.CheckerOptions{
"checkRoles": "admin",
"expectedName": "Alice Example",
},
want: happydns.StatusInfo,
code: "contact_result",
},
{
name: "missing role",
opts: happydns.CheckerOptions{
"checkRoles": "billing",
"expectedName": "Alice Example",
},
want: happydns.StatusWarn,
code: "contact_result",
},
{
name: "multi-role mixed (worst wins)",
opts: happydns.CheckerOptions{
"checkRoles": "registrant,admin",
"expectedName": "Alice Example",
},
// admin is redacted (Info) — Info is worse than OK from registrant.
want: happydns.StatusInfo,
code: "contact_result",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
st := rule.Evaluate(context.Background(), obs, tc.opts)
if st.Status != tc.want {
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
}
if st.Code != tc.code {
t.Errorf("code = %q, want %q", st.Code, tc.code)
}
})
}
}
func TestDomainContactRule_EvaluateObservationError(t *testing.T) {
rule := &domainContactRule{}
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("nope")}
st := rule.Evaluate(context.Background(), obs, happydns.CheckerOptions{"expectedName": "x"})
if st.Status != happydns.StatusError || st.Code != "contact_error" {
t.Errorf("got %v / %q", st.Status, st.Code)
}
}
func TestDomainContactRule_ValidateOptions(t *testing.T) {
rule := &domainContactRule{}
cases := []struct {
name string
opts happydns.CheckerOptions
wantErr bool
}{
{"empty", nil, false},
{"all valid", happydns.CheckerOptions{
"expectedName": "x",
"checkRoles": "registrant,tech",
}, false},
{"unknown role", happydns.CheckerOptions{"checkRoles": "billing"}, true},
{"empty roles after split", happydns.CheckerOptions{"checkRoles": " , , "}, true},
{"wrong type", happydns.CheckerOptions{"expectedEmail": 42}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := rule.ValidateOptions(tc.opts)
if (err != nil) != tc.wantErr {
t.Errorf("err=%v wantErr=%v", err, tc.wantErr)
}
})
}
}
func TestIsRedacted(t *testing.T) {
cases := []struct {
c *happydns.ContactInfo
want bool
}{
{&happydns.ContactInfo{Name: "REDACTED FOR PRIVACY"}, true},
{&happydns.ContactInfo{Organization: "Contact Privacy Inc"}, true},
{&happydns.ContactInfo{Email: "withheld@example.com"}, true},
{&happydns.ContactInfo{Name: "Alice", Email: "alice@example.com"}, false},
}
for _, tc := range cases {
if got := isRedacted(tc.c); got != tc.want {
t.Errorf("isRedacted(%+v) = %v, want %v", tc.c, got, tc.want)
}
}
}
func TestWorseStatus(t *testing.T) {
cases := []struct {
a, b, want happydns.Status
}{
{happydns.StatusOK, happydns.StatusInfo, happydns.StatusInfo},
{happydns.StatusInfo, happydns.StatusWarn, happydns.StatusWarn},
{happydns.StatusCrit, happydns.StatusWarn, happydns.StatusCrit},
{happydns.StatusOK, happydns.StatusUnknown, happydns.StatusOK},
{happydns.StatusError, happydns.StatusCrit, happydns.StatusError},
}
for _, tc := range cases {
if got := worseStatus(tc.a, tc.b); got != tc.want {
t.Errorf("worseStatus(%v,%v) = %v, want %v", tc.a, tc.b, got, tc.want)
}
}
}
// Sanity: WHOIS data with no contacts must not panic when a role is requested.
func TestDomainContactRule_NilContacts(t *testing.T) {
rule := &domainContactRule{}
obs := newWhoisObs(&WHOISData{})
st := rule.Evaluate(context.Background(), obs, happydns.CheckerOptions{
"expectedName": "Alice",
})
if st.Status != happydns.StatusWarn {
t.Errorf("status = %v, want Warn", st.Status)
}
if !strings.Contains(st.Message, "not found") {
t.Errorf("message = %q", st.Message)
}
}

View file

@ -1,237 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/pkg/domaininfo"
)
const (
// ObservationKeyWhois is the observation key for WHOIS / domain expiry data.
ObservationKeyWhois happydns.ObservationKey = "whois"
defaultWarningDays = 30
defaultCriticalDays = 7
)
// WHOISData represents WHOIS observation data.
type WHOISData struct {
ExpiryDate time.Time `json:"expiryDate"`
Registrar string `json:"registrar"`
Contacts map[string]*happydns.ContactInfo `json:"contacts,omitempty"`
Status []string `json:"status,omitempty"`
}
// whoisProvider is a placeholder WHOIS observation provider.
type whoisProvider struct{}
func (p *whoisProvider) Key() happydns.ObservationKey {
return ObservationKeyWhois
}
func (p *whoisProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
domainName, _ := opts["domainName"].(string)
if domainName == "" {
return nil, fmt.Errorf("domainName is required")
}
info, err := domaininfo.GetDomainInfo(ctx, happydns.Origin(domainName))
if err != nil {
return nil, fmt.Errorf("failed to retrieve domain info: %w", err)
}
if info.ExpirationDate == nil {
return nil, fmt.Errorf("expiration date not available for %s", domainName)
}
registrar := info.Registrar
if registrar == "" {
registrar = "Unknown"
}
return &WHOISData{
ExpiryDate: *info.ExpirationDate,
Registrar: registrar,
Contacts: info.Contacts,
Status: info.Status,
}, nil
}
// ExtractMetrics implements happydns.CheckerMetricsReporter.
func (p *whoisProvider) ExtractMetrics(raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, error) {
var data WHOISData
if err := json.Unmarshal(raw, &data); err != nil {
return nil, err
}
daysRemaining := data.ExpiryDate.Sub(collectedAt).Hours() / 24
return []happydns.CheckMetric{{
Name: "domain_expiry_days_remaining",
Value: daysRemaining,
Unit: "days",
Labels: map[string]string{"registrar": data.Registrar},
Timestamp: collectedAt,
}}, nil
}
// domainExpiryRule checks whether a domain is nearing expiration.
type domainExpiryRule struct{}
func (r *domainExpiryRule) Name() string {
return "domain_expiry_check"
}
func (r *domainExpiryRule) Description() string {
return "Checks whether a domain name is nearing its expiration date"
}
func (r *domainExpiryRule) ValidateOptions(opts happydns.CheckerOptions) error {
warningDays := float64(defaultWarningDays)
criticalDays := float64(defaultCriticalDays)
if v, ok := opts["warning_days"]; ok {
d, ok := v.(float64)
if !ok {
return fmt.Errorf("warning_days must be a number")
}
if d <= 0 {
return fmt.Errorf("warning_days must be positive")
}
warningDays = d
}
if v, ok := opts["critical_days"]; ok {
d, ok := v.(float64)
if !ok {
return fmt.Errorf("critical_days must be a number")
}
if d <= 0 {
return fmt.Errorf("critical_days must be positive")
}
criticalDays = d
}
if criticalDays >= warningDays {
return fmt.Errorf("critical_days (%v) must be less than warning_days (%v)", criticalDays, warningDays)
}
return nil
}
func (r *domainExpiryRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
var whois WHOISData
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
return happydns.CheckState{
Status: happydns.StatusError,
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
Code: "whois_error",
}
}
// Read thresholds from options with defaults.
warningDays := sdk.GetIntOption(opts, "warning_days", defaultWarningDays)
criticalDays := sdk.GetIntOption(opts, "critical_days", defaultCriticalDays)
daysRemaining := int(time.Until(whois.ExpiryDate).Hours() / 24)
meta := map[string]any{"days_remaining": daysRemaining}
if daysRemaining <= criticalDays {
return happydns.CheckState{
Status: happydns.StatusCrit,
Message: fmt.Sprintf("Domain expires in %d days", daysRemaining),
Code: "expiry_critical",
Meta: meta,
}
}
if daysRemaining <= warningDays {
return happydns.CheckState{
Status: happydns.StatusWarn,
Message: fmt.Sprintf("Domain expires in %d days", daysRemaining),
Code: "expiry_warning",
Meta: meta,
}
}
return happydns.CheckState{
Status: happydns.StatusOK,
Message: fmt.Sprintf("Domain expires in %d days", daysRemaining),
Code: "expiry_ok",
Meta: meta,
}
}
func init() {
checker.RegisterObservationProvider(&whoisProvider{})
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "domain_expiry",
Name: "Domain Expiry",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
Options: happydns.CheckerOptionsDocumentation{
DomainOpts: []happydns.CheckerOptionDocumentation{
{
Id: "domainName",
Type: "string",
AutoFill: happydns.AutoFillDomainName,
Hide: true,
},
{
Id: "warning_days",
Type: "uint",
Label: "Warning threshold (days)",
Description: "Number of days before expiration to trigger a warning.",
Default: defaultWarningDays,
Placeholder: strconv.Itoa(defaultWarningDays),
},
{
Id: "critical_days",
Type: "uint",
Label: "Critical threshold (days)",
Description: "Number of days before expiration to trigger a critical alert.",
Default: defaultCriticalDays,
Placeholder: strconv.Itoa(defaultCriticalDays),
},
},
},
Rules: []happydns.CheckRule{
&domainExpiryRule{},
},
Interval: &happydns.CheckIntervalSpec{
Min: 12 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
HasMetrics: true,
})
}

View file

@ -1,136 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
"context"
"encoding/json"
"testing"
"time"
"git.happydns.org/happyDomain/model"
)
func TestDomainExpiryRule_Evaluate(t *testing.T) {
rule := &domainExpiryRule{}
now := time.Now()
cases := []struct {
name string
expiresIn time.Duration
opts happydns.CheckerOptions
want happydns.Status
code string
}{
{"already expired", -5 * 24 * time.Hour, nil, happydns.StatusCrit, "expiry_critical"},
{"critical default", 3 * 24 * time.Hour, nil, happydns.StatusCrit, "expiry_critical"},
{"warning default", 15 * 24 * time.Hour, nil, happydns.StatusWarn, "expiry_warning"},
{"ok default", 90 * 24 * time.Hour, nil, happydns.StatusOK, "expiry_ok"},
{"ok with custom thresholds", 10 * 24 * time.Hour, happydns.CheckerOptions{
"warning_days": float64(5),
"critical_days": float64(2),
}, happydns.StatusOK, "expiry_ok"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
obs := newWhoisObs(&WHOISData{
ExpiryDate: now.Add(tc.expiresIn),
Registrar: "Test Registrar",
})
st := rule.Evaluate(context.Background(), obs, tc.opts)
if st.Status != tc.want {
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
}
if st.Code != tc.code {
t.Errorf("code = %q, want %q", st.Code, tc.code)
}
})
}
}
func TestDomainExpiryRule_EvaluateObservationError(t *testing.T) {
rule := &domainExpiryRule{}
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("boom")}
st := rule.Evaluate(context.Background(), obs, nil)
if st.Status != happydns.StatusError {
t.Fatalf("expected StatusError, got %v", st.Status)
}
if st.Code != "whois_error" {
t.Errorf("code = %q, want whois_error", st.Code)
}
}
func TestDomainExpiryRule_ValidateOptions(t *testing.T) {
rule := &domainExpiryRule{}
cases := []struct {
name string
opts happydns.CheckerOptions
wantErr bool
}{
{"defaults", nil, false},
{"valid custom", happydns.CheckerOptions{"warning_days": 30.0, "critical_days": 7.0}, false},
{"crit >= warn", happydns.CheckerOptions{"warning_days": 5.0, "critical_days": 5.0}, true},
{"warn wrong type", happydns.CheckerOptions{"warning_days": "thirty"}, true},
{"warn negative", happydns.CheckerOptions{"warning_days": -1.0}, true},
{"crit negative", happydns.CheckerOptions{"critical_days": -3.0}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := rule.ValidateOptions(tc.opts)
if (err != nil) != tc.wantErr {
t.Errorf("ValidateOptions err=%v, wantErr=%v", err, tc.wantErr)
}
})
}
}
func TestWhoisProvider_ExtractMetrics(t *testing.T) {
p := &whoisProvider{}
collected := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
data := WHOISData{
ExpiryDate: collected.Add(10 * 24 * time.Hour),
Registrar: "Acme",
}
raw, err := json.Marshal(data)
if err != nil {
t.Fatal(err)
}
metrics, err := p.ExtractMetrics(raw, collected)
if err != nil {
t.Fatal(err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 metric, got %d", len(metrics))
}
m := metrics[0]
if m.Name != "domain_expiry_days_remaining" {
t.Errorf("name = %q", m.Name)
}
if m.Value < 9.99 || m.Value > 10.01 {
t.Errorf("value = %v, want ~10", m.Value)
}
if m.Labels["registrar"] != "Acme" {
t.Errorf("registrar label = %q", m.Labels["registrar"])
}
}

View file

@ -1,165 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
"context"
"fmt"
"strings"
"time"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
const defaultRequiredLockStatuses = "clientTransferProhibited"
// domainLockRule verifies that a domain carries the expected EPP lock
// statuses (e.g. clientTransferProhibited) as reported by RDAP/WHOIS.
type domainLockRule struct{}
func (r *domainLockRule) Name() string {
return "domain_lock_check"
}
func (r *domainLockRule) Description() string {
return "Verifies that a domain carries the expected EPP lock statuses (e.g. clientTransferProhibited)"
}
func (r *domainLockRule) ValidateOptions(opts happydns.CheckerOptions) error {
v, ok := opts["requiredStatuses"]
if !ok {
return nil
}
s, ok := v.(string)
if !ok {
return fmt.Errorf("requiredStatuses must be a string")
}
for _, p := range strings.Split(s, ",") {
if strings.TrimSpace(p) != "" {
return nil
}
}
return fmt.Errorf("requiredStatuses must contain at least one EPP status code")
}
func (r *domainLockRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
var whois WHOISData
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
return happydns.CheckState{
Status: happydns.StatusError,
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
Code: "lock_error",
}
}
requiredStr := defaultRequiredLockStatuses
if v, ok := opts["requiredStatuses"].(string); ok && v != "" {
requiredStr = v
}
var required []string
for _, s := range strings.Split(requiredStr, ",") {
s = strings.TrimSpace(s)
if s != "" {
required = append(required, s)
}
}
if len(required) == 0 {
return happydns.CheckState{
Status: happydns.StatusUnknown,
Message: "No required lock statuses configured",
Code: "lock_skipped",
}
}
present := make(map[string]bool, len(whois.Status))
for _, s := range whois.Status {
present[strings.ToLower(s)] = true
}
var missing []string
for _, req := range required {
if !present[strings.ToLower(req)] {
missing = append(missing, req)
}
}
if len(missing) > 0 {
return happydns.CheckState{
Status: happydns.StatusCrit,
Message: fmt.Sprintf("Missing lock status: %s", strings.Join(missing, ", ")),
Code: "lock_missing",
Meta: map[string]any{
"missing": missing,
"present": whois.Status,
},
}
}
return happydns.CheckState{
Status: happydns.StatusOK,
Message: fmt.Sprintf("All required statuses present: %s", strings.Join(required, ", ")),
Code: "lock_ok",
Meta: map[string]any{
"required": required,
},
}
}
func init() {
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "domain_lock",
Name: "Domain Lock Status",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
Options: happydns.CheckerOptionsDocumentation{
DomainOpts: []happydns.CheckerOptionDocumentation{
{
Id: "domainName",
Type: "string",
AutoFill: happydns.AutoFillDomainName,
Hide: true,
},
{
Id: "requiredStatuses",
Type: "string",
Label: "Required lock statuses",
Description: "Comma-separated list of EPP status codes that must be present on the domain (e.g. clientTransferProhibited, clientUpdateProhibited, clientDeleteProhibited).",
Default: defaultRequiredLockStatuses,
Placeholder: defaultRequiredLockStatuses,
},
},
},
Rules: []happydns.CheckRule{
&domainLockRule{},
},
Interval: &happydns.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
})
}

View file

@ -1,150 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
"context"
"testing"
"git.happydns.org/happyDomain/model"
)
func TestDomainLockRule_Evaluate(t *testing.T) {
rule := &domainLockRule{}
cases := []struct {
name string
status []string
opts happydns.CheckerOptions
want happydns.Status
code string
}{
{
name: "default required present",
status: []string{"clientTransferProhibited", "ok"},
opts: nil,
want: happydns.StatusOK,
code: "lock_ok",
},
{
name: "default required missing",
status: []string{"ok"},
opts: nil,
want: happydns.StatusCrit,
code: "lock_missing",
},
{
name: "multiple required all present",
status: []string{"clientTransferProhibited", "clientUpdateProhibited", "clientDeleteProhibited"},
opts: happydns.CheckerOptions{
"requiredStatuses": "clientTransferProhibited,clientUpdateProhibited,clientDeleteProhibited",
},
want: happydns.StatusOK,
code: "lock_ok",
},
{
name: "multiple required some missing",
status: []string{"clientTransferProhibited"},
opts: happydns.CheckerOptions{
"requiredStatuses": "clientTransferProhibited,clientUpdateProhibited",
},
want: happydns.StatusCrit,
code: "lock_missing",
},
{
name: "case insensitive match",
status: []string{"clienttransferprohibited"},
opts: nil,
want: happydns.StatusOK,
code: "lock_ok",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
obs := newWhoisObs(&WHOISData{Status: tc.status})
st := rule.Evaluate(context.Background(), obs, tc.opts)
if st.Status != tc.want {
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
}
if st.Code != tc.code {
t.Errorf("code = %q, want %q", st.Code, tc.code)
}
})
}
}
// Sanity: WHOIS data with nil/empty status must report missing locks, not panic.
func TestDomainLockRule_NilStatus(t *testing.T) {
rule := &domainLockRule{}
cases := []struct {
name string
status []string
}{
{"nil status", nil},
{"empty status", []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
obs := newWhoisObs(&WHOISData{Status: tc.status})
st := rule.Evaluate(context.Background(), obs, nil)
if st.Status != happydns.StatusCrit {
t.Errorf("status = %v, want Crit", st.Status)
}
if st.Code != "lock_missing" {
t.Errorf("code = %q, want lock_missing", st.Code)
}
})
}
}
func TestDomainLockRule_EvaluateObservationError(t *testing.T) {
rule := &domainLockRule{}
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("nope")}
st := rule.Evaluate(context.Background(), obs, nil)
if st.Status != happydns.StatusError || st.Code != "lock_error" {
t.Errorf("got %v / %q", st.Status, st.Code)
}
}
func TestDomainLockRule_ValidateOptions(t *testing.T) {
rule := &domainLockRule{}
cases := []struct {
name string
opts happydns.CheckerOptions
wantErr bool
}{
{"default", nil, false},
{"valid", happydns.CheckerOptions{"requiredStatuses": "clientTransferProhibited"}, false},
{"empty after split", happydns.CheckerOptions{"requiredStatuses": " , , "}, true},
{"wrong type", happydns.CheckerOptions{"requiredStatuses": 123}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := rule.ValidateOptions(tc.opts)
if (err != nil) != tc.wantErr {
t.Errorf("err=%v wantErr=%v", err, tc.wantErr)
}
})
}
}

View file

@ -1,61 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
"context"
"encoding/json"
"git.happydns.org/happyDomain/model"
)
// stubObservationGetter is a test helper that serves a single pre-built
// observation, mimicking the SDK's mapObservationGetter (JSON round-trip).
type stubObservationGetter struct {
key happydns.ObservationKey
data any
err error
}
func (g *stubObservationGetter) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
if g.err != nil {
return g.err
}
if key != g.key {
return errNotFound
}
raw, err := json.Marshal(g.data)
if err != nil {
return err
}
return json.Unmarshal(raw, dest)
}
type errString string
func (e errString) Error() string { return string(e) }
const errNotFound = errString("observation not available")
func newWhoisObs(d *WHOISData) *stubObservationGetter {
return &stubObservationGetter{key: ObservationKeyWhois, data: d}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

View file

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

24
go.mod
View file

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

200
go.sum
View file

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

View file

@ -26,13 +26,11 @@ 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"
)
@ -152,11 +150,7 @@ func NewDNSControlProviderAdapter(configAdapter DNSControlConfigAdapter) (ret ha
auditor = p.RecordAuditor
}
return &DNSControlAdapterNSProvider{
DNSServiceProvider: provider,
RecordAuditor: auditor,
providerName: configAdapter.DNSControlName(),
}, nil
return &DNSControlAdapterNSProvider{provider, auditor}, nil
}
// DNSControlAdapterNSProvider wraps a DNSControl provider to implement the happyDomain ProviderActuator interface.
@ -166,8 +160,6 @@ 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).
@ -177,26 +169,6 @@ func (p *DNSControlAdapterNSProvider) CanListZones() bool {
return ok
}
// observeProviderCall starts timing a provider API call and returns a closure
// that records the outcome. Intended use:
//
// defer p.observeProviderCall("op")(&err)
//
// The returned closure reads *err at defer-execution time, so it observes the
// final value of the named return even if it is reassigned later in the
// function (including from a recover() block).
func (p *DNSControlAdapterNSProvider) observeProviderCall(operation string) func(err *error) {
start := time.Now()
return func(err *error) {
status := "success"
if err != nil && *err != nil {
status = "error"
}
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, operation, status).Inc()
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, operation).Observe(time.Since(start).Seconds())
}
}
// CanCreateDomain checks if the provider supports creating new domains.
// Returns true if the provider implements the ZoneCreator interface.
func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
@ -210,7 +182,6 @@ func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happydns.Record, err error) {
var records models.Records
defer p.observeProviderCall("get_zone_records")(&err)
defer func() {
if a := recover(); a != nil {
err = fmt.Errorf("%s", a)
@ -240,8 +211,6 @@ func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happy
// before computing corrections.
// Returns a slice of corrections, the total number of corrections needed, and any error.
func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []happydns.Record) (ret []*happydns.Correction, nbCorrections int, err error) {
defer p.observeProviderCall("get_zone_corrections")(&err)
var dc *models.DomainConfig
dc, err = NewDNSControlDomainConfig(strings.TrimSuffix(domain, "."), rrs)
if err != nil {
@ -292,31 +261,23 @@ 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) (err error) {
defer p.observeProviderCall("create_domain")(&err)
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) error {
zc, ok := p.DNSServiceProvider.(dnscontrol.ZoneCreator)
if !ok {
err = fmt.Errorf("Provider doesn't support domain creation.")
return
return fmt.Errorf("Provider doesn't support domain creation.")
}
err = zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
return
return zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
}
// 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() (zones []string, err error) {
defer p.observeProviderCall("list_zones")(&err)
func (p *DNSControlAdapterNSProvider) ListZones() ([]string, error) {
zl, ok := p.DNSServiceProvider.(dnscontrol.ZoneLister)
if !ok {
err = fmt.Errorf("Provider doesn't support domain listing.")
return
return nil, fmt.Errorf("Provider doesn't support domain listing.")
}
zones, err = zl.ListZones()
return
return zl.ListZones()
}

View file

@ -1,272 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package adapter provides tests for DNSControlAdapterNSProvider instrumentation.
// These tests live in the internal package so they can construct the struct
// directly and set the unexported providerName field used in metric labels.
package adapter
import (
"errors"
"testing"
dnscontrolmodels "github.com/StackExchange/dnscontrol/v4/models"
dnscontrol "github.com/StackExchange/dnscontrol/v4/pkg/providers"
"github.com/prometheus/client_golang/prometheus/testutil"
"git.happydns.org/happyDomain/internal/metrics"
)
// --- mock DNSServiceProvider -------------------------------------------------
// mockDNSProvider implements dnscontrol.DNSServiceProvider (i.e. models.DNSProvider).
type mockDNSProvider struct {
getZoneRecordsErr error
getZoneRecordsResult dnscontrolmodels.Records
correctionsErr error
panicOnGetZoneRecords bool
}
func (m *mockDNSProvider) GetNameservers(domain string) ([]*dnscontrolmodels.Nameserver, error) {
return nil, nil
}
func (m *mockDNSProvider) GetZoneRecords(domain string, meta map[string]string) (dnscontrolmodels.Records, error) {
if m.panicOnGetZoneRecords {
panic("simulated provider panic")
}
return m.getZoneRecordsResult, m.getZoneRecordsErr
}
func (m *mockDNSProvider) GetZoneRecordsCorrections(dc *dnscontrolmodels.DomainConfig, existing dnscontrolmodels.Records) ([]*dnscontrolmodels.Correction, int, error) {
return nil, 0, m.correctionsErr
}
// mockZoneLister extends mockDNSProvider with ZoneLister.
type mockZoneLister struct {
mockDNSProvider
listErr error
listResult []string
}
func (m *mockZoneLister) ListZones() ([]string, error) {
return m.listResult, m.listErr
}
// mockZoneCreator extends mockDNSProvider with ZoneCreator.
type mockZoneCreator struct {
mockDNSProvider
ensureErr error
}
func (m *mockZoneCreator) EnsureZoneExists(domain string, metadata map[string]string) error {
return m.ensureErr
}
// --- helpers -----------------------------------------------------------------
// noopAuditor is a RecordAuditor that approves all records.
func noopAuditor(rcs []*dnscontrolmodels.RecordConfig) []error { return nil }
// newTestAdapter constructs a DNSControlAdapterNSProvider with the given
// provider mock and a fixed providerName so metric labels are predictable.
func newTestAdapter(provider dnscontrol.DNSServiceProvider) *DNSControlAdapterNSProvider {
return &DNSControlAdapterNSProvider{
DNSServiceProvider: provider,
RecordAuditor: noopAuditor,
providerName: "TEST_PROVIDER",
}
}
// --- GetZoneRecords ----------------------------------------------------------
func TestObserveProviderCall_GetZoneRecords_Success(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockDNSProvider{})
_, err := a.GetZoneRecords("example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "success")); got != 1 {
t.Errorf("expected counter=1 for success, got %v", got)
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "error")); got != 0 {
t.Errorf("expected error counter=0, got %v", got)
}
}
func TestObserveProviderCall_GetZoneRecords_Error(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockDNSProvider{getZoneRecordsErr: errors.New("upstream timeout")})
_, err := a.GetZoneRecords("example.com.")
if err == nil {
t.Fatal("expected an error, got nil")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "error")); got != 1 {
t.Errorf("expected error counter=1, got %v", got)
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "success")); got != 0 {
t.Errorf("expected success counter=0, got %v", got)
}
}
func TestObserveProviderCall_GetZoneRecords_Panic(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockDNSProvider{panicOnGetZoneRecords: true})
// The recover() block in GetZoneRecords must catch the panic and return an
// error, which the observe closure then records as "error".
_, err := a.GetZoneRecords("example.com.")
if err == nil {
t.Fatal("expected panic to be recovered as an error, got nil")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "error")); got != 1 {
t.Errorf("expected error counter=1 after recovered panic, got %v", got)
}
}
// --- GetZoneCorrections ------------------------------------------------------
func TestObserveProviderCall_GetZoneCorrections_Success(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockDNSProvider{})
_, _, err := a.GetZoneCorrections("example.com.", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_corrections", "success")); got != 1 {
t.Errorf("expected counter=1 for success, got %v", got)
}
}
func TestObserveProviderCall_GetZoneCorrections_Error(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockDNSProvider{getZoneRecordsErr: errors.New("provider down")})
_, _, err := a.GetZoneCorrections("example.com.", nil)
if err == nil {
t.Fatal("expected an error, got nil")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_corrections", "error")); got != 1 {
t.Errorf("expected error counter=1, got %v", got)
}
}
// --- CreateDomain ------------------------------------------------------------
func TestObserveProviderCall_CreateDomain_NotSupported(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
// mockDNSProvider does not implement ZoneCreator, so CreateDomain must fail.
a := newTestAdapter(&mockDNSProvider{})
err := a.CreateDomain("example.com.")
if err == nil {
t.Fatal("expected error when provider does not support domain creation")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "create_domain", "error")); got != 1 {
t.Errorf("expected error counter=1, got %v", got)
}
}
func TestObserveProviderCall_CreateDomain_Success(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockZoneCreator{})
err := a.CreateDomain("example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "create_domain", "success")); got != 1 {
t.Errorf("expected success counter=1, got %v", got)
}
}
func TestObserveProviderCall_CreateDomain_Error(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockZoneCreator{ensureErr: errors.New("zone already exists")})
err := a.CreateDomain("example.com.")
if err == nil {
t.Fatal("expected an error, got nil")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "create_domain", "error")); got != 1 {
t.Errorf("expected error counter=1, got %v", got)
}
}
// --- ListZones ---------------------------------------------------------------
func TestObserveProviderCall_ListZones_NotSupported(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
// mockDNSProvider does not implement ZoneLister, so ListZones must fail.
a := newTestAdapter(&mockDNSProvider{})
_, err := a.ListZones()
if err == nil {
t.Fatal("expected error when provider does not support zone listing")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "list_zones", "error")); got != 1 {
t.Errorf("expected error counter=1, got %v", got)
}
}
func TestObserveProviderCall_ListZones_Success(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockZoneLister{listResult: []string{"example.com", "example.net"}})
zones, err := a.ListZones()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(zones) != 2 {
t.Errorf("expected 2 zones, got %d", len(zones))
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "list_zones", "success")); got != 1 {
t.Errorf("expected success counter=1, got %v", got)
}
}
func TestObserveProviderCall_ListZones_Error(t *testing.T) {
metrics.ProviderAPICallsTotal.Reset()
a := newTestAdapter(&mockZoneLister{listErr: errors.New("API rate limit")})
_, err := a.ListZones()
if err == nil {
t.Fatal("expected an error, got nil")
}
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "list_zones", "error")); got != 1 {
t.Errorf("expected error counter=1, got %v", got)
}
}

View file

@ -1,66 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
apicontroller "git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
)
// AdminCheckerController handles admin checker-related API endpoints.
// It embeds CheckerController and overrides GetCheckerOptions to return a flat
// (non-positional) map scoped to nil (global/admin) level.
type AdminCheckerController struct {
*apicontroller.CheckerController
}
// NewAdminCheckerController creates a new AdminCheckerController.
func NewAdminCheckerController(optionsUC *checkerUC.CheckerOptionsUsecase) *AdminCheckerController {
return &AdminCheckerController{
// countManualTriggers=false because admin has no budgetChecker (nil),
// which means the flag is inert on this path — value is for clarity.
CheckerController: apicontroller.NewCheckerController(nil, optionsUC, nil, nil, nil, nil, false),
}
}
// GetCheckerOptions returns admin-level options (nil scope) for a checker as a flat map.
//
// @Summary Get admin-level checker options
// @Tags admin,checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Success 200 {object} checker.CheckerOptions
// @Router /checkers/{checkerId}/options [get]
func (cc *AdminCheckerController) GetCheckerOptions(c *gin.Context) {
checkerID := c.Param("checkerId")
opts, err := cc.OptionsUC.GetCheckerOptions(checkerID, nil, nil, nil)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, opts)
}

View file

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

View file

@ -1,100 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
)
// AdminSchedulerController handles admin scheduler API endpoints.
type AdminSchedulerController struct {
scheduler *checkerUC.Scheduler
}
// NewAdminSchedulerController creates a new AdminSchedulerController.
func NewAdminSchedulerController(scheduler *checkerUC.Scheduler) *AdminSchedulerController {
return &AdminSchedulerController{scheduler: scheduler}
}
// GetSchedulerStatus returns the current scheduler status.
//
// @Summary Get scheduler status
// @Tags admin-scheduler
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} checkerUC.SchedulerStatus
// @Router /scheduler [get]
func (s *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) {
c.JSON(http.StatusOK, s.scheduler.GetStatus())
}
// EnableScheduler starts the scheduler and returns updated status.
//
// @Summary Enable the scheduler
// @Tags admin-scheduler
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} checkerUC.SchedulerStatus
// @Failure 500 {object} object
// @Router /scheduler/enable [post]
func (s *AdminSchedulerController) EnableScheduler(c *gin.Context) {
if err := s.scheduler.SetEnabled(c.Request.Context(), true); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, s.scheduler.GetStatus())
}
// DisableScheduler stops the scheduler and returns updated status.
//
// @Summary Disable the scheduler
// @Tags admin-scheduler
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} checkerUC.SchedulerStatus
// @Failure 500 {object} object
// @Router /scheduler/disable [post]
func (s *AdminSchedulerController) DisableScheduler(c *gin.Context) {
if err := s.scheduler.SetEnabled(c.Request.Context(), false); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, s.scheduler.GetStatus())
}
// RescheduleUpcoming rebuilds the job queue and returns the new count.
//
// @Summary Rebuild the scheduler queue
// @Tags admin-scheduler
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} map[string]int
// @Router /scheduler/reschedule-upcoming [post]
func (s *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) {
n := s.scheduler.RebuildQueue()
c.JSON(http.StatusOK, gin.H{"rescheduled": n})
}

View file

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

View file

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

View file

@ -1,51 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
)
func declareCheckersRoutes(router *gin.RouterGroup, dep Dependencies) {
if dep.CheckerOptionsUC == nil {
return
}
cc := controller.NewAdminCheckerController(dep.CheckerOptionsUC)
apiCheckersRoutes := router.Group("/checkers")
apiCheckersRoutes.GET("", cc.ListCheckers)
apiCheckerRoutes := apiCheckersRoutes.Group("/:checkerId")
apiCheckerRoutes.Use(cc.CheckerHandler)
apiCheckerRoutes.GET("", cc.GetChecker)
apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options")
apiCheckerOptionsRoutes.GET("", cc.GetCheckerOptions)
apiCheckerOptionsRoutes.POST("", cc.AddCheckerOptions)
apiCheckerOptionsRoutes.PUT("", cc.ChangeCheckerOptions)
apiCheckerOptionRoutes := apiCheckerOptionsRoutes.Group("/:optname")
apiCheckerOptionRoutes.GET("", cc.GetCheckerOption)
apiCheckerOptionRoutes.PUT("", cc.SetCheckerOption)
}

View file

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

View file

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

View file

@ -1,272 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"io"
"log"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
// CheckerController handles checker-related API endpoints.
type CheckerController struct {
engine happydns.CheckerEngine
OptionsUC *checkerUC.CheckerOptionsUsecase
planUC *checkerUC.CheckPlanUsecase
statusUC *checkerUC.CheckStatusUsecase
plannedProvider checkerUC.PlannedJobProvider
budgetChecker checkerUC.BudgetChecker
countManualTriggers bool
}
// NewCheckerController creates a new CheckerController.
//
// countManualTriggers controls whether manual (API-triggered) checker runs
// count against the user's MaxChecksPerDay budget. When true and
// budgetChecker is non-nil, TriggerCheck refuses the request with HTTP 429
// once the user is over budget and increments the counter on success.
// When false, manual triggers bypass the quota entirely (legacy behavior).
// The value is ignored when budgetChecker is nil.
func NewCheckerController(
engine happydns.CheckerEngine,
optionsUC *checkerUC.CheckerOptionsUsecase,
planUC *checkerUC.CheckPlanUsecase,
statusUC *checkerUC.CheckStatusUsecase,
plannedProvider checkerUC.PlannedJobProvider,
budgetChecker checkerUC.BudgetChecker,
countManualTriggers bool,
) *CheckerController {
return &CheckerController{
engine: engine,
OptionsUC: optionsUC,
planUC: planUC,
statusUC: statusUC,
plannedProvider: plannedProvider,
budgetChecker: budgetChecker,
countManualTriggers: countManualTriggers,
}
}
// StatusUC returns the CheckStatusUsecase for use by other controllers.
func (cc *CheckerController) StatusUC() *checkerUC.CheckStatusUsecase {
return cc.statusUC
}
// targetFromContext builds a CheckTarget from middleware context values.
func targetFromContext(c *gin.Context) happydns.CheckTarget {
user := middleware.MyUser(c)
target := happydns.CheckTarget{}
if user != nil {
target.UserId = user.Id.String()
}
if domain, exists := c.Get("domain"); exists {
d := domain.(*happydns.Domain)
target.DomainId = d.Id.String()
}
if sid, exists := c.Get("serviceid"); exists {
id := sid.(happydns.Identifier)
target.ServiceId = id.String()
if z, zExists := c.Get("zone"); zExists {
zone := z.(*happydns.Zone)
if _, svc := zone.FindService(id); svc != nil {
target.ServiceType = svc.Type
}
}
}
return target
}
// --- Global checker routes ---
// ListCheckers returns all registered checker definitions.
//
// @Summary List available checkers
// @Tags checkers
// @Produce json
// @Success 200 {object} map[string]checker.CheckerDefinition
// @Router /checkers [get]
func (cc *CheckerController) ListCheckers(c *gin.Context) {
c.JSON(http.StatusOK, checkerPkg.GetCheckers())
}
// GetChecker returns a specific checker definition.
//
// @Summary Get a checker definition
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Success 200 {object} checker.CheckerDefinition
// @Failure 404 {object} happydns.ErrorResponse
// @Router /checkers/{checkerId} [get]
func (cc *CheckerController) GetChecker(c *gin.Context) {
def, _ := c.Get("checker")
c.JSON(http.StatusOK, def)
}
// CheckerHandler is a middleware that validates the checkerId path parameter and sets "checker" in context.
func (cc *CheckerController) CheckerHandler(c *gin.Context) {
checkerID := c.Param("checkerId")
def := checkerPkg.FindChecker(checkerID)
if def == nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Checker not found"})
return
}
c.Set("checker", def)
c.Next()
}
// --- Scoped routes (domain/service) ---
// ListAvailableChecks lists all checkers with their latest status for a target.
//
// @Summary List available checks with status
// @Tags checkers
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {array} happydns.CheckerStatus
// @Router /domains/{domain}/checkers [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers [get]
func (cc *CheckerController) ListAvailableChecks(c *gin.Context) {
target := targetFromContext(c)
result, err := cc.statusUC.ListCheckerStatuses(target)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, result)
}
// TriggerCheck manually triggers a checker execution.
// By default the check runs asynchronously and returns an Execution (HTTP 202).
// Pass ?sync=true to block until the check completes and return a CheckEvaluation (HTTP 200).
//
// @Summary Trigger a manual check
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param sync query bool false "Run synchronously"
// @Param body body happydns.CheckerRunRequest false "Run request with options and enabled rules"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.CheckEvaluation
// @Success 202 {object} happydns.Execution
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 429 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [post]
func (cc *CheckerController) TriggerCheck(c *gin.Context) {
cname := c.Param("checkerId")
var req happydns.CheckerRunRequest
// Body is optional; io.EOF means no body was sent, which is valid (no custom options or rules).
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
target := targetFromContext(c)
if err := cc.OptionsUC.ValidateOptions(cname, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), req.Options, true); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
// Enforce the daily check quota on manual triggers when configured.
// Interval=0 means "long-interval" in UserGater terms, so manual triggers
// are only denied at the hard limit — never by interval-aware throttling.
if cc.countManualTriggers && cc.budgetChecker != nil && !cc.budgetChecker.AllowWithInterval(target, 0) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"errmsg": "daily check quota exhausted; try again after 00:00 UTC"})
return
}
// Build a temporary plan from enabled rules if provided.
var plan *happydns.CheckPlan
if len(req.EnabledRules) > 0 {
plan = &happydns.CheckPlan{
CheckerID: cname,
Target: target,
Enabled: req.EnabledRules,
}
}
exec, err := cc.engine.CreateExecution(cname, target, plan)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Count the manual execution against the user's daily budget. Mirrors
// the scheduler's onExecute semantics: increment after CreateExecution
// succeeds, regardless of whether RunExecution later fails.
if cc.countManualTriggers && cc.budgetChecker != nil {
cc.budgetChecker.IncrementUsage(target)
}
if c.Query("sync") == "true" {
eval, err := cc.engine.RunExecution(c.Request.Context(), exec, plan, req.Options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, eval)
} else {
go func() {
if _, err := cc.engine.RunExecution(context.WithoutCancel(c.Request.Context()), exec, plan, req.Options); err != nil {
log.Printf("async RunExecution error for checker %q execution %v: %v", cname, exec.Id, err)
}
}()
c.JSON(http.StatusAccepted, exec)
}
}
// GetExecutionStatus returns the status of an execution.
//
// @Summary Get execution status
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.Execution
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [get]
func (cc *CheckerController) GetExecutionStatus(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
c.JSON(http.StatusOK, exec)
}

View file

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

View file

@ -1,117 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"strings"
"testing"
"time"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
"git.happydns.org/happyDomain/model"
)
func TestRenderPrometheus_ParsesAsValidExposition(t *testing.T) {
// Include a label value with characters that fmt's %q would have escaped
// as \xNN / \uNNNN — sequences which are NOT valid in Prometheus text
// format. The output must still parse cleanly via the upstream parser.
out := renderPrometheus([]happydns.CheckMetric{
{
Name: "happydomain_check_latency_seconds",
Unit: "seconds",
Value: 0.123,
Timestamp: time.Unix(1700000000, 0),
Labels: map[string]string{
"target": "exämple.com", // non-ASCII
"note": "line1\nline2", // newline (must become \n)
"quoted": `he said "hi"`, // quotes
"slash": `a\b`, // backslash
},
},
{
Name: "happydomain_check_latency_seconds",
Value: 0.456,
Labels: map[string]string{
"target": "second.example",
},
},
})
p := expfmt.NewTextParser(model.LegacyValidation)
if _, err := p.TextToMetricFamilies(strings.NewReader(out)); err != nil {
t.Fatalf("renderPrometheus output is not valid Prometheus text format: %v\noutput:\n%s", err, out)
}
}
func TestRenderPrometheus_EscapesLabelValues(t *testing.T) {
out := renderPrometheus([]happydns.CheckMetric{{
Name: "x",
Value: 1,
Labels: map[string]string{
"a": `\`,
"b": `"`,
"c": "\n",
},
}})
if !strings.Contains(out, `a="\\"`) {
t.Errorf("backslash not escaped: %q", out)
}
if !strings.Contains(out, `b="\""`) {
t.Errorf("quote not escaped: %q", out)
}
if !strings.Contains(out, `c="\n"`) {
t.Errorf("newline not escaped: %q", out)
}
}
func TestRenderPrometheus_NoOpenMetricsDirectives(t *testing.T) {
out := renderPrometheus([]happydns.CheckMetric{{
Name: "x",
Unit: "seconds",
Value: 1,
}})
if strings.Contains(out, "# UNIT") {
t.Errorf("output contains OpenMetrics-only # UNIT directive incompatible with text/plain;version=0.0.4: %q", out)
}
}
func TestWantsPrometheusText(t *testing.T) {
cases := []struct {
accept string
want bool
}{
{"", false},
{"*/*", false},
{"application/json", false},
{"application/json, text/plain", false}, // explicit JSON wins
{"text/plain", true},
{"text/plain; version=0.0.4", true},
{"application/openmetrics-text; version=1.0.0", true},
}
for _, tc := range cases {
if got := wantsPrometheusText(tc.accept); got != tc.want {
t.Errorf("wantsPrometheusText(%q) = %v, want %v", tc.accept, got, tc.want)
}
}
}

View file

@ -1,223 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// GetCheckerOptions returns layered options for a checker, from least to most specific scope.
// The scope is determined by the route context (user-only at /api/checkers, domain/service at scoped routes).
//
// @Summary Get checker options by scope
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {array} happydns.CheckerOptionsPositional
// @Router /checkers/{checkerId}/options [get]
// @Router /domains/{domain}/checkers/{checkerId}/options [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [get]
func (cc *CheckerController) GetCheckerOptions(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
positionals, err := cc.OptionsUC.GetCheckerOptionsPositional(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId))
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if positionals == nil {
positionals = []*happydns.CheckerOptionsPositional{}
}
// Append auto-fill resolved values so the frontend can display them.
autoFillOpts, err := cc.OptionsUC.GetAutoFillOptions(checkerID, target)
if err == nil && autoFillOpts != nil {
positionals = append(positionals, &happydns.CheckerOptionsPositional{
CheckName: checkerID,
UserId: happydns.TargetIdentifier(target.UserId),
DomainId: happydns.TargetIdentifier(target.DomainId),
ServiceId: happydns.TargetIdentifier(target.ServiceId),
Options: autoFillOpts,
})
}
c.JSON(http.StatusOK, positionals)
}
// AddCheckerOptions partially merges options at the current scope.
//
// @Summary Merge checker options
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param options body checker.CheckerOptions true "Options to merge"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} checker.CheckerOptions
// @Router /checkers/{checkerId}/options [post]
// @Router /domains/{domain}/checkers/{checkerId}/options [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [post]
func (cc *CheckerController) AddCheckerOptions(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
var opts happydns.CheckerOptions
if err := c.ShouldBindJSON(&opts); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
merged, err := cc.OptionsUC.MergeCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), merged, false); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if _, err := cc.OptionsUC.AddCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, merged)
}
// ChangeCheckerOptions fully replaces options at the current scope.
//
// @Summary Replace checker options
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param options body checker.CheckerOptions true "Options to set"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} checker.CheckerOptions
// @Router /checkers/{checkerId}/options [put]
// @Router /domains/{domain}/checkers/{checkerId}/options [put]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [put]
func (cc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
var opts happydns.CheckerOptions
if err := c.ShouldBindJSON(&opts); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts, false); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if err := cc.OptionsUC.SetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, opts)
}
// GetCheckerOption returns a single option value at the current scope.
//
// @Summary Get a single checker option
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param optname path string true "Option name"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} any
// @Router /checkers/{checkerId}/options/{optname} [get]
// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [get]
func (cc *CheckerController) GetCheckerOption(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
optname := c.Param("optname")
val, err := cc.OptionsUC.GetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if val == nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Option not set"})
return
}
c.JSON(http.StatusOK, val)
}
// SetCheckerOption sets a single option value at the current scope.
//
// @Summary Set a single checker option
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param optname path string true "Option name"
// @Param value body any true "Option value"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} any
// @Router /checkers/{checkerId}/options/{optname} [put]
// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [put]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [put]
func (cc *CheckerController) SetCheckerOption(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
optname := c.Param("optname")
var value any
if err := c.ShouldBindJSON(&value); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
// Validate the full merged options after inserting the key.
existing, err := cc.OptionsUC.GetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId))
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
existing[optname] = value
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), existing, false); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if err := cc.OptionsUC.SetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname, value); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, value)
}

View file

@ -1,196 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// PlanHandler is a middleware that validates the planId path parameter,
// checks target scope, and sets "plan" in context.
func (cc *CheckerController) PlanHandler(c *gin.Context) {
planID, err := happydns.NewIdentifierFromString(c.Param("planId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid plan ID"})
return
}
plan, err := cc.planUC.GetCheckPlan(targetFromContext(c), planID)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"})
return
}
c.Set("plan", plan)
c.Next()
}
// ListCheckPlans returns all check plans for a domain.
//
// @Summary List check plans for a domain
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {array} happydns.CheckPlan
// @Router /domains/{domain}/checkers/{checkerId}/plans [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [get]
func (cc *CheckerController) ListCheckPlans(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
plans, err := cc.planUC.ListCheckPlansByTargetAndChecker(target, checkerID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, plans)
}
// CreateCheckPlan creates a new check plan.
//
// @Summary Create a check plan
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param plan body happydns.CheckPlan true "Check plan to create"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 201 {object} happydns.CheckPlan
// @Failure 400 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/plans [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [post]
func (cc *CheckerController) CreateCheckPlan(c *gin.Context) {
target := targetFromContext(c)
var plan happydns.CheckPlan
if err := c.ShouldBindJSON(&plan); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
plan.Target = target
plan.CheckerID = c.Param("checkerId")
if err := cc.planUC.CreateCheckPlan(&plan); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("cannot create check plan: %w", err))
return
}
c.JSON(http.StatusCreated, plan)
}
// GetCheckPlan returns a specific check plan.
//
// @Summary Get a check plan
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param planId path string true "Plan ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.CheckPlan
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [get]
func (cc *CheckerController) GetCheckPlan(c *gin.Context) {
plan := c.MustGet("plan").(*happydns.CheckPlan)
c.JSON(http.StatusOK, plan)
}
// UpdateCheckPlan updates an existing check plan.
//
// @Summary Update a check plan
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param planId path string true "Plan ID"
// @Param plan body happydns.CheckPlan true "Updated check plan"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.CheckPlan
// @Failure 400 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [put]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [put]
func (cc *CheckerController) UpdateCheckPlan(c *gin.Context) {
existing := c.MustGet("plan").(*happydns.CheckPlan)
var plan happydns.CheckPlan
if err := c.ShouldBindJSON(&plan); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
plan.Target = targetFromContext(c)
plan.CheckerID = c.Param("checkerId")
updated, err := cc.planUC.UpdateCheckPlan(plan.Target, existing.Id, &plan)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("cannot update check plan: %w", err))
return
}
c.JSON(http.StatusOK, updated)
}
// DeleteCheckPlan deletes a check plan.
//
// @Summary Delete a check plan
// @Tags checkers
// @Param checkerId path string true "Checker ID"
// @Param planId path string true "Plan ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 204
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [delete]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [delete]
func (cc *CheckerController) DeleteCheckPlan(c *gin.Context) {
plan := c.MustGet("plan").(*happydns.CheckPlan)
if err := cc.planUC.DeleteCheckPlan(targetFromContext(c), plan.Id); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"})
return
}
c.Status(http.StatusNoContent)
}

View file

@ -1,307 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
// ExecutionHandler is a middleware that validates the executionId path parameter,
// checks target scope, and sets "execution" in context.
func (cc *CheckerController) ExecutionHandler(c *gin.Context) {
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
return
}
exec, err := cc.statusUC.GetExecution(targetFromContext(c), execID)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
return
}
c.Set("execution", exec)
c.Next()
}
// ListExecutions returns executions for a checker on a target.
//
// @Summary List executions for a checker
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param limit query int false "Maximum number of results"
// @Param include_planned query bool false "Include upcoming planned executions from the scheduler"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {array} happydns.Execution
// @Router /domains/{domain}/checkers/{checkerId}/executions [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [get]
func (cc *CheckerController) ListExecutions(c *gin.Context) {
cname := c.Param("checkerId")
target := targetFromContext(c)
limit := getLimitParam(c, 0)
execs, err := cc.statusUC.ListExecutionsByChecker(cname, target, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if execs == nil {
execs = []*happydns.Execution{}
}
if c.Query("include_planned") == "true" || c.Query("include_planned") == "1" {
planned := checkerUC.ListPlannedExecutions(cc.plannedProvider, cc.budgetChecker, cname, target)
execs = append(planned, execs...)
}
c.JSON(http.StatusOK, execs)
}
// DeleteExecution deletes an execution record.
//
// @Summary Delete an execution
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 204
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [delete]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [delete]
func (cc *CheckerController) DeleteExecution(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
if err := cc.statusUC.DeleteExecution(targetFromContext(c), exec.Id); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}
// DeleteCheckerExecutions deletes all executions for a checker on a target.
//
// @Summary Delete all executions for a checker
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 204
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions [delete]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [delete]
func (cc *CheckerController) DeleteCheckerExecutions(c *gin.Context) {
cname := c.Param("checkerId")
target := targetFromContext(c)
if err := cc.statusUC.DeleteExecutionsByChecker(cname, target); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}
// GetExecutionObservations returns the observation snapshot for an execution.
//
// @Summary Get observations for an execution
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.ObservationSnapshot
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations [get]
func (cc *CheckerController) GetExecutionObservations(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
snap, err := cc.statusUC.GetObservationsByExecution(targetFromContext(c), exec.Id)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"})
return
}
c.JSON(http.StatusOK, snap)
}
// GetExecutionObservation returns a specific observation key from an execution's snapshot.
//
// @Summary Get a specific observation for an execution
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param obsKey path string true "Observation key"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} any
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get]
func (cc *CheckerController) GetExecutionObservation(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
obsKey := c.Param("obsKey")
val, err := cc.statusUC.GetSnapshotByExecution(targetFromContext(c), exec.Id, obsKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation not available"})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", val)
}
// GetExecutionResults returns the evaluation (per-rule states) for an execution.
//
// @Summary Get results for an execution
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.CheckEvaluation
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results [get]
func (cc *CheckerController) GetExecutionResults(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
eval, err := cc.statusUC.GetResultsByExecution(targetFromContext(c), exec.Id)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"})
return
}
c.JSON(http.StatusOK, eval)
}
// GetExecutionResult returns a specific rule's result from an execution.
//
// @Summary Get a specific rule result for an execution
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param ruleName path string true "Rule name"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} checker.CheckState
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get]
func (cc *CheckerController) GetExecutionResult(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
eval, err := cc.statusUC.GetResultsByExecution(targetFromContext(c), exec.Id)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"})
return
}
ruleName := c.Param("ruleName")
for _, state := range eval.States {
if state.Code == ruleName {
c.JSON(http.StatusOK, state)
return
}
}
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Rule result not found"})
}
// GetExecutionHTMLReport returns the HTML report for a specific observation of an execution.
//
// @Summary Get execution observation HTML report
// @Description Returns the full HTML document generated from an observation's data. Only available for observation providers that implement HTML reporting.
// @Tags checkers
// @Produce html
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param obsKey path string true "Observation key"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {string} string "HTML document"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
func (cc *CheckerController) GetExecutionHTMLReport(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
obsKey := c.Param("obsKey")
val, err := cc.statusUC.GetSnapshotByExecution(targetFromContext(c), exec.Id, obsKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation not available"})
return
}
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, val)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if !supported {
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("observation %q does not support HTML reports", obsKey))
return
}
c.Header("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'")
c.Header("X-Content-Type-Options", "nosniff")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
}

View file

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

View file

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

View file

@ -1,86 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
)
type DomainInfoController struct {
diuService happydns.DomainInfoUsecase
}
func NewDomainInfoController(diuService happydns.DomainInfoUsecase) *DomainInfoController {
return &DomainInfoController{
diuService: diuService,
}
}
// GetDomainInfo retrieves domain's administrative information.
//
// @Summary Get domain administrative information
// @Schemes
// @Description Retrieve domain's administrative information.
// @Tags domains
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Param domain path string true "Domain name"
// @Success 200 {object} happydns.DomainInfo
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domaininfo/{domain} [post]
func (dc *DomainInfoController) GetDomainInfo(c *gin.Context) {
domain := c.Param("domain")
if dn, ok := c.Get("domain"); ok {
domain = dn.(*happydns.Domain).DomainName
}
domain = dns.Fqdn(strings.TrimSpace(domain))
if domain == "." {
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: "empty domain name"})
return
}
if _, ok := dns.IsDomainName(domain); !ok {
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: "invalid domain name"})
return
}
info, err := dc.diuService.GetDomainInfo(c.Request.Context(), happydns.Origin(domain))
if err != nil {
if errors.Is(err, happydns.ErrDomainDoesNotExist) {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: err.Error()})
} else {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
}
return
}
c.JSON(http.StatusOK, info)
}

View file

@ -1,296 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/storage/inmemory"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
// --- Stub types for domain tests ---
// stubDomainUsecase implements happydns.DomainUsecase for testing.
type stubDomainUsecase struct {
domains []*happydns.Domain
err error
}
func (s *stubDomainUsecase) CreateDomain(ctx context.Context, user *happydns.User, input *happydns.DomainCreationInput) (*happydns.Domain, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) DeleteDomain(id happydns.Identifier) error {
return fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) ExtendsDomainWithZoneMeta(d *happydns.Domain) (*happydns.DomainWithZoneMetadata, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) GetUserDomain(user *happydns.User, id happydns.Identifier) (*happydns.Domain, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) GetUserDomainByFQDN(user *happydns.User, fqdn string) ([]*happydns.Domain, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) ListUserDomains(user *happydns.User) ([]*happydns.Domain, error) {
if s.err != nil {
return nil, s.err
}
return s.domains, nil
}
func (s *stubDomainUsecase) UpdateDomain(id happydns.Identifier, user *happydns.User, fn func(*happydns.Domain)) error {
return fmt.Errorf("not implemented")
}
// newDomainTestContext creates a gin context with a logged-in user and a recorder.
func newDomainTestContext(user *happydns.User) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/domains", nil)
if user != nil {
c.Set("LoggedUser", user)
}
return w, c
}
// --- GetDomains tests ---
func TestGetDomains_Unauthenticated(t *testing.T) {
dc := NewDomainController(&stubDomainUsecase{}, nil, nil, nil)
w, c := newDomainTestContext(nil)
dc.GetDomains(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
}
func TestGetDomains_ListError(t *testing.T) {
stub := &stubDomainUsecase{err: fmt.Errorf("db failure")}
dc := NewDomainController(stub, nil, nil, nil)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
func TestGetDomains_EmptyList(t *testing.T) {
stub := &stubDomainUsecase{domains: []*happydns.Domain{}}
dc := NewDomainController(stub, nil, nil, nil)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
var result []happydns.DomainWithCheckStatus
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(result) != 0 {
t.Errorf("expected 0 domains, got %d", len(result))
}
}
func TestGetDomains_NilCheckStatusUC(t *testing.T) {
did1, _ := happydns.NewRandomIdentifier()
did2, _ := happydns.NewRandomIdentifier()
stub := &stubDomainUsecase{
domains: []*happydns.Domain{
{Id: did1, DomainName: "example.com."},
{Id: did2, DomainName: "example.org."},
},
}
dc := NewDomainController(stub, nil, nil, nil)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
var result []happydns.DomainWithCheckStatus
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 domains, got %d", len(result))
}
for _, d := range result {
if d.LastCheckStatus != nil {
t.Errorf("expected nil LastCheckStatus when checkStatusUC is nil, got %v for domain %s", *d.LastCheckStatus, d.DomainName)
}
}
}
func TestGetDomains_WithCheckStatuses(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
did1, _ := happydns.NewRandomIdentifier()
did2, _ := happydns.NewRandomIdentifier()
did3, _ := happydns.NewRandomIdentifier()
stub := &stubDomainUsecase{
domains: []*happydns.Domain{
{Id: did1, DomainName: "warn.example.com.", Owner: uid},
{Id: did2, DomainName: "ok.example.com.", Owner: uid},
{Id: did3, DomainName: "unchecked.example.com.", Owner: uid},
},
}
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("failed to create in-memory store: %v", err)
}
statusUC := checkerUC.NewCheckStatusUsecase(store, store, store, store)
// Create executions: domain 1 has WARN, domain 2 has OK, domain 3 has none.
for _, tc := range []struct {
domainId happydns.Identifier
status happydns.Status
}{
{did1, happydns.StatusOK},
{did1, happydns.StatusWarn},
{did2, happydns.StatusOK},
} {
exec := &happydns.Execution{
CheckerID: "test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: tc.domainId.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: tc.status},
}
if err := store.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
dc := NewDomainController(stub, nil, nil, statusUC)
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
var result []happydns.DomainWithCheckStatus
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(result) != 3 {
t.Fatalf("expected 3 domains, got %d", len(result))
}
statusByDomain := make(map[string]*happydns.Status)
for _, d := range result {
statusByDomain[d.Id.String()] = d.LastCheckStatus
}
// Domain 1: worst is WARN.
if s := statusByDomain[did1.String()]; s == nil {
t.Error("expected non-nil status for domain 1 (warn.example.com)")
} else if *s != happydns.StatusWarn {
t.Errorf("expected WARN for domain 1, got %v", *s)
}
// Domain 2: worst is OK.
if s := statusByDomain[did2.String()]; s == nil {
t.Error("expected non-nil status for domain 2 (ok.example.com)")
} else if *s != happydns.StatusOK {
t.Errorf("expected OK for domain 2, got %v", *s)
}
// Domain 3: no executions → nil.
if s := statusByDomain[did3.String()]; s != nil {
t.Errorf("expected nil status for domain 3 (unchecked.example.com), got %v", *s)
}
}
func TestGetDomains_ResponseIncludesDomainFields(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
pid, _ := happydns.NewRandomIdentifier()
stub := &stubDomainUsecase{
domains: []*happydns.Domain{
{Id: did, DomainName: "test.example.com.", Owner: uid, ProviderId: pid, Group: "mygroup"},
},
}
dc := NewDomainController(stub, nil, nil, nil)
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
var result []json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(result) != 1 {
t.Fatalf("expected 1 domain, got %d", len(result))
}
// Verify the JSON contains the expected domain fields (embedded from *Domain).
var fields map[string]json.RawMessage
if err := json.Unmarshal(result[0], &fields); err != nil {
t.Fatalf("failed to unmarshal domain entry: %v", err)
}
for _, key := range []string{"id", "id_owner", "id_provider", "domain", "group"} {
if _, ok := fields[key]; !ok {
t.Errorf("expected field %q in response JSON", key)
}
}
// last_check_status should be omitted when nil (omitempty).
if _, ok := fields["last_check_status"]; ok {
t.Error("expected last_check_status to be omitted when nil")
}
}

View file

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

View file

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

View file

@ -1,130 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package middleware_test
import (
"bytes"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
"git.happydns.org/happyDomain/model"
)
func init() {
gin.SetMode(gin.TestMode)
}
// stubAuthUsecase is a no-op implementation of happydns.AuthenticationUsecase.
// The middleware should never reach a method of this stub when the token is a
// session ID, and should never reach it on a malformed JWT either (it returns
// after logging). We still assert it was not called by leaving the methods
// panicking — if any test trips one, we know the branching logic regressed.
type stubAuthUsecase struct{}
func (stubAuthUsecase) AuthenticateUserWithPassword(_ happydns.LoginRequest) (*happydns.User, error) {
panic("AuthenticateUserWithPassword should not be called in these tests")
}
func (stubAuthUsecase) CompleteAuthentication(_ happydns.UserInfo) (*happydns.User, error) {
panic("CompleteAuthentication should not be called in these tests")
}
// captureLog redirects the default logger to a buffer for the duration of fn.
func captureLog(t *testing.T, fn func()) string {
t.Helper()
var buf bytes.Buffer
prevOut := log.Writer()
prevFlags := log.Flags()
log.SetOutput(&buf)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(prevOut)
log.SetFlags(prevFlags)
})
fn()
return buf.String()
}
func newRouter() *gin.Engine {
r := gin.New()
r.Use(middleware.JwtAuthMiddleware(stubAuthUsecase{}, "HS256", []byte("test-secret")))
r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
return r
}
func Test_JwtAuthMiddleware_SessionIDTokenIsSilent(t *testing.T) {
r := newRouter()
output := captureLog(t, func() {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+sessionUC.NewSessionID())
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
})
if strings.Contains(output, "bad JWT claims") {
t.Errorf("expected no %q log for a session-ID token, got:\n%s", "bad JWT claims", output)
}
if strings.TrimSpace(output) != "" {
t.Errorf("expected no log output at all for a session-ID token, got:\n%s", output)
}
}
func Test_JwtAuthMiddleware_MalformedTokenStillLogs(t *testing.T) {
r := newRouter()
output := captureLog(t, func() {
req := httptest.NewRequest(http.MethodGet, "/", nil)
// Contains a dot, so it can't match the session-ID shape and will be
// routed to the JWT parser, which will fail.
req.Header.Set("Authorization", "Bearer not.a.jwt")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
})
if !strings.Contains(output, "bad JWT claims") {
t.Errorf("expected %q log for a malformed JWT, got:\n%s", "bad JWT claims", output)
}
}
func Test_JwtAuthMiddleware_NoAuthHeaderIsSilent(t *testing.T) {
r := newRouter()
output := captureLog(t, func() {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
})
if strings.TrimSpace(output) != "" {
t.Errorf("expected no log output without an Authorization header, got:\n%s", output)
}
}

View file

@ -1,116 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
// declareCheckerOptionsRoutes registers the options sub-routes on a checker group.
func declareCheckerOptionsRoutes(checkerID *gin.RouterGroup, cc *controller.CheckerController) {
checkerID.GET("/options", cc.GetCheckerOptions)
checkerID.POST("/options", cc.AddCheckerOptions)
checkerID.PUT("/options", cc.ChangeCheckerOptions)
checkerID.GET("/options/:optname", cc.GetCheckerOption)
checkerID.PUT("/options/:optname", cc.SetCheckerOption)
}
// DeclareCheckerRoutes registers global checker routes under /api/checkers.
// Returns the controller so it can be reused for scoped routes.
func DeclareCheckerRoutes(
apiRoutes *gin.RouterGroup,
engine happydns.CheckerEngine,
optionsUC *checkerUC.CheckerOptionsUsecase,
planUC *checkerUC.CheckPlanUsecase,
statusUC *checkerUC.CheckStatusUsecase,
plannedProvider checkerUC.PlannedJobProvider,
budgetChecker checkerUC.BudgetChecker,
countManualTriggers bool,
) *controller.CheckerController {
cc := controller.NewCheckerController(engine, optionsUC, planUC, statusUC, plannedProvider, budgetChecker, countManualTriggers)
// Global: /api/checkers
checkers := apiRoutes.Group("/checkers")
checkers.GET("", cc.ListCheckers)
checkers.GET("/metrics", cc.GetUserMetrics)
checkerID := checkers.Group("/:checkerId")
checkerID.Use(cc.CheckerHandler)
checkerID.GET("", cc.GetChecker)
declareCheckerOptionsRoutes(checkerID, cc)
return cc
}
// DeclareScopedCheckerRoutes registers checker routes scoped to a domain or service.
// Called for both /api/domains/:domain/checkers and .../services/:serviceid/checkers.
func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController) {
checkers := scopedRouter.Group("/checkers")
checkers.GET("", cc.ListAvailableChecks)
checkers.GET("/metrics", cc.GetDomainMetrics)
checkerID := checkers.Group("/:checkerId")
checkerID.Use(cc.CheckerHandler)
declareCheckerOptionsRoutes(checkerID, cc)
// Plans (schedules).
checkerID.GET("/plans", cc.ListCheckPlans)
checkerID.POST("/plans", cc.CreateCheckPlan)
planID := checkerID.Group("/plans/:planId")
planID.Use(cc.PlanHandler)
planID.GET("", cc.GetCheckPlan)
planID.PUT("", cc.UpdateCheckPlan)
planID.DELETE("", cc.DeleteCheckPlan)
// Per-checker metrics.
checkerID.GET("/metrics", cc.GetCheckerMetrics)
// Executions.
executions := checkerID.Group("/executions")
executions.GET("", cc.ListExecutions)
executions.POST("", cc.TriggerCheck)
executions.DELETE("", cc.DeleteCheckerExecutions)
executionID := executions.Group("/:executionId")
executionID.Use(cc.ExecutionHandler)
executionID.GET("", cc.GetExecutionStatus)
executionID.DELETE("", cc.DeleteExecution)
// Metrics (under execution).
executionID.GET("/metrics", cc.GetExecutionMetrics)
// Observations (under execution).
executionID.GET("/observations", cc.GetExecutionObservations)
executionID.GET("/observations/:obsKey", cc.GetExecutionObservation)
executionID.GET("/observations/:obsKey/report", cc.GetExecutionHTMLReport)
// Results (under execution).
executionID.GET("/results", cc.GetExecutionResults)
executionID.GET("/results/:ruleName", cc.GetExecutionResult)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,557 +0,0 @@
// Code generated by go run tools/gen_instrumented_storage.go; DO NOT EDIT.
package app
import (
"time"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/internal/storage"
happydns "git.happydns.org/happyDomain/model"
)
// instrumentedStorage wraps a storage.Storage to record Prometheus metrics for
// every operation.
type instrumentedStorage struct {
inner storage.Storage
}
// newInstrumentedStorage wraps the given Storage with metrics instrumentation.
func newInstrumentedStorage(s storage.Storage) storage.Storage {
return &instrumentedStorage{inner: s}
}
// observe starts a timer and returns a closure that, when called with a
// pointer to the named return error, records the operation outcome. Use as:
//
// defer observe("get", "user")(&err)
//
// The closure reads *err at defer-execution time, so it captures the final
// value of the named return.
func observe(operation, entity string) func(err *error) {
start := time.Now()
return func(err *error) {
status := "success"
if *err != nil {
status = "error"
}
metrics.StorageOperationsTotal.WithLabelValues(operation, entity, status).Inc()
metrics.StorageOperationDuration.WithLabelValues(operation, entity).Observe(time.Since(start).Seconds())
}
}
func (s *instrumentedStorage) AuthUserExists(email string) (ret bool, err error) {
defer observe("get", "authuser")(&err)
return s.inner.AuthUserExists(email)
}
func (s *instrumentedStorage) ClearAuthUsers() (err error) {
defer observe("delete", "authuser")(&err)
return s.inner.ClearAuthUsers()
}
func (s *instrumentedStorage) ClearCheckPlans() (err error) {
defer observe("delete", "check_plan")(&err)
return s.inner.ClearCheckPlans()
}
func (s *instrumentedStorage) ClearCheckerConfigurations() (err error) {
defer observe("delete", "check_config")(&err)
return s.inner.ClearCheckerConfigurations()
}
func (s *instrumentedStorage) ClearDomains() (err error) {
defer observe("delete", "domain")(&err)
return s.inner.ClearDomains()
}
func (s *instrumentedStorage) ClearEvaluations() (err error) {
defer observe("delete", "check_evaluation")(&err)
return s.inner.ClearEvaluations()
}
func (s *instrumentedStorage) ClearExecutions() (err error) {
defer observe("delete", "execution")(&err)
return s.inner.ClearExecutions()
}
func (s *instrumentedStorage) ClearProviders() (err error) {
defer observe("delete", "provider")(&err)
return s.inner.ClearProviders()
}
func (s *instrumentedStorage) ClearSessions() (err error) {
defer observe("delete", "session")(&err)
return s.inner.ClearSessions()
}
func (s *instrumentedStorage) ClearSnapshots() (err error) {
defer observe("delete", "observation_snapshot")(&err)
return s.inner.ClearSnapshots()
}
func (s *instrumentedStorage) ClearUsers() (err error) {
defer observe("delete", "user")(&err)
return s.inner.ClearUsers()
}
func (s *instrumentedStorage) ClearZones() (err error) {
defer observe("delete", "zone")(&err)
return s.inner.ClearZones()
}
func (s *instrumentedStorage) Close() error { return s.inner.Close() }
func (s *instrumentedStorage) CountDomains() (ret int, err error) {
defer observe("count", "domain")(&err)
return s.inner.CountDomains()
}
func (s *instrumentedStorage) CountProviders() (ret int, err error) {
defer observe("count", "provider")(&err)
return s.inner.CountProviders()
}
func (s *instrumentedStorage) CountUsers() (ret int, err error) {
defer observe("count", "user")(&err)
return s.inner.CountUsers()
}
func (s *instrumentedStorage) CountZones() (ret int, err error) {
defer observe("count", "zone")(&err)
return s.inner.CountZones()
}
func (s *instrumentedStorage) CreateAuthUser(user *happydns.UserAuth) (err error) {
defer observe("create", "authuser")(&err)
return s.inner.CreateAuthUser(user)
}
func (s *instrumentedStorage) CreateCheckPlan(plan *happydns.CheckPlan) (err error) {
defer observe("create", "check_plan")(&err)
return s.inner.CreateCheckPlan(plan)
}
func (s *instrumentedStorage) CreateDomain(domain *happydns.Domain) (err error) {
defer observe("create", "domain")(&err)
return s.inner.CreateDomain(domain)
}
func (s *instrumentedStorage) CreateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
defer observe("create", "domain_log")(&err)
return s.inner.CreateDomainLog(domain, log)
}
func (s *instrumentedStorage) CreateEvaluation(eval *happydns.CheckEvaluation) (err error) {
defer observe("create", "check_evaluation")(&err)
return s.inner.CreateEvaluation(eval)
}
func (s *instrumentedStorage) CreateExecution(exec *happydns.Execution) (err error) {
defer observe("create", "execution")(&err)
return s.inner.CreateExecution(exec)
}
func (s *instrumentedStorage) CreateOrUpdateUser(user *happydns.User) (err error) {
defer observe("update", "user")(&err)
return s.inner.CreateOrUpdateUser(user)
}
func (s *instrumentedStorage) CreateProvider(prvd *happydns.Provider) (err error) {
defer observe("create", "provider")(&err)
return s.inner.CreateProvider(prvd)
}
func (s *instrumentedStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) (err error) {
defer observe("create", "observation_snapshot")(&err)
return s.inner.CreateSnapshot(snap)
}
func (s *instrumentedStorage) CreateZone(zone *happydns.Zone) (err error) {
defer observe("create", "zone")(&err)
return s.inner.CreateZone(zone)
}
func (s *instrumentedStorage) DeleteAuthUser(user *happydns.UserAuth) (err error) {
defer observe("delete", "authuser")(&err)
return s.inner.DeleteAuthUser(user)
}
func (s *instrumentedStorage) DeleteCheckPlan(planID happydns.Identifier) (err error) {
defer observe("delete", "check_plan")(&err)
return s.inner.DeleteCheckPlan(planID)
}
func (s *instrumentedStorage) DeleteCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) (err error) {
defer observe("delete", "check_config")(&err)
return s.inner.DeleteCheckerConfiguration(checkerName, userId, domainId, serviceId)
}
func (s *instrumentedStorage) DeleteDomain(domainid happydns.Identifier) (err error) {
defer observe("delete", "domain")(&err)
return s.inner.DeleteDomain(domainid)
}
func (s *instrumentedStorage) DeleteDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
defer observe("delete", "domain_log")(&err)
return s.inner.DeleteDomainLog(domain, log)
}
func (s *instrumentedStorage) DeleteEvaluation(evalID happydns.Identifier) (err error) {
defer observe("delete", "check_evaluation")(&err)
return s.inner.DeleteEvaluation(evalID)
}
func (s *instrumentedStorage) DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) (err error) {
defer observe("delete", "check_evaluation")(&err)
return s.inner.DeleteEvaluationsByChecker(checkerID, target)
}
func (s *instrumentedStorage) DeleteExecution(execID happydns.Identifier) (err error) {
defer observe("delete", "execution")(&err)
return s.inner.DeleteExecution(execID)
}
func (s *instrumentedStorage) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) (err