Compare commits
50 commits
e4abb59e83
...
b1d1353b93
| Author | SHA1 | Date | |
|---|---|---|---|
| b1d1353b93 | |||
| 4f20a2ff06 | |||
| 57de739f80 | |||
| e857b1fb99 | |||
| 504660367e | |||
| 35d4d84004 | |||
| 1ca806852e | |||
| 7899b2f0e8 | |||
| 5660003311 | |||
| 7ac13175c6 | |||
| b4c6492936 | |||
| b7beefed3f | |||
| 5de221411e | |||
| 6555df1f4d | |||
| 26bbdfdcc4 | |||
| 313434f883 | |||
| a531a6ef66 | |||
| 8a546a8b41 | |||
| e0dc19614f | |||
| 561d885c6f | |||
| f8e1633a29 | |||
| 166c7e864c | |||
| 97d9e2872f | |||
| 030ff6dc61 | |||
| 6aa25c74c0 | |||
| 8876b3972a | |||
| 9bd11fff4c | |||
| ec1ae64e4d | |||
| ce925af579 | |||
| 2402c61455 | |||
| 4ce6728a87 | |||
| 524e83d056 | |||
| 98642bf7e7 | |||
| 33ef6b60e7 | |||
| 439fe2a2e9 | |||
| fb02331f41 | |||
| 4e034b0135 | |||
| 322ade2881 | |||
| 6ed3b3f6ed | |||
| b37ed1d349 | |||
| 7a4de13ac6 | |||
| ee560a699d | |||
| ee8e7b322d | |||
| 4bee15e684 | |||
| b18f3d737e | |||
| dc8143628a | |||
| 76af934782 | |||
| 71b0c7fdaf | |||
| 8a826b1e8f | |||
| 07d4c244d1 |
229 changed files with 31058 additions and 419 deletions
256
checkers/domain_contact.go
Normal file
256
checkers/domain_contact.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// redactedPatterns is the list of substrings that, when found in a contact
|
||||
// field, indicate the data is privacy-protected rather than meaningful.
|
||||
var redactedPatterns = []string{
|
||||
"redacted",
|
||||
"privacy",
|
||||
"not disclosed",
|
||||
"whoisguard",
|
||||
"withheld",
|
||||
"data protected",
|
||||
"contact privacy",
|
||||
}
|
||||
|
||||
// domainContactRule compares the registered domain contacts (registrant,
|
||||
// admin, tech) against user-supplied expected values, with redaction
|
||||
// detection for privacy-protected domains.
|
||||
type domainContactRule struct{}
|
||||
|
||||
func (r *domainContactRule) Name() string {
|
||||
return "domain_contact_check"
|
||||
}
|
||||
|
||||
func (r *domainContactRule) Description() string {
|
||||
return "Verifies that domain contacts (name/organization/email) match expected values"
|
||||
}
|
||||
|
||||
// validRoles enumerates the contact roles supported by the WHOIS observation.
|
||||
var validRoles = map[string]bool{
|
||||
"registrant": true,
|
||||
"admin": true,
|
||||
"tech": true,
|
||||
}
|
||||
|
||||
func (r *domainContactRule) ValidateOptions(opts happydns.CheckerOptions) error {
|
||||
for _, key := range []string{"expectedName", "expectedOrganization", "expectedEmail", "checkRoles"} {
|
||||
if v, ok := opts[key]; ok {
|
||||
if _, ok := v.(string); !ok {
|
||||
return fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := opts["checkRoles"].(string); ok && v != "" {
|
||||
hasOne := false
|
||||
for _, p := range strings.Split(v, ",") {
|
||||
role := strings.TrimSpace(p)
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
if !validRoles[role] {
|
||||
return fmt.Errorf("checkRoles: unknown role %q (allowed: registrant, admin, tech)", role)
|
||||
}
|
||||
hasOne = true
|
||||
}
|
||||
if !hasOne {
|
||||
return fmt.Errorf("checkRoles must contain at least one role")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *domainContactRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
|
||||
var whois WHOISData
|
||||
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusError,
|
||||
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
|
||||
Code: "contact_error",
|
||||
}
|
||||
}
|
||||
|
||||
expectedName, _ := opts["expectedName"].(string)
|
||||
expectedOrg, _ := opts["expectedOrganization"].(string)
|
||||
expectedEmail, _ := opts["expectedEmail"].(string)
|
||||
|
||||
if expectedName == "" && expectedOrg == "" && expectedEmail == "" {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusUnknown,
|
||||
Message: "No expected contact values configured",
|
||||
Code: "contact_skipped",
|
||||
}
|
||||
}
|
||||
|
||||
checkRolesStr := "registrant"
|
||||
if v, ok := opts["checkRoles"].(string); ok && v != "" {
|
||||
checkRolesStr = v
|
||||
}
|
||||
|
||||
var roles []string
|
||||
for s := range strings.SplitSeq(checkRolesStr, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
roles = append(roles, s)
|
||||
}
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusUnknown,
|
||||
Message: "No contact roles to check",
|
||||
Code: "contact_skipped",
|
||||
}
|
||||
}
|
||||
|
||||
worst := happydns.StatusOK
|
||||
var lines []string
|
||||
|
||||
for _, role := range roles {
|
||||
contact, found := whois.Contacts[role]
|
||||
if !found || contact == nil {
|
||||
lines = append(lines, fmt.Sprintf("%s: contact not found", role))
|
||||
worst = worseStatus(worst, happydns.StatusWarn)
|
||||
continue
|
||||
}
|
||||
|
||||
if isRedacted(contact) {
|
||||
lines = append(lines, fmt.Sprintf("%s: contact info is redacted/private", role))
|
||||
worst = worseStatus(worst, happydns.StatusInfo)
|
||||
continue
|
||||
}
|
||||
|
||||
var mismatches []string
|
||||
if expectedName != "" && !strings.EqualFold(expectedName, contact.Name) {
|
||||
mismatches = append(mismatches, fmt.Sprintf("name: got %q, expected %q", contact.Name, expectedName))
|
||||
}
|
||||
if expectedOrg != "" && !strings.EqualFold(expectedOrg, contact.Organization) {
|
||||
mismatches = append(mismatches, fmt.Sprintf("organization: got %q, expected %q", contact.Organization, expectedOrg))
|
||||
}
|
||||
if expectedEmail != "" && !strings.EqualFold(expectedEmail, contact.Email) {
|
||||
mismatches = append(mismatches, fmt.Sprintf("email: got %q, expected %q", contact.Email, expectedEmail))
|
||||
}
|
||||
|
||||
if len(mismatches) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("%s: %s", role, strings.Join(mismatches, ", ")))
|
||||
worst = worseStatus(worst, happydns.StatusWarn)
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("%s: contact info matches", role))
|
||||
}
|
||||
}
|
||||
|
||||
return happydns.CheckState{
|
||||
Status: worst,
|
||||
Message: strings.Join(lines, "; "),
|
||||
Code: "contact_result",
|
||||
}
|
||||
}
|
||||
|
||||
// isRedacted reports whether a contact's fields look privacy-protected.
|
||||
func isRedacted(c *happydns.ContactInfo) bool {
|
||||
for _, field := range []string{c.Name, c.Organization, c.Email} {
|
||||
lower := strings.ToLower(field)
|
||||
for _, pattern := range redactedPatterns {
|
||||
if strings.Contains(lower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// worseStatus returns the more severe of two statuses. The SDK orders
|
||||
// statuses as Unknown < OK < Info < Warn < Crit < Error, so the higher
|
||||
// numeric value is the more severe one.
|
||||
func worseStatus(a, b happydns.Status) happydns.Status {
|
||||
if b > a {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func init() {
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "domain_contact",
|
||||
Name: "Domain Contact Consistency",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
DomainOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domainName",
|
||||
Type: "string",
|
||||
AutoFill: happydns.AutoFillDomainName,
|
||||
Hide: true,
|
||||
},
|
||||
{
|
||||
Id: "expectedName",
|
||||
Type: "string",
|
||||
Label: "Expected registrant name",
|
||||
Description: "If set, the configured roles must report this exact name (case-insensitive).",
|
||||
},
|
||||
{
|
||||
Id: "expectedOrganization",
|
||||
Type: "string",
|
||||
Label: "Expected organization",
|
||||
Description: "If set, the configured roles must report this exact organization (case-insensitive).",
|
||||
},
|
||||
{
|
||||
Id: "expectedEmail",
|
||||
Type: "string",
|
||||
Label: "Expected email",
|
||||
Description: "If set, the configured roles must report this exact email (case-insensitive).",
|
||||
},
|
||||
{
|
||||
Id: "checkRoles",
|
||||
Type: "string",
|
||||
Label: "Contact roles to check",
|
||||
Description: "Comma-separated list of roles among: registrant, admin, tech.",
|
||||
Default: "registrant",
|
||||
Placeholder: "registrant",
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&domainContactRule{},
|
||||
},
|
||||
Interval: &happydns.CheckIntervalSpec{
|
||||
Min: 1 * time.Hour,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 24 * time.Hour,
|
||||
},
|
||||
})
|
||||
}
|
||||
211
checkers/domain_contact_test.go
Normal file
211
checkers/domain_contact_test.go
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func contactsFixture() map[string]*happydns.ContactInfo {
|
||||
return map[string]*happydns.ContactInfo{
|
||||
"registrant": {
|
||||
Name: "Alice Example",
|
||||
Organization: "Example Inc",
|
||||
Email: "alice@example.com",
|
||||
},
|
||||
"admin": {
|
||||
Name: "REDACTED FOR PRIVACY",
|
||||
Organization: "REDACTED FOR PRIVACY",
|
||||
Email: "redacted@example.com",
|
||||
},
|
||||
"tech": {
|
||||
Name: "Bob Tech",
|
||||
Organization: "Example Inc",
|
||||
Email: "bob@example.com",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainContactRule_Evaluate(t *testing.T) {
|
||||
rule := &domainContactRule{}
|
||||
obs := newWhoisObs(&WHOISData{Contacts: contactsFixture()})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts happydns.CheckerOptions
|
||||
want happydns.Status
|
||||
code string
|
||||
}{
|
||||
{
|
||||
name: "no expectations",
|
||||
opts: nil,
|
||||
want: happydns.StatusUnknown,
|
||||
code: "contact_skipped",
|
||||
},
|
||||
{
|
||||
name: "registrant matches",
|
||||
opts: happydns.CheckerOptions{
|
||||
"expectedName": "Alice Example",
|
||||
"expectedEmail": "alice@example.com",
|
||||
},
|
||||
want: happydns.StatusOK,
|
||||
code: "contact_result",
|
||||
},
|
||||
{
|
||||
name: "registrant name mismatch",
|
||||
opts: happydns.CheckerOptions{
|
||||
"expectedName": "Carol Other",
|
||||
},
|
||||
want: happydns.StatusWarn,
|
||||
code: "contact_result",
|
||||
},
|
||||
{
|
||||
name: "admin role is redacted",
|
||||
opts: happydns.CheckerOptions{
|
||||
"checkRoles": "admin",
|
||||
"expectedName": "Alice Example",
|
||||
},
|
||||
want: happydns.StatusInfo,
|
||||
code: "contact_result",
|
||||
},
|
||||
{
|
||||
name: "missing role",
|
||||
opts: happydns.CheckerOptions{
|
||||
"checkRoles": "billing",
|
||||
"expectedName": "Alice Example",
|
||||
},
|
||||
want: happydns.StatusWarn,
|
||||
code: "contact_result",
|
||||
},
|
||||
{
|
||||
name: "multi-role mixed (worst wins)",
|
||||
opts: happydns.CheckerOptions{
|
||||
"checkRoles": "registrant,admin",
|
||||
"expectedName": "Alice Example",
|
||||
},
|
||||
// admin is redacted (Info) — Info is worse than OK from registrant.
|
||||
want: happydns.StatusInfo,
|
||||
code: "contact_result",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st := rule.Evaluate(context.Background(), obs, tc.opts)
|
||||
if st.Status != tc.want {
|
||||
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
|
||||
}
|
||||
if st.Code != tc.code {
|
||||
t.Errorf("code = %q, want %q", st.Code, tc.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainContactRule_EvaluateObservationError(t *testing.T) {
|
||||
rule := &domainContactRule{}
|
||||
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("nope")}
|
||||
st := rule.Evaluate(context.Background(), obs, happydns.CheckerOptions{"expectedName": "x"})
|
||||
if st.Status != happydns.StatusError || st.Code != "contact_error" {
|
||||
t.Errorf("got %v / %q", st.Status, st.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainContactRule_ValidateOptions(t *testing.T) {
|
||||
rule := &domainContactRule{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts happydns.CheckerOptions
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", nil, false},
|
||||
{"all valid", happydns.CheckerOptions{
|
||||
"expectedName": "x",
|
||||
"checkRoles": "registrant,tech",
|
||||
}, false},
|
||||
{"unknown role", happydns.CheckerOptions{"checkRoles": "billing"}, true},
|
||||
{"empty roles after split", happydns.CheckerOptions{"checkRoles": " , , "}, true},
|
||||
{"wrong type", happydns.CheckerOptions{"expectedEmail": 42}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := rule.ValidateOptions(tc.opts)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("err=%v wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRedacted(t *testing.T) {
|
||||
cases := []struct {
|
||||
c *happydns.ContactInfo
|
||||
want bool
|
||||
}{
|
||||
{&happydns.ContactInfo{Name: "REDACTED FOR PRIVACY"}, true},
|
||||
{&happydns.ContactInfo{Organization: "Contact Privacy Inc"}, true},
|
||||
{&happydns.ContactInfo{Email: "withheld@example.com"}, true},
|
||||
{&happydns.ContactInfo{Name: "Alice", Email: "alice@example.com"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isRedacted(tc.c); got != tc.want {
|
||||
t.Errorf("isRedacted(%+v) = %v, want %v", tc.c, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorseStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b, want happydns.Status
|
||||
}{
|
||||
{happydns.StatusOK, happydns.StatusInfo, happydns.StatusInfo},
|
||||
{happydns.StatusInfo, happydns.StatusWarn, happydns.StatusWarn},
|
||||
{happydns.StatusCrit, happydns.StatusWarn, happydns.StatusCrit},
|
||||
{happydns.StatusOK, happydns.StatusUnknown, happydns.StatusOK},
|
||||
{happydns.StatusError, happydns.StatusCrit, happydns.StatusError},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := worseStatus(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("worseStatus(%v,%v) = %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: WHOIS data with no contacts must not panic when a role is requested.
|
||||
func TestDomainContactRule_NilContacts(t *testing.T) {
|
||||
rule := &domainContactRule{}
|
||||
obs := newWhoisObs(&WHOISData{})
|
||||
st := rule.Evaluate(context.Background(), obs, happydns.CheckerOptions{
|
||||
"expectedName": "Alice",
|
||||
})
|
||||
if st.Status != happydns.StatusWarn {
|
||||
t.Errorf("status = %v, want Warn", st.Status)
|
||||
}
|
||||
if !strings.Contains(st.Message, "not found") {
|
||||
t.Errorf("message = %q", st.Message)
|
||||
}
|
||||
}
|
||||
237
checkers/domain_expiry.go
Normal file
237
checkers/domain_expiry.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
"git.happydns.org/happyDomain/pkg/domaininfo"
|
||||
)
|
||||
|
||||
const (
|
||||
// ObservationKeyWhois is the observation key for WHOIS / domain expiry data.
|
||||
ObservationKeyWhois happydns.ObservationKey = "whois"
|
||||
|
||||
defaultWarningDays = 30
|
||||
defaultCriticalDays = 7
|
||||
)
|
||||
|
||||
// WHOISData represents WHOIS observation data.
|
||||
type WHOISData struct {
|
||||
ExpiryDate time.Time `json:"expiryDate"`
|
||||
Registrar string `json:"registrar"`
|
||||
Contacts map[string]*happydns.ContactInfo `json:"contacts,omitempty"`
|
||||
Status []string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// whoisProvider is a placeholder WHOIS observation provider.
|
||||
type whoisProvider struct{}
|
||||
|
||||
func (p *whoisProvider) Key() happydns.ObservationKey {
|
||||
return ObservationKeyWhois
|
||||
}
|
||||
|
||||
func (p *whoisProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
domainName, _ := opts["domainName"].(string)
|
||||
if domainName == "" {
|
||||
return nil, fmt.Errorf("domainName is required")
|
||||
}
|
||||
|
||||
info, err := domaininfo.GetDomainInfo(ctx, happydns.Origin(domainName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve domain info: %w", err)
|
||||
}
|
||||
|
||||
if info.ExpirationDate == nil {
|
||||
return nil, fmt.Errorf("expiration date not available for %s", domainName)
|
||||
}
|
||||
|
||||
registrar := info.Registrar
|
||||
if registrar == "" {
|
||||
registrar = "Unknown"
|
||||
}
|
||||
|
||||
return &WHOISData{
|
||||
ExpiryDate: *info.ExpirationDate,
|
||||
Registrar: registrar,
|
||||
Contacts: info.Contacts,
|
||||
Status: info.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExtractMetrics implements happydns.CheckerMetricsReporter.
|
||||
func (p *whoisProvider) ExtractMetrics(raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, error) {
|
||||
var data WHOISData
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
daysRemaining := data.ExpiryDate.Sub(collectedAt).Hours() / 24
|
||||
return []happydns.CheckMetric{{
|
||||
Name: "domain_expiry_days_remaining",
|
||||
Value: daysRemaining,
|
||||
Unit: "days",
|
||||
Labels: map[string]string{"registrar": data.Registrar},
|
||||
Timestamp: collectedAt,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// domainExpiryRule checks whether a domain is nearing expiration.
|
||||
type domainExpiryRule struct{}
|
||||
|
||||
func (r *domainExpiryRule) Name() string {
|
||||
return "domain_expiry_check"
|
||||
}
|
||||
|
||||
func (r *domainExpiryRule) Description() string {
|
||||
return "Checks whether a domain name is nearing its expiration date"
|
||||
}
|
||||
|
||||
func (r *domainExpiryRule) ValidateOptions(opts happydns.CheckerOptions) error {
|
||||
warningDays := float64(defaultWarningDays)
|
||||
criticalDays := float64(defaultCriticalDays)
|
||||
|
||||
if v, ok := opts["warning_days"]; ok {
|
||||
d, ok := v.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("warning_days must be a number")
|
||||
}
|
||||
if d <= 0 {
|
||||
return fmt.Errorf("warning_days must be positive")
|
||||
}
|
||||
warningDays = d
|
||||
}
|
||||
if v, ok := opts["critical_days"]; ok {
|
||||
d, ok := v.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("critical_days must be a number")
|
||||
}
|
||||
if d <= 0 {
|
||||
return fmt.Errorf("critical_days must be positive")
|
||||
}
|
||||
criticalDays = d
|
||||
}
|
||||
|
||||
if criticalDays >= warningDays {
|
||||
return fmt.Errorf("critical_days (%v) must be less than warning_days (%v)", criticalDays, warningDays)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *domainExpiryRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
|
||||
var whois WHOISData
|
||||
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusError,
|
||||
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
|
||||
Code: "whois_error",
|
||||
}
|
||||
}
|
||||
|
||||
// Read thresholds from options with defaults.
|
||||
warningDays := sdk.GetIntOption(opts, "warning_days", defaultWarningDays)
|
||||
criticalDays := sdk.GetIntOption(opts, "critical_days", defaultCriticalDays)
|
||||
|
||||
daysRemaining := int(time.Until(whois.ExpiryDate).Hours() / 24)
|
||||
meta := map[string]any{"days_remaining": daysRemaining}
|
||||
|
||||
if daysRemaining <= criticalDays {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusCrit,
|
||||
Message: fmt.Sprintf("Domain expires in %d days", daysRemaining),
|
||||
Code: "expiry_critical",
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
if daysRemaining <= warningDays {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusWarn,
|
||||
Message: fmt.Sprintf("Domain expires in %d days", daysRemaining),
|
||||
Code: "expiry_warning",
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusOK,
|
||||
Message: fmt.Sprintf("Domain expires in %d days", daysRemaining),
|
||||
Code: "expiry_ok",
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(&whoisProvider{})
|
||||
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "domain_expiry",
|
||||
Name: "Domain Expiry",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
DomainOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domainName",
|
||||
Type: "string",
|
||||
AutoFill: happydns.AutoFillDomainName,
|
||||
Hide: true,
|
||||
},
|
||||
{
|
||||
Id: "warning_days",
|
||||
Type: "uint",
|
||||
Label: "Warning threshold (days)",
|
||||
Description: "Number of days before expiration to trigger a warning.",
|
||||
Default: defaultWarningDays,
|
||||
Placeholder: strconv.Itoa(defaultWarningDays),
|
||||
},
|
||||
{
|
||||
Id: "critical_days",
|
||||
Type: "uint",
|
||||
Label: "Critical threshold (days)",
|
||||
Description: "Number of days before expiration to trigger a critical alert.",
|
||||
Default: defaultCriticalDays,
|
||||
Placeholder: strconv.Itoa(defaultCriticalDays),
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&domainExpiryRule{},
|
||||
},
|
||||
Interval: &happydns.CheckIntervalSpec{
|
||||
Min: 12 * time.Hour,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 24 * time.Hour,
|
||||
},
|
||||
HasMetrics: true,
|
||||
})
|
||||
}
|
||||
136
checkers/domain_expiry_test.go
Normal file
136
checkers/domain_expiry_test.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestDomainExpiryRule_Evaluate(t *testing.T) {
|
||||
rule := &domainExpiryRule{}
|
||||
now := time.Now()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
expiresIn time.Duration
|
||||
opts happydns.CheckerOptions
|
||||
want happydns.Status
|
||||
code string
|
||||
}{
|
||||
{"already expired", -5 * 24 * time.Hour, nil, happydns.StatusCrit, "expiry_critical"},
|
||||
{"critical default", 3 * 24 * time.Hour, nil, happydns.StatusCrit, "expiry_critical"},
|
||||
{"warning default", 15 * 24 * time.Hour, nil, happydns.StatusWarn, "expiry_warning"},
|
||||
{"ok default", 90 * 24 * time.Hour, nil, happydns.StatusOK, "expiry_ok"},
|
||||
{"ok with custom thresholds", 10 * 24 * time.Hour, happydns.CheckerOptions{
|
||||
"warning_days": float64(5),
|
||||
"critical_days": float64(2),
|
||||
}, happydns.StatusOK, "expiry_ok"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
obs := newWhoisObs(&WHOISData{
|
||||
ExpiryDate: now.Add(tc.expiresIn),
|
||||
Registrar: "Test Registrar",
|
||||
})
|
||||
st := rule.Evaluate(context.Background(), obs, tc.opts)
|
||||
if st.Status != tc.want {
|
||||
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
|
||||
}
|
||||
if st.Code != tc.code {
|
||||
t.Errorf("code = %q, want %q", st.Code, tc.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainExpiryRule_EvaluateObservationError(t *testing.T) {
|
||||
rule := &domainExpiryRule{}
|
||||
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("boom")}
|
||||
st := rule.Evaluate(context.Background(), obs, nil)
|
||||
if st.Status != happydns.StatusError {
|
||||
t.Fatalf("expected StatusError, got %v", st.Status)
|
||||
}
|
||||
if st.Code != "whois_error" {
|
||||
t.Errorf("code = %q, want whois_error", st.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainExpiryRule_ValidateOptions(t *testing.T) {
|
||||
rule := &domainExpiryRule{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts happydns.CheckerOptions
|
||||
wantErr bool
|
||||
}{
|
||||
{"defaults", nil, false},
|
||||
{"valid custom", happydns.CheckerOptions{"warning_days": 30.0, "critical_days": 7.0}, false},
|
||||
{"crit >= warn", happydns.CheckerOptions{"warning_days": 5.0, "critical_days": 5.0}, true},
|
||||
{"warn wrong type", happydns.CheckerOptions{"warning_days": "thirty"}, true},
|
||||
{"warn negative", happydns.CheckerOptions{"warning_days": -1.0}, true},
|
||||
{"crit negative", happydns.CheckerOptions{"critical_days": -3.0}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := rule.ValidateOptions(tc.opts)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("ValidateOptions err=%v, wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoisProvider_ExtractMetrics(t *testing.T) {
|
||||
p := &whoisProvider{}
|
||||
collected := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
data := WHOISData{
|
||||
ExpiryDate: collected.Add(10 * 24 * time.Hour),
|
||||
Registrar: "Acme",
|
||||
}
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metrics, err := p.ExtractMetrics(raw, collected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("expected 1 metric, got %d", len(metrics))
|
||||
}
|
||||
m := metrics[0]
|
||||
if m.Name != "domain_expiry_days_remaining" {
|
||||
t.Errorf("name = %q", m.Name)
|
||||
}
|
||||
if m.Value < 9.99 || m.Value > 10.01 {
|
||||
t.Errorf("value = %v, want ~10", m.Value)
|
||||
}
|
||||
if m.Labels["registrar"] != "Acme" {
|
||||
t.Errorf("registrar label = %q", m.Labels["registrar"])
|
||||
}
|
||||
}
|
||||
165
checkers/domain_lock.go
Normal file
165
checkers/domain_lock.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const defaultRequiredLockStatuses = "clientTransferProhibited"
|
||||
|
||||
// domainLockRule verifies that a domain carries the expected EPP lock
|
||||
// statuses (e.g. clientTransferProhibited) as reported by RDAP/WHOIS.
|
||||
type domainLockRule struct{}
|
||||
|
||||
func (r *domainLockRule) Name() string {
|
||||
return "domain_lock_check"
|
||||
}
|
||||
|
||||
func (r *domainLockRule) Description() string {
|
||||
return "Verifies that a domain carries the expected EPP lock statuses (e.g. clientTransferProhibited)"
|
||||
}
|
||||
|
||||
func (r *domainLockRule) ValidateOptions(opts happydns.CheckerOptions) error {
|
||||
v, ok := opts["requiredStatuses"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("requiredStatuses must be a string")
|
||||
}
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("requiredStatuses must contain at least one EPP status code")
|
||||
}
|
||||
|
||||
func (r *domainLockRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
|
||||
var whois WHOISData
|
||||
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusError,
|
||||
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
|
||||
Code: "lock_error",
|
||||
}
|
||||
}
|
||||
|
||||
requiredStr := defaultRequiredLockStatuses
|
||||
if v, ok := opts["requiredStatuses"].(string); ok && v != "" {
|
||||
requiredStr = v
|
||||
}
|
||||
|
||||
var required []string
|
||||
for _, s := range strings.Split(requiredStr, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
required = append(required, s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(required) == 0 {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusUnknown,
|
||||
Message: "No required lock statuses configured",
|
||||
Code: "lock_skipped",
|
||||
}
|
||||
}
|
||||
|
||||
present := make(map[string]bool, len(whois.Status))
|
||||
for _, s := range whois.Status {
|
||||
present[strings.ToLower(s)] = true
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, req := range required {
|
||||
if !present[strings.ToLower(req)] {
|
||||
missing = append(missing, req)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusCrit,
|
||||
Message: fmt.Sprintf("Missing lock status: %s", strings.Join(missing, ", ")),
|
||||
Code: "lock_missing",
|
||||
Meta: map[string]any{
|
||||
"missing": missing,
|
||||
"present": whois.Status,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusOK,
|
||||
Message: fmt.Sprintf("All required statuses present: %s", strings.Join(required, ", ")),
|
||||
Code: "lock_ok",
|
||||
Meta: map[string]any{
|
||||
"required": required,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "domain_lock",
|
||||
Name: "Domain Lock Status",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
DomainOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domainName",
|
||||
Type: "string",
|
||||
AutoFill: happydns.AutoFillDomainName,
|
||||
Hide: true,
|
||||
},
|
||||
{
|
||||
Id: "requiredStatuses",
|
||||
Type: "string",
|
||||
Label: "Required lock statuses",
|
||||
Description: "Comma-separated list of EPP status codes that must be present on the domain (e.g. clientTransferProhibited, clientUpdateProhibited, clientDeleteProhibited).",
|
||||
Default: defaultRequiredLockStatuses,
|
||||
Placeholder: defaultRequiredLockStatuses,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&domainLockRule{},
|
||||
},
|
||||
Interval: &happydns.CheckIntervalSpec{
|
||||
Min: 1 * time.Hour,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 24 * time.Hour,
|
||||
},
|
||||
})
|
||||
}
|
||||
150
checkers/domain_lock_test.go
Normal file
150
checkers/domain_lock_test.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestDomainLockRule_Evaluate(t *testing.T) {
|
||||
rule := &domainLockRule{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status []string
|
||||
opts happydns.CheckerOptions
|
||||
want happydns.Status
|
||||
code string
|
||||
}{
|
||||
{
|
||||
name: "default required present",
|
||||
status: []string{"clientTransferProhibited", "ok"},
|
||||
opts: nil,
|
||||
want: happydns.StatusOK,
|
||||
code: "lock_ok",
|
||||
},
|
||||
{
|
||||
name: "default required missing",
|
||||
status: []string{"ok"},
|
||||
opts: nil,
|
||||
want: happydns.StatusCrit,
|
||||
code: "lock_missing",
|
||||
},
|
||||
{
|
||||
name: "multiple required all present",
|
||||
status: []string{"clientTransferProhibited", "clientUpdateProhibited", "clientDeleteProhibited"},
|
||||
opts: happydns.CheckerOptions{
|
||||
"requiredStatuses": "clientTransferProhibited,clientUpdateProhibited,clientDeleteProhibited",
|
||||
},
|
||||
want: happydns.StatusOK,
|
||||
code: "lock_ok",
|
||||
},
|
||||
{
|
||||
name: "multiple required some missing",
|
||||
status: []string{"clientTransferProhibited"},
|
||||
opts: happydns.CheckerOptions{
|
||||
"requiredStatuses": "clientTransferProhibited,clientUpdateProhibited",
|
||||
},
|
||||
want: happydns.StatusCrit,
|
||||
code: "lock_missing",
|
||||
},
|
||||
{
|
||||
name: "case insensitive match",
|
||||
status: []string{"clienttransferprohibited"},
|
||||
opts: nil,
|
||||
want: happydns.StatusOK,
|
||||
code: "lock_ok",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
obs := newWhoisObs(&WHOISData{Status: tc.status})
|
||||
st := rule.Evaluate(context.Background(), obs, tc.opts)
|
||||
if st.Status != tc.want {
|
||||
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
|
||||
}
|
||||
if st.Code != tc.code {
|
||||
t.Errorf("code = %q, want %q", st.Code, tc.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: WHOIS data with nil/empty status must report missing locks, not panic.
|
||||
func TestDomainLockRule_NilStatus(t *testing.T) {
|
||||
rule := &domainLockRule{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status []string
|
||||
}{
|
||||
{"nil status", nil},
|
||||
{"empty status", []string{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
obs := newWhoisObs(&WHOISData{Status: tc.status})
|
||||
st := rule.Evaluate(context.Background(), obs, nil)
|
||||
if st.Status != happydns.StatusCrit {
|
||||
t.Errorf("status = %v, want Crit", st.Status)
|
||||
}
|
||||
if st.Code != "lock_missing" {
|
||||
t.Errorf("code = %q, want lock_missing", st.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainLockRule_EvaluateObservationError(t *testing.T) {
|
||||
rule := &domainLockRule{}
|
||||
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("nope")}
|
||||
st := rule.Evaluate(context.Background(), obs, nil)
|
||||
if st.Status != happydns.StatusError || st.Code != "lock_error" {
|
||||
t.Errorf("got %v / %q", st.Status, st.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainLockRule_ValidateOptions(t *testing.T) {
|
||||
rule := &domainLockRule{}
|
||||
cases := []struct {
|
||||
name string
|
||||
opts happydns.CheckerOptions
|
||||
wantErr bool
|
||||
}{
|
||||
{"default", nil, false},
|
||||
{"valid", happydns.CheckerOptions{"requiredStatuses": "clientTransferProhibited"}, false},
|
||||
{"empty after split", happydns.CheckerOptions{"requiredStatuses": " , , "}, true},
|
||||
{"wrong type", happydns.CheckerOptions{"requiredStatuses": 123}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := rule.ValidateOptions(tc.opts)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("err=%v wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
61
checkers/helpers_test.go
Normal file
61
checkers/helpers_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// stubObservationGetter is a test helper that serves a single pre-built
|
||||
// observation, mimicking the SDK's mapObservationGetter (JSON round-trip).
|
||||
type stubObservationGetter struct {
|
||||
key happydns.ObservationKey
|
||||
data any
|
||||
err error
|
||||
}
|
||||
|
||||
func (g *stubObservationGetter) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
|
||||
if g.err != nil {
|
||||
return g.err
|
||||
}
|
||||
if key != g.key {
|
||||
return errNotFound
|
||||
}
|
||||
raw, err := json.Marshal(g.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
const errNotFound = errString("observation not available")
|
||||
|
||||
func newWhoisObs(d *WHOISData) *stubObservationGetter {
|
||||
return &stubObservationGetter{key: ObservationKeyWhois, data: d}
|
||||
}
|
||||
33
checkers/matrix_federation.go
Normal file
33
checkers/matrix_federation.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
matrix "git.happydns.org/checker-matrix/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(matrix.Provider())
|
||||
// Not Externalizable checker as it already calls a HTTP API
|
||||
checker.RegisterChecker(matrix.Definition())
|
||||
}
|
||||
32
checkers/ns_restrictions.go
Normal file
32
checkers/ns_restrictions.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
nsr "git.happydns.org/checker-ns-restrictions/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(nsr.Provider())
|
||||
checker.RegisterExternalizableChecker(nsr.Definition())
|
||||
}
|
||||
32
checkers/ping.go
Normal file
32
checkers/ping.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
ping "git.happydns.org/checker-ping/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(ping.Provider())
|
||||
checker.RegisterExternalizableChecker(ping.Definition())
|
||||
}
|
||||
33
checkers/zonemaster.go
Normal file
33
checkers/zonemaster.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
zonemaster "git.happydns.org/checker-zonemaster/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(zonemaster.Provider())
|
||||
// Not Externalizable checker as it already calls a HTTP API
|
||||
checker.RegisterChecker(zonemaster.Definition())
|
||||
}
|
||||
|
|
@ -26,13 +26,16 @@ import (
|
|||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/earthboundkid/versioninfo/v2"
|
||||
"github.com/fatih/color"
|
||||
|
||||
_ "git.happydns.org/happyDomain/checkers"
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/app"
|
||||
"git.happydns.org/happyDomain/internal/config"
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
_ "git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||
_ "git.happydns.org/happyDomain/internal/storage/leveldb"
|
||||
_ "git.happydns.org/happyDomain/internal/storage/oracle-nosql"
|
||||
|
|
@ -54,11 +57,19 @@ func main() {
|
|||
LastCommit: versioninfo.Revision,
|
||||
DirtyBuild: versioninfo.DirtyBuild,
|
||||
}
|
||||
v := Version
|
||||
if Version == "custom-build" {
|
||||
controller.HDVersion.Version = versioninfo.Short()
|
||||
v = versioninfo.Short()
|
||||
controller.HDVersion.Version = v
|
||||
} else {
|
||||
versioninfo.Version = Version
|
||||
}
|
||||
metrics.SetBuildInfo(
|
||||
v,
|
||||
versioninfo.Revision,
|
||||
versioninfo.LastCommit.UTC().Format(time.RFC3339),
|
||||
versioninfo.DirtyBuild,
|
||||
)
|
||||
|
||||
log.Println("This is happyDomain", versioninfo.Short())
|
||||
|
||||
|
|
|
|||
187
docs/checker-quotas.md
Normal file
187
docs/checker-quotas.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# Checker quotas and scheduling policy
|
||||
|
||||
happyDomain's checker subsystem runs scheduled DNS health checks on behalf of
|
||||
each user. To keep resource usage predictable on shared instances, the
|
||||
scheduler consults a per-user policy before every job and can throttle or
|
||||
skip executions based on the user's activity, explicit pause, or daily
|
||||
budget.
|
||||
|
||||
This document describes:
|
||||
|
||||
1. The three gates a scheduled check must pass through.
|
||||
2. How per-day budgets are counted and reset.
|
||||
3. How an administrator configures system-wide defaults.
|
||||
4. How per-user overrides work.
|
||||
5. Operational caveats (restart behaviour, manual triggers, UI indications).
|
||||
|
||||
> **Scope:** this document only covers the scheduler's user-level gate. It
|
||||
> does not cover retention (`--checker-retention-days`, see the janitor) nor
|
||||
> the per-checker `MinInterval` throttling.
|
||||
|
||||
---
|
||||
|
||||
## The three gates
|
||||
|
||||
Before each scheduled execution, the scheduler evaluates the job against a
|
||||
**user-level gate**. A job is dropped (and rescheduled for the next tick) if
|
||||
any of the following apply:
|
||||
|
||||
| Gate | Blocks when… | Source of truth |
|
||||
| --- | --- | --- |
|
||||
| Scheduling paused | `UserQuota.SchedulingPaused == true` | per-user only |
|
||||
| Inactivity pause | user has not logged in for N days | per-user, falls back to system default |
|
||||
| Daily budget | user has executed `MaxChecksPerDay` scheduled checks today | per-user, falls back to system default |
|
||||
|
||||
The first two gates ("policy layer") are cached for 5 minutes after a lookup
|
||||
so the scheduler hot path does not hit storage on every job pop. The cache
|
||||
is invalidated automatically whenever a user's quota or `LastSeen` changes
|
||||
(login, admin edit).
|
||||
|
||||
The daily budget gate is **not** cached: the counter changes on every
|
||||
successful execution and must be accurate.
|
||||
|
||||
## Daily budget
|
||||
|
||||
### How it is counted
|
||||
|
||||
- The counter is incremented **once** per successful call to
|
||||
`CreateExecution` by the scheduler. It is _not_ decremented if
|
||||
`RunExecution` later fails — a check that got as far as being recorded
|
||||
counts against the budget. This prevents users from burning capacity via
|
||||
engines that repeatedly error.
|
||||
- **Manual API triggers are counted by default**, and refused with
|
||||
`HTTP 429 Too Many Requests` once the user is over budget. This applies
|
||||
to "Run now" in the UI and to `POST
|
||||
/api/domains/.../checkers/.../executions`. The behaviour is controlled
|
||||
by `--checker-count-manual-triggers` (default `true`); set it to
|
||||
`false` to restore the legacy bypass — in that mode, manual triggers
|
||||
are neither checked nor incremented.
|
||||
|
||||
### When it resets
|
||||
|
||||
- The counter resets at **00:00 UTC** every day. This is intentionally
|
||||
independent of the user's local timezone so the behaviour is consistent
|
||||
across deployments. A user in UTC+8 will see their counter flip
|
||||
mid-afternoon local time.
|
||||
- The counter is kept **in memory only**. A process restart resets it to
|
||||
zero for every user. In a rolling-restart environment, this effectively
|
||||
grants a partial top-up; operators should size their defaults with this
|
||||
in mind.
|
||||
|
||||
### Interval-aware throttling
|
||||
|
||||
When the budget is at least **80 % consumed**, the scheduler starts skipping
|
||||
jobs whose configured interval is shorter than **4 hours**. Jobs with a
|
||||
longer interval continue to run until the hard limit is reached.
|
||||
|
||||
The goal is to prevent rare-but-important checks (for example, a daily
|
||||
DNSSEC sanity check) from being starved by frequent low-value pings (for
|
||||
example, a 1-minute probe). Put bluntly: if you are going to run out of
|
||||
budget anyway, spend the last 20 % on the checks you would most miss.
|
||||
|
||||
Constants (not currently configurable at runtime):
|
||||
|
||||
- `throttleFillRatio` = 0.8
|
||||
- `throttleShortIntervalCutoff` = 4 h
|
||||
|
||||
Both live in `internal/usecase/checker/user_gate.go`.
|
||||
|
||||
### UI signalling
|
||||
|
||||
Planned (not-yet-run) executions returned by the "upcoming checks" API are
|
||||
marked with status `ExecutionRateLimited` whenever the target user is over
|
||||
budget. This lets the frontend show the user that scheduled work is on hold
|
||||
until tomorrow, distinct from a merely-pending job.
|
||||
|
||||
Only _synthetic_ planned entries carry this status; it is never persisted
|
||||
on a real execution record.
|
||||
|
||||
## System-wide configuration
|
||||
|
||||
| CLI flag | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--checker-inactivity-pause-days` | `90` | Stop scheduling for users inactive for this many days. `0` disables the inactivity gate. |
|
||||
| `--checker-max-checks-per-day` | `0` | Cap on scheduled executions per user per day. `0` means unlimited. Counter resets at 00:00 UTC. |
|
||||
| `--checker-count-manual-triggers` | `true` | When `true`, manual triggers count against `MaxChecksPerDay` and are refused with HTTP 429 once exhausted. When `false`, manual triggers bypass the quota entirely. No effect when `MaxChecksPerDay` is `0` (unlimited). |
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
happyDomain \
|
||||
--checker-max-checks-per-day=2000 \
|
||||
--checker-inactivity-pause-days=60 \
|
||||
--checker-count-manual-triggers=true
|
||||
```
|
||||
|
||||
Values set via the CLI are read at startup. Changing them requires a
|
||||
restart of the happyDomain process.
|
||||
|
||||
## Per-user overrides
|
||||
|
||||
Administrators can override the system defaults for individual users via
|
||||
the admin API (`UserQuota`):
|
||||
|
||||
```json
|
||||
{
|
||||
"max_checks_per_day": 500,
|
||||
"inactivity_pause_days": 14,
|
||||
"scheduling_paused": false
|
||||
}
|
||||
```
|
||||
|
||||
Semantics:
|
||||
|
||||
- `max_checks_per_day`: `0` means "use the system default", any positive
|
||||
value is an override, and a **negative** value disables the daily cap
|
||||
for that user (explicit unlimited, independent of the system default).
|
||||
Changes take effect immediately — on the next scheduler tick, the
|
||||
user's budget cache is recomputed against the new limit while the
|
||||
accumulated usage counter for the current day is preserved.
|
||||
- `inactivity_pause_days`: `0` means "use the system default", any
|
||||
positive value is an override, and a **negative** value disables
|
||||
inactivity pausing for that user.
|
||||
- `scheduling_paused`: hard per-user override. Takes effect on the next
|
||||
scheduler tick (within the cache TTL of 5 minutes; an admin edit
|
||||
invalidates the cache immediately).
|
||||
|
||||
After editing a user's quota via the admin API, the gate cache is
|
||||
invalidated automatically. You should not need to restart the scheduler.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Failed executions still count.** If the checker engine is misbehaving
|
||||
for an unrelated reason, users will see their budget drained by failed
|
||||
runs. Watch the scheduler logs (`Scheduler: checker ... failed: ...`)
|
||||
and the `ExecutionFailed` status counts in the admin metrics.
|
||||
- **Process restarts clear the counters.** For deployments with a hard
|
||||
budget, avoid frequent restarts during peak hours.
|
||||
- **Manual triggers count by default.** With
|
||||
`--checker-count-manual-triggers=true` (the default), pressing
|
||||
"Run now" consumes one unit of the daily budget and returns HTTP 429
|
||||
once the budget is exhausted. Set the flag to `false` to restore the
|
||||
legacy bypass; that option is useful for self-hosted instances where
|
||||
the quota is only meant to protect against runaway scheduled load.
|
||||
- **HTTP 429 response body.** Rejected manual triggers return
|
||||
`{"errmsg": "daily check quota exhausted; try again after 00:00 UTC"}`
|
||||
alongside the 429 status. Clients should surface this to the user —
|
||||
the `ExecutionRateLimited` status (value `4`) used for planned
|
||||
executions in `?include_planned=1` can be reused for consistent
|
||||
iconography.
|
||||
- **Invalidate is cooperative.** The gate cache is invalidated on user
|
||||
update and on admin quota edit. If you modify user data through a
|
||||
backdoor (direct database write, migration, …) the cache will stay
|
||||
stale until its 5-minute TTL expires.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
There is currently no dedicated endpoint to introspect per-user usage.
|
||||
Pragmatic options, in order of effort:
|
||||
|
||||
1. Read the scheduler logs: every gated job emits a line at debug level
|
||||
(commented out by default in `scheduler.go`; enable locally when
|
||||
investigating).
|
||||
2. Query planned executions via the user-facing API with
|
||||
`?include_planned=1` and look for `ExecutionRateLimited` entries —
|
||||
this confirms a user is currently over budget.
|
||||
3. Restart the process to force a clean slate (destructive to all
|
||||
counters; use only as a last resort).
|
||||
253
docs/checker-scheduler.md
Normal file
253
docs/checker-scheduler.md
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
# Checker scheduling and execution
|
||||
|
||||
happyDomain's checker subsystem runs small health checks against DNS zones,
|
||||
domains, and services. Each check can be triggered in two ways (on a
|
||||
recurring schedule, or manually from the UI/API), and both paths share the
|
||||
same execution pipeline (observation → rule evaluation → aggregated status).
|
||||
|
||||
This document describes:
|
||||
|
||||
1. The two ways a check can be triggered (scheduled vs manual).
|
||||
2. How the execution pipeline turns an observation into a status.
|
||||
3. How options are composed across scopes and how auto-fill variables work.
|
||||
4. Where to find the per-user throttling rules.
|
||||
|
||||
> **See also:** [checker-quotas.md](./checker-quotas.md) for the per-user
|
||||
> scheduling gate (pause, inactivity, daily budget) that sits in front of
|
||||
> every scheduled and manual trigger.
|
||||
|
||||
---
|
||||
|
||||
## Key concepts
|
||||
|
||||
A few terms recur throughout this document and the checker APIs:
|
||||
|
||||
- **Checker.** The unit of monitoring logic: a named program (built-in,
|
||||
plugin, or external HTTP service) that declares a set of observations
|
||||
it can collect and a set of rules that evaluate them. A checker is
|
||||
identified by a stable `checkerId` and described by a
|
||||
`CheckerDefinition` (options, intervals, availability, rules).
|
||||
- **Observation.** The raw data produced by a single collect step: for
|
||||
example, the RTT samples of a ping probe or the response of a DNS
|
||||
query. Each observation is typed, identified by an
|
||||
`ObservationKey`, serialised to JSON, and stored in an
|
||||
`ObservationSnapshot`. A checker may expose several observation
|
||||
keys; rules request the ones they need.
|
||||
- **Rule.** A named predicate that turns observations into a
|
||||
`CheckState` (`StatusOK`, `StatusWarn`, `StatusCrit`, ...). A single
|
||||
checker typically ships several rules that look at the same
|
||||
observation from different angles (e.g. "packet loss" and "latency"
|
||||
on the same ping data). Users can enable or disable rules
|
||||
individually per target.
|
||||
- **Target.** What a check runs against: a user, a domain, a
|
||||
(zone, subdomain) pair, or a service inside a zone. The
|
||||
`CheckerAvailability` of a checker decides which target levels it
|
||||
may attach to.
|
||||
- **CheckPlan.** The per-target configuration that ties a checker to a
|
||||
specific target. It carries the user's (or admin's) overrides:
|
||||
interval, and a per-rule `enabled` map. A plan is optional: if none
|
||||
exists, the checker runs with its declared defaults.
|
||||
- **Execution.** A single run of the pipeline for a given
|
||||
(checker, target). It is created either by the scheduler (when a
|
||||
plan or default schedule is due) or by a manual trigger, and
|
||||
progresses through the lifecycle statuses `Pending → Running →
|
||||
Done | Failed`.
|
||||
- **CheckEvaluation.** The result attached to a finished execution: a
|
||||
list of rule states plus a reference to the `ObservationSnapshot`
|
||||
they were computed from. This is what the UI renders as green / amber
|
||||
/ red badges.
|
||||
|
||||
In short: a **CheckPlan** pairs a checker with a target; each run
|
||||
produces one **Execution**, which collects one or more **Observations**
|
||||
into a snapshot and feeds them to the enabled **Rules**; the rules'
|
||||
states are bundled into a **CheckEvaluation**, which becomes the
|
||||
execution's final result.
|
||||
|
||||
## How a check runs
|
||||
|
||||
### 1. Scheduled runs (automatic)
|
||||
|
||||
The scheduler maintains a priority queue of upcoming jobs and, on every
|
||||
tick, pops the jobs whose `NextRun` is due. A job that passes the
|
||||
[user-level gate](./checker-quotas.md#the-three-gates) is handed to the
|
||||
engine, executed, and re-enqueued for its next run.
|
||||
|
||||
**Interval resolution.** The effective interval for a given (checker,
|
||||
target) pair is chosen in this order:
|
||||
|
||||
1. `CheckPlan.Interval`: the per-target override stored in DB, if the
|
||||
user (or admin) set one.
|
||||
2. `CheckerDefinition.Interval.Default`: the checker's own default,
|
||||
declared at registration time.
|
||||
3. `24h`: the hardcoded fallback used when the checker did not declare
|
||||
an interval spec.
|
||||
|
||||
The result is then **clamped** to `[Interval.Min, Interval.Max]` if the
|
||||
checker declared bounds, so a user cannot set `Interval = 10s` on a
|
||||
checker whose minimum is `1m`.
|
||||
|
||||
**Spread and jitter.** To avoid thundering herds when many checks share
|
||||
the same interval, the scheduler adds:
|
||||
|
||||
- a **deterministic offset** per (checkerID, target) hashed into the
|
||||
interval window, and
|
||||
- a ~5 % **deterministic jitter** per cycle.
|
||||
|
||||
Two users who configure the same 5-minute probe on the same day will
|
||||
therefore run it on different sub-minute offsets.
|
||||
|
||||
**Applicability.** A checker is scheduled for a target only if its
|
||||
`CheckerAvailability` matches: `ApplyToDomain` enrolls every domain,
|
||||
`ApplyToZone` every published zone, and `ApplyToService` every service
|
||||
of a type listed in `LimitToServices` (or all of them, if the list is
|
||||
empty).
|
||||
|
||||
### 2. Manual runs ("Run now")
|
||||
|
||||
Users (and admins) can trigger an execution on demand:
|
||||
|
||||
```
|
||||
POST /api/domains/{domain}/checkers/{checkerId}/executions
|
||||
POST /api/domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions
|
||||
```
|
||||
|
||||
The request body is optional:
|
||||
|
||||
```json
|
||||
{
|
||||
"options": { "...": "..." },
|
||||
"enabledRules": { "ruleName": true, "otherRule": false }
|
||||
}
|
||||
```
|
||||
|
||||
- `options` are **run-time overrides** that take effect for this single
|
||||
execution. They are validated against the checker's `RunOpts`.
|
||||
- `enabledRules` temporarily selects a subset of the rules for this run;
|
||||
omit it to evaluate every rule configured for the target.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- By default, the endpoint returns **HTTP 202** with the newly created
|
||||
`Execution` and runs the pipeline asynchronously. Poll
|
||||
`GET .../executions/{id}` (or its aliases) for completion.
|
||||
- With `?sync=true`, it blocks and returns **HTTP 200** with the
|
||||
resulting `CheckEvaluation`.
|
||||
- Manual triggers are subject to the per-user daily budget and may be
|
||||
refused with **HTTP 429**. See
|
||||
[checker-quotas.md](./checker-quotas.md#daily-budget).
|
||||
|
||||
The handler is `TriggerCheck` in the checker API controller.
|
||||
|
||||
## The execution pipeline
|
||||
|
||||
Whether the trigger is scheduled or manual, a single execution goes
|
||||
through the same steps:
|
||||
|
||||
1. **Resolve options.** Merge admin → user → domain → service → run-time
|
||||
overrides into a single `CheckerOptions`, then apply
|
||||
[auto-fill](#auto-fill-variables).
|
||||
2. **Collect observations.** Each `ObservationProvider` referenced by
|
||||
the enabled rules is invoked (with caching: two rules sharing the
|
||||
same observation key only collect once). Providers return a typed
|
||||
struct that is JSON-serialised into an `ObservationSnapshot`.
|
||||
3. **Evaluate rules.** Each enabled `CheckRule` receives the snapshot
|
||||
(via an `ObservationGetter`) plus the resolved options, and returns a
|
||||
`CheckState` with one of the statuses below.
|
||||
4. **Aggregate** the individual rule states into the execution's final
|
||||
result (worst-status wins, by default) and persist both the snapshot
|
||||
and the evaluation.
|
||||
5. **Update the `Execution`** with its terminal `ExecutionStatus` and a
|
||||
link to the evaluation.
|
||||
|
||||
For the anatomy of a checker (data types, providers, rules, metrics),
|
||||
see the companion project
|
||||
[checker-dummy](https://git.happydns.org/checker-dummy), which is the
|
||||
reference walkthrough.
|
||||
|
||||
### Check statuses
|
||||
|
||||
Returned by each rule and aggregated at the execution level:
|
||||
|
||||
| Constant | Meaning |
|
||||
| ---------------- | ----------------------------------------------- |
|
||||
| `StatusOK` | Healthy. |
|
||||
| `StatusInfo` | Informational, not a problem. |
|
||||
| `StatusWarn` | Soft threshold crossed. |
|
||||
| `StatusCrit` | Hard threshold crossed. |
|
||||
| `StatusError` | The rule itself could not evaluate (e.g. bad data). |
|
||||
| `StatusUnknown` | Not enough information to decide. |
|
||||
|
||||
### Execution lifecycle statuses
|
||||
|
||||
Attached to the `Execution` record (not to individual rule states):
|
||||
|
||||
| Status | Value | Meaning |
|
||||
| ---------------------- | ----- | ----------------------------------------------------- |
|
||||
| `ExecutionPending` | `0` | Created, not yet running. |
|
||||
| `ExecutionRunning` | `1` | Pipeline is executing. |
|
||||
| `ExecutionDone` | `2` | Pipeline completed; see the linked `CheckEvaluation`. |
|
||||
| `ExecutionFailed` | `3` | Pipeline errored before producing an evaluation. |
|
||||
| `ExecutionRateLimited` | `4` | Synthetic, only on planned (not-yet-run) entries returned by `?include_planned=1`; never persisted. See [checker-quotas.md](./checker-quotas.md#ui-signalling). |
|
||||
|
||||
## Configuring a check
|
||||
|
||||
Every checker exposes a set of typed options grouped by **scope**. The
|
||||
scope determines who sets the option and for how many targets it
|
||||
applies at once:
|
||||
|
||||
| Scope | Who sets it | Applies to |
|
||||
| -------------- | ------------------ | -------------------------- |
|
||||
| `AdminOpts` | instance admin | every user on the instance |
|
||||
| `UserOpts` | end user | all their own targets |
|
||||
| `DomainOpts` | end user / auto | a single domain |
|
||||
| `ServiceOpts` | end user / auto | a single service |
|
||||
| `RunOpts` | trigger caller | a single execution |
|
||||
|
||||
At execution time the scopes are merged in order of increasing
|
||||
specificity (`admin → user → domain → service → run`), so a per-service
|
||||
value wins over a per-domain one, which wins over a per-user default,
|
||||
and so on. Admin-provided values can be locked with the `NoOverride`
|
||||
attribute to prevent lower scopes from changing them.
|
||||
|
||||
The interval itself is **not** an option; it lives on the `CheckPlan`
|
||||
record for that (checker, target) pair, as described above.
|
||||
|
||||
### Auto-fill variables
|
||||
|
||||
Some options don't need to be typed in manually: they can be resolved
|
||||
from the surrounding context of the execution. Each such option is
|
||||
declared with an `AutoFill` attribute, and the engine populates it just
|
||||
before the collect step. Supported variables:
|
||||
|
||||
| Constant | Resolves to |
|
||||
| --------------------- | ---------------------------------------------------- |
|
||||
| `AutoFillDomainName` | The target domain's FQDN. |
|
||||
| `AutoFillSubdomain` | The subdomain under which the target service lives. |
|
||||
| `AutoFillZone` | The published zone the check is running against. |
|
||||
| `AutoFillServiceType` | The service's type identifier. |
|
||||
| `AutoFillService` | The full service payload. |
|
||||
|
||||
Auto-fill runs **last** and overrides any value that may have been set
|
||||
at a lower scope: the goal is to keep these fields authoritative. Rule
|
||||
code can therefore rely on them being present and correct at
|
||||
`Evaluate()` time without re-deriving them from the observation.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Same pipeline for both triggers.** A manual run and a scheduled run
|
||||
produce an `Execution` of the same shape; the only distinction is the
|
||||
`TriggerInfo.Type` (`TriggerManual` vs `TriggerSchedule`) stored on
|
||||
the execution.
|
||||
- **User gate first.** The per-user pause / inactivity / daily-budget
|
||||
gate is evaluated before the pipeline starts; a gated scheduled job
|
||||
is dropped and the counter is not incremented. The rules are in
|
||||
[checker-quotas.md](./checker-quotas.md).
|
||||
- **Rules can be disabled per target.** `CheckPlan.Enabled` is a rule
|
||||
name → boolean map. A missing entry means "enabled", an empty map
|
||||
means "all enabled". A plan where every rule is explicitly `false`
|
||||
disables the checker entirely for that target
|
||||
(`CheckPlan.IsFullyDisabled`).
|
||||
- **Options validation.** Options are validated both when a plan is
|
||||
stored and when a manual trigger arrives (with `RunOpts` included for
|
||||
the latter). Invalid options yield HTTP 400 at trigger time;
|
||||
`TriggerCheck` does not silently drop bad input.
|
||||
BIN
docs/domain-prometheus-url.png
Normal file
BIN
docs/domain-prometheus-url.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
60
docs/metrics.md
Normal file
60
docs/metrics.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# happyDomain Metrics
|
||||
|
||||
happyDomain exposes Prometheus metrics at `GET /metrics` on the **admin
|
||||
socket only** (Unix socket or loopback). The admin socket is not
|
||||
authenticated; do not expose it to untrusted networks. The public HTTP API
|
||||
does **not** serve `/metrics`.
|
||||
|
||||
All metric names are prefixed with `happydomain_`.
|
||||
|
||||
## Exported metrics
|
||||
|
||||
| Metric | Type | Labels | Cardinality bound | Description |
|
||||
|---|---|---|---|---|
|
||||
| `happydomain_http_requests_total` | counter | `method`, `path`, `status` | HTTP methods × Gin route templates × HTTP status codes (low hundreds) | Total HTTP requests served. `path` is the Gin route template (e.g. `/api/domains/:domain`), never the raw URL, to keep cardinality bounded. |
|
||||
| `happydomain_http_request_duration_seconds` | histogram | `method`, `path` | same as above | HTTP request latency, default Prometheus buckets. |
|
||||
| `happydomain_http_requests_in_flight` | gauge | – | 1 | HTTP requests currently being served. |
|
||||
| `happydomain_scheduler_queue_depth` | gauge (func) | – | 1 | Sampled at scrape time via `RegisterSchedulerQueueDepth`. Reports 0 when no scheduler is registered. |
|
||||
| `happydomain_scheduler_active_workers` | gauge | – | 1 | Workers currently executing a check. |
|
||||
| `happydomain_scheduler_checks_total` | counter | `checker`, `status` | #checker types × {`success`, `error`} | Total scheduler check executions. Checker IDs are system-defined, never user input. |
|
||||
| `happydomain_scheduler_check_duration_seconds` | histogram | `checker` | #checker types | Check execution latency. |
|
||||
| `happydomain_provider_api_calls_total` | counter | `provider`, `operation`, `status` | #providers × #ops × {`success`, `error`} | DNS provider API calls. `provider` is the dnscontrol provider name (bounded set). |
|
||||
| `happydomain_provider_api_duration_seconds` | histogram | `provider`, `operation` | same | DNS provider API latency. |
|
||||
| `happydomain_storage_operations_total` | counter | `operation`, `entity`, `status` | ~6 ops × ~5 entities × {`success`, `error`} | Storage operations. |
|
||||
| `happydomain_storage_operation_duration_seconds` | histogram | `operation`, `entity` | same | Storage operation latency. |
|
||||
| `happydomain_storage_stats_errors_total` | counter | `entity` | #entities | Errors encountered while collecting storage stats during a scrape. Alert on a non-zero rate — silent storage failures otherwise produce gaps in the gauges below. |
|
||||
| `happydomain_registered_users` | gauge | – | 1 | Registered user accounts (sampled live at scrape time). |
|
||||
| `happydomain_domains` | gauge | – | 1 | Domains managed across all users. |
|
||||
| `happydomain_zones` | gauge | – | 1 | Zone snapshots stored. |
|
||||
| `happydomain_providers` | gauge | – | 1 | Provider configurations across all users. |
|
||||
| `happydomain_build_info` | gauge | `version`, `revision`, `dirty`, `build_date` | 1 per build | Always 1; metadata is in the labels. |
|
||||
|
||||
## Cardinality rules
|
||||
|
||||
- **Never** add a label whose value comes from user input: domain name, user
|
||||
ID, zone ID, provider URL, raw HTTP path, etc.
|
||||
- New labels MUST have a documented finite bound in the table above before
|
||||
the metric is merged.
|
||||
- Histograms inherit the cardinality of their labels — be especially careful.
|
||||
|
||||
## Security
|
||||
|
||||
`/metrics` exposes business intelligence (entity counts, provider mix,
|
||||
latency profiles) and operational shape (queue depth, worker counts). It is
|
||||
intentionally only mounted on the admin socket (`internal/app/admin.go`).
|
||||
Bind that socket to a Unix path or `127.0.0.1` only — exposing it on a
|
||||
network interface will leak this information to anyone who can reach it.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- The HTTP middleware uses `c.FullPath()` (Gin route template) to populate
|
||||
the `path` label. See `internal/metrics/http.go`.
|
||||
- The scheduler queue depth gauge is a `GaugeFunc` that calls back into the
|
||||
scheduler at scrape time, installed via
|
||||
`metrics.RegisterSchedulerQueueDepth`. The scheduler unregisters its
|
||||
accessor in `Stop()` so stopped schedulers do not leak their queue.
|
||||
- The storage stats collector runs each `Count*` query in its own goroutine
|
||||
with a `recover()` guard, so a panicking backend cannot crash the scrape.
|
||||
Failures increment `happydomain_storage_stats_errors_total{entity=…}`.
|
||||
- `happydomain_build_info` is set once at startup from `cmd/happyDomain/main.go`
|
||||
using `versioninfo.Revision`, `LastCommit`, and `DirtyBuild`.
|
||||
149
docs/plugins/checker-plugin.md
Normal file
149
docs/plugins/checker-plugin.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# Building a happyDomain Checker Plugin
|
||||
|
||||
This page documents how to ship a **checker** as an in-process Go plugin
|
||||
that happyDomain loads at startup. Checker plugins extend happyDomain with
|
||||
automated diagnostics on zones, domains, services or users.
|
||||
|
||||
If you've never built a happyDomain plugin before, read
|
||||
[`checker-dummy`](https://git.happydns.org/checker-dummy) first; it is the
|
||||
reference implementation that this page mirrors.
|
||||
|
||||
> ⚠️ **Security note.** A `.so` plugin is loaded into the happyDomain process
|
||||
> and runs with the same privileges. happyDomain refuses to load plugins from
|
||||
> a directory that is group- or world-writable; keep your plugin directory
|
||||
> owned and writable only by the happyDomain user.
|
||||
|
||||
---
|
||||
|
||||
## What a checker plugin must export
|
||||
|
||||
happyDomain's loader looks for a single exported symbol named
|
||||
`NewCheckerPlugin` with this exact signature:
|
||||
|
||||
```go
|
||||
func NewCheckerPlugin() (
|
||||
*checker.CheckerDefinition,
|
||||
checker.ObservationProvider,
|
||||
error,
|
||||
)
|
||||
```
|
||||
|
||||
where `checker` is `git.happydns.org/checker-sdk-go/checker` (see
|
||||
[Licensing](#licensing) below for why the SDK lives in a separate module).
|
||||
|
||||
- `*CheckerDefinition` describes the checker: ID, name, version, options
|
||||
documentation, rules, optional aggregator, scheduling interval, and
|
||||
whether the checker exposes HTML reports or metrics. The `ID` field is
|
||||
the persistent key: pick something stable and namespaced
|
||||
(`com.example.dnssec-freshness`, not `dnssec`).
|
||||
- `ObservationProvider` is the data-collection half of the checker. It
|
||||
exposes a `Key()` (the observation key the rules will look up) and a
|
||||
`Collect(ctx, opts)` method that returns the raw observation payload.
|
||||
happyDomain serialises the result to JSON and caches it per
|
||||
`ObservationContext`.
|
||||
- Return a non-nil `error` if your plugin cannot initialise (missing
|
||||
environment variable, broken cgo dependency, …); the host will log it and
|
||||
skip the file rather than aborting startup.
|
||||
|
||||
### Registration and collisions
|
||||
|
||||
The loader calls `RegisterExternalizableChecker` and
|
||||
`RegisterObservationProvider` from the SDK registry. Pick globally unique
|
||||
identifiers: if your checker ID or observation key collides with a built-in
|
||||
or another plugin, the duplicate is ignored.
|
||||
|
||||
The same `.so` may export both `NewCheckerPlugin` and (e.g.)
|
||||
`NewProviderPlugin`. The loader runs every known plugin loader against
|
||||
every file, so a single binary can ship a checker, a provider and a service
|
||||
at once.
|
||||
|
||||
---
|
||||
|
||||
## Minimal example
|
||||
|
||||
```go
|
||||
// Command plugin is the happyDomain plugin entrypoint for the dummy checker.
|
||||
//
|
||||
// Build with:
|
||||
// go build -buildmode=plugin -o checker-dummy.so ./plugin
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type dummyProvider struct{}
|
||||
|
||||
func (dummyProvider) Key() sdk.ObservationKey { return "dummy.observation" }
|
||||
|
||||
func (dummyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
return map[string]string{"hello": "world"}, nil
|
||||
}
|
||||
|
||||
// NewCheckerPlugin is the symbol resolved by happyDomain at startup.
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
def := &sdk.CheckerDefinition{
|
||||
ID: "com.example.dummy",
|
||||
Name: "Dummy checker",
|
||||
Version: "0.1.0",
|
||||
ObservationKeys: []sdk.ObservationKey{"dummy.observation"},
|
||||
// Add Rules / Aggregator / Options here in a real plugin.
|
||||
}
|
||||
return def, dummyProvider{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Build and deploy:
|
||||
|
||||
```bash
|
||||
go build -buildmode=plugin -o checker-dummy.so ./plugin
|
||||
sudo install -m 0644 -o happydomain checker-dummy.so /var/lib/happydomain/plugins/
|
||||
sudo systemctl restart happydomain
|
||||
```
|
||||
|
||||
happyDomain will log:
|
||||
|
||||
```
|
||||
Plugin com.example.dummy (.../checker-dummy.so) loaded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build constraints and platform support
|
||||
|
||||
Go's `plugin` package is unforgiving:
|
||||
|
||||
- The plugin **must be built with the same Go version** as happyDomain
|
||||
itself, including the same toolchain patch level.
|
||||
- It **must use the same versions of every shared dependency**. Vendor the
|
||||
exact module versions happyDomain ships, or pin them in your `go.mod`
|
||||
with `replace` directives.
|
||||
- `CGO_ENABLED=1` is required.
|
||||
- `GOOS`/`GOARCH` must match the host binary.
|
||||
|
||||
If any of these don't match, `plugin.Open` will fail with a (sometimes
|
||||
cryptic) error like *"plugin was built with a different version of package
|
||||
…"*. The host will log it and skip the file.
|
||||
|
||||
Go's `plugin` package only works on **linux**, **darwin** and **freebsd**.
|
||||
On other platforms (Windows, plan9, …) happyDomain is built without plugin
|
||||
support and `--plugins-directory` is silently ignored apart from a warning
|
||||
log line at startup.
|
||||
|
||||
---
|
||||
|
||||
## Licensing
|
||||
|
||||
Checker plugins import only `git.happydns.org/checker-sdk-go/checker`,
|
||||
which is licensed under **Apache-2.0**. This is intentional: the
|
||||
checker SDK is a small, stable public API for third-party checkers,
|
||||
deliberately split out of the AGPL-3.0 happyDomain core so that
|
||||
permissively-licensed checker plugins are possible.
|
||||
|
||||
You may therefore distribute your checker `.so` under any license compatible
|
||||
with Apache-2.0. Note that this only covers checker plugins; provider and
|
||||
service plugins still link against AGPL code and remain subject to the
|
||||
AGPL-3.0 reciprocity rules described in their respective documentation
|
||||
([provider](provider-plugin.md), [service](service-plugin.md)).
|
||||
275
docs/prometheus.md
Normal file
275
docs/prometheus.md
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
# Scraping happyDomain checker metrics with Prometheus
|
||||
|
||||
happyDomain exposes check results as time-series metrics that Prometheus can
|
||||
scrape directly. This lets you alert on DNS health checks, track trends over
|
||||
time, and correlate domain health with the rest of your infrastructure.
|
||||
|
||||
> **Scope of this document:** user-facing metrics from the checker subsystem.
|
||||
> The admin-socket `/metrics` endpoint (happyDomain internal instrumentation)
|
||||
> is covered separately in [metrics.md](metrics.md).
|
||||
|
||||
---
|
||||
|
||||
## Getting started
|
||||
|
||||
### 1. Create an API token
|
||||
|
||||
The metrics endpoints require authentication. You need a long-lived API token
|
||||
(not your login session).
|
||||
|
||||
1. Log in to happyDomain.
|
||||
2. Go to the **Account** page (top-right user menu).
|
||||
3. Scroll down to the **Security & Access** section.
|
||||
4. Click **Create API key**.
|
||||
5. Give the key a descriptive name (e.g. `prometheus-scraper`).
|
||||
6. Click **Create API key** in the modal.
|
||||
7. **Copy the secret immediately** (it is shown only once).
|
||||
|
||||

|
||||
|
||||
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
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
BIN
docs/user-create-api-key.png
Normal file
BIN
docs/user-create-api-key.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
|
|
@ -21,10 +21,11 @@
|
|||
|
||||
package main
|
||||
|
||||
//go:generate go run tools/gen_instrumented_storage.go
|
||||
//go:generate go run tools/gen_icon.go providers providers
|
||||
//go:generate go run tools/gen_icon.go services svcs
|
||||
//go:generate go run tools/gen_rr_typescript.go web/src/lib/dns_rr.ts
|
||||
//go:generate go run tools/gen_service_specs.go -o web/src/lib/services_specs.ts
|
||||
//go:generate go run tools/gen_dns_type_mapping.go -o internal/usecase/service_specs_dns_types.go
|
||||
//go:generate swag init --exclude internal/api-admin/ --generalInfo internal/api/route/route.go
|
||||
//go:generate swag init --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go
|
||||
//go:generate swag init --parseDependency --exclude internal/api-admin/ --generalInfo internal/api/route/route.go
|
||||
//go:generate swag init --parseDependency --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go
|
||||
|
|
|
|||
24
go.mod
24
go.mod
|
|
@ -5,6 +5,12 @@ go 1.25.0
|
|||
toolchain go1.26.2
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489
|
||||
git.happydns.org/checker-ns-restrictions v0.0.0-20260415205411-f1e3096f606d
|
||||
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc
|
||||
git.happydns.org/checker-sdk-go v0.5.0
|
||||
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc
|
||||
github.com/JGLTechnologies/gin-rate-limit v1.5.8
|
||||
github.com/StackExchange/dnscontrol/v4 v4.34.0
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
|
|
@ -33,6 +39,16 @@ require (
|
|||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/likexian/gokit v0.25.16 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
|
|
@ -157,6 +173,8 @@ require (
|
|||
github.com/labstack/echo/v4 v4.15.1 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/likexian/whois v1.15.7
|
||||
github.com/likexian/whois-parser v1.24.21
|
||||
github.com/luadns/luadns-go v0.3.0 // indirect
|
||||
github.com/mailgun/raymond/v2 v2.0.48 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
|
|
@ -170,6 +188,7 @@ require (
|
|||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
|
||||
github.com/nrdcg/goinwx v0.12.0 // indirect
|
||||
github.com/openrdap/rdap v0.9.1
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
|
||||
github.com/oracle/oci-go-sdk/v65 v65.111.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
|
|
@ -179,9 +198,10 @@ require (
|
|||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus-community/pro-bing v0.8.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/common v0.67.5
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
|
|
|
|||
200
go.sum
200
go.sum
|
|
@ -1,25 +1,29 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
codeberg.org/miekg/dns v0.6.67 h1:vsVNsqAOE9uYscJHIHNtoCxiEySQn/B9BEvAUYI5Zmc=
|
||||
codeberg.org/miekg/dns v0.6.67/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
|
||||
codeberg.org/miekg/dns v0.6.73 h1:4aRD1k1THw49vpe1d+W3KO16adAGN8Raxdi0WGvvbrY=
|
||||
codeberg.org/miekg/dns v0.6.73/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489 h1:pTGfGq88Dj4Y60LJLSW4FvpUubeYpNlwuxKt/2IFzdo=
|
||||
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489/go.mod h1:fQjY1yWYFucu+Ebn5uYM7ZWTJNQIgjMENI/8tqlaR98=
|
||||
git.happydns.org/checker-ns-restrictions v0.0.0-20260415205411-f1e3096f606d h1:WqNi0vYSu7ZtPC4zNTwrZM64WcceaOistkF9qMxFQvE=
|
||||
git.happydns.org/checker-ns-restrictions v0.0.0-20260415205411-f1e3096f606d/go.mod h1:Sw8SqlrTAi3ZSVQQ+5kKE8reekxvLRm7oYlUYfJD+Z4=
|
||||
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc h1:jKEOx2NDbHHxjCy1fUkcn1RgpzOKbE+bGRsF+ITNigI=
|
||||
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc/go.mod h1:wphWmslFhKcpWfJTrHdChv8DkhUP9jwis7V2jy7vOX0=
|
||||
git.happydns.org/checker-sdk-go v0.5.0 h1:wpFIK/vxanrAYf1OlewSnSCYc7KOJKdu88uUWB7HIQI=
|
||||
git.happydns.org/checker-sdk-go v0.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc h1:y5xjoqLA/WztFWhEUifOwnJ6POjl+Udw6bWjzQ2afOw=
|
||||
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc/go.mod h1:B1P23OMm82GfAtYw8vCbspc7qULsFA0u/tqR+SGAaNw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c=
|
||||
|
|
@ -36,8 +40,6 @@ github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+W
|
|||
github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1 h1:edShSHV3DV90+kt+CMaEXEzR9QF7wFrPJxVGz2blMIU=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
|
|
@ -47,21 +49,19 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
|
|||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20251202014920-1725d2651bd4 h1:DQ1+lDdBve+u+aovjh4wV6sYnvZKH0Hx8GaQOi4vYl8=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20251202014920-1725d2651bd4/go.mod h1:eauGmjfZG874MOAEPVeqg21mZCbTOLW+tFe8F7NpfnY=
|
||||
github.com/CloudyKit/jet/v6 v6.3.1 h1:6IAo5Cx21xrHVaR8zzXN5gJatKV/wO7Nf6bfCnCSbUw=
|
||||
github.com/CloudyKit/jet/v6 v6.3.1/go.mod h1:lf8ksdNsxZt7/yH/3n4vJQWA9RUq4wpaHtArHhGVMOw=
|
||||
github.com/CloudyKit/jet/v6 v6.3.2 h1:BPaX0lnXTZ9TniICiiK/0iJqzeGJ2ibvB4DjAqLMBSM=
|
||||
github.com/CloudyKit/jet/v6 v6.3.2/go.mod h1:lf8ksdNsxZt7/yH/3n4vJQWA9RUq4wpaHtArHhGVMOw=
|
||||
github.com/G-Core/gcore-dns-sdk-go v0.3.3 h1:McILJSbJ5nOcT0MI0aBYhEuufCF329YbqKwFIN0RjCI=
|
||||
github.com/G-Core/gcore-dns-sdk-go v0.3.3/go.mod h1:35t795gOfzfVanhzkFyUXEzaBuMXwETmJldPpP28MN4=
|
||||
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
|
||||
github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0=
|
||||
github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g=
|
||||
github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc=
|
||||
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
|
||||
github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk=
|
||||
github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
|
|
@ -72,12 +72,14 @@ github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY
|
|||
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
||||
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0 h1:iGVPe/gPqzpXggbbmVLWR0TyJ9UoPoqKL+kspjseZzE=
|
||||
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0/go.mod h1:76JtkiCKMwTdTOlKe9goT4Md+oWjfMouGBQgy+u1bgc=
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0 h1:7oPti0aUS+YCep8nwt5b9g4jYfCU55ZruWESL8G9K5M=
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0/go.mod h1:I8ESLVWR9C58uvGufB/AJDPhaSU4+4Oh3DLpVtgwDAk=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
|
|
@ -87,68 +89,36 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP
|
|||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3 h1:JRPXnIr0WwFsSHBmuCvT/uh0Vgys+crvwkOghbJEqi8=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3/go.mod h1:DHddp7OO4bY467WVCqWBzk5+aEWn7vqYkap7UigJzGk=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 h1:Z+/OLsb85Kpq7TVLCspskqePaf68Tdv6GfmJP4kH6i0=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5/go.mod h1:TmxGowuBYwjmHFOsEDxaZdsQE62JJzOmtiWafTi/czg=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17 h1:Fw2SIR63jhfLpFZr6955zU3g9V8ouHC/pRpmmiHmIFM=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17/go.mod h1:x9PRRtbCQ/gv1ziQPXFB7nQwQgVLQ+FSvPIkVAhRcYY=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.19 h1:I1uSW0oydwLZWp4IDjGqAJY+EoNFylgNxxcXeOSioVk=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.19/go.mod h1:1qCxun61Kq+S1e790tY+MpOKQ29DoOt2Fdx8Efgmo2g=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
|
||||
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
|
|
@ -165,20 +135,15 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF
|
|||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18 h1:RvyTDU0VmnUBd3Qm2i6irEXtCR2KRIxnRlD8l+5z/DY=
|
||||
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18/go.mod h1:a6n4wXFHbMW0iJFxHIJR4PkgG5krP52nOVCBU0m+Obw=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
|
|
@ -196,12 +161,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
|||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deepmap/oapi-codegen v1.16.3 h1:GT9G86SbQtT1r8ZB+4Cybi9VGdu1P5ieNvNdEoCSbrA=
|
||||
github.com/deepmap/oapi-codegen v1.16.3/go.mod h1:JD6ErqeX0nYnhdciLc61Konj3NBASREMlkHOgHn8WAM=
|
||||
github.com/digitalocean/godo v1.176.0 h1:P379vPO5TUre+bUHPEsdSAbl5vIrRRhP91tMIEPoWYU=
|
||||
github.com/digitalocean/godo v1.176.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/digitalocean/godo v1.184.0 h1:2B2CQhxftlf3xa24Nrzn5CBQlaQjyaWqi3XbbnJlG3w=
|
||||
github.com/digitalocean/godo v1.184.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
|
||||
github.com/dnsimple/dnsimple-go/v8 v8.1.0 h1:U4ENaNCe5aUFHLiF7lj2NNpLPzFY3YIriu/UzrdfUbg=
|
||||
github.com/dnsimple/dnsimple-go/v8 v8.1.0/go.mod h1:61MdYHRL+p2TBBUVEkxo1n4iRF6s3R9fZcvQvyt5du8=
|
||||
github.com/dnsimple/dnsimple-go/v8 v8.2.0 h1:nNgtqKrt1K1BIWIpKTCL2qCiQcfYUxzsyRGIKLYEYH0=
|
||||
github.com/dnsimple/dnsimple-go/v8 v8.2.0/go.mod h1:61MdYHRL+p2TBBUVEkxo1n4iRF6s3R9fZcvQvyt5du8=
|
||||
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
|
||||
|
|
@ -231,8 +194,6 @@ github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d
|
|||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
|
||||
github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
|
|
@ -249,56 +210,33 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-mail/mail v2.3.1+incompatible h1:UzNOn0k5lpfVtO31cK3hn6I4VEVGhe3lX8AJBAxXExM=
|
||||
github.com/go-mail/mail v2.3.1+incompatible/go.mod h1:VPWjmmNyRsWXQZHVHT3g0YbIINUkSmuKOiLIDkWbL6M=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
|
||||
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
|
||||
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
|
||||
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
|
||||
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
|
||||
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
|
|
@ -311,8 +249,6 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
|
|||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
|
|
@ -321,8 +257,6 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
|||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe h1:zn8tqiUbec4wR94o7Qj3LZCAT6uGobhEgnDRg6isG5U=
|
||||
github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXKkTfoE=
|
||||
|
|
@ -333,7 +267,6 @@ github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8
|
|||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
|
|
@ -376,12 +309,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
|||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
|
||||
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
|
|
@ -408,14 +337,10 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU
|
|||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g=
|
||||
github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.36.0 h1:HlLL/aaVXUulqe+rsjoJmrxKhPi1MflL5O9iq5QEtvo=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.36.0/go.mod h1:MnN/QJEa/RYNQiiVoJjNHPntM7Z1wlYPgJ2HA40/cDE=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.37.0 h1:PMnuOA8pL8aHLLPp6nnnCTo2Xk2tqu4dAfYsC3bWdT0=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.37.0/go.mod h1:zaDOCKmpnI86ftoCpUpaiYaw9Wew1ib1AcXTh96deYI=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 h1:J+U6+eUjIsBhefolFdZW5hQNJbkMj+7msxZrv56Cg2g=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.192 h1:xgKdmcALGqLGBrBG8stMli0k+irufCeNPenn76Y4U9o=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.192/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
|
||||
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
|
||||
|
|
@ -458,8 +383,6 @@ github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwf
|
|||
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
||||
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
|
|
@ -474,8 +397,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
|
||||
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
|
||||
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
|
|
@ -489,12 +410,16 @@ github.com/libdns/ionos v1.2.0 h1:FQ2xQTBfsjc7aMArRBBCs9l48Squt76GHXbxDsqOKgw=
|
|||
github.com/libdns/ionos v1.2.0/go.mod h1:g/JYno/+VXdujTGPBDMDeCfeLF0PJyJynsCrFu+2EFQ=
|
||||
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
|
||||
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||
github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP8=
|
||||
github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4=
|
||||
github.com/likexian/whois v1.15.7 h1:sajjDhi2bVD71AHJhjV7jLYxN92H4AWhTwxM8hmj7c0=
|
||||
github.com/likexian/whois v1.15.7/go.mod h1:kdPQtYb+7SQVftBEbCblDadUkycN7Mg1k1/Li/rwvmc=
|
||||
github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpOtTDA5OsM=
|
||||
github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4=
|
||||
github.com/luadns/luadns-go v0.3.0 h1:mN2yhFv/LnGvPw/HmvYUhXe+lc95oXUqjlYVeJeOJng=
|
||||
github.com/luadns/luadns-go v0.3.0/go.mod h1:DmPXbrGMpynq1YNDpvgww3NP5Zf4wXM5raAbGrp5L+8=
|
||||
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
|
||||
github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
|
|
@ -504,8 +429,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
|||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
|
||||
|
|
@ -545,19 +468,16 @@ github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGm
|
|||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/openrdap/rdap v0.9.1 h1:Rv6YbanbiVPsKRvOLdUmlU1AL5+2OFuEFLjFN+mQsCM=
|
||||
github.com/openrdap/rdap v0.9.1/go.mod h1:vKSiotbsENrjM/vaHXLddXbW8iQkBfa+ldEuYEjyLTQ=
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
|
||||
github.com/oracle/nosql-go-sdk v1.4.7 h1:dqVBSMulObDj0JHm1mAncTHrQg8wIiQJNC0JRNKPACg=
|
||||
github.com/oracle/nosql-go-sdk v1.4.7/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.109.0 h1:EsbFVvcV+uid9SoQnFQbTKS6FgqsM9NtoKmUIovKsbk=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.109.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.111.0 h1:eDkWg6ZN0uKwWzSekoFcQJhR+C+F/aVdTwr+lGHU9Qk=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.111.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
|
||||
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
|
||||
github.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c=
|
||||
|
|
@ -573,6 +493,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
|
||||
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
|
|
@ -580,8 +502,6 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
|||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
|
||||
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 h1:wSmWgpuccqS2IOfmYrbRiUgv+g37W5suLLLxwwniTSc=
|
||||
|
|
@ -590,6 +510,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
|||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0=
|
||||
github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
|
|
@ -635,6 +557,7 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F
|
|||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
|
|
@ -646,20 +569,14 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
|||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/tdewolff/minify/v2 v2.24.9 h1:W6A570F9N6MuZtg9mdHXD93piZZIWJaGpbAw9Narrfw=
|
||||
github.com/tdewolff/minify/v2 v2.24.9/go.mod h1:9F66jUzl/Pdf6Q5x0RXFUsI/8N1kjBb3ILg9ABSWoOI=
|
||||
github.com/tdewolff/minify/v2 v2.24.12 h1:YXJxVJmz7vxgnEv1v8J/EI4x+Uw4MMohcRFK7TFOjmk=
|
||||
github.com/tdewolff/minify/v2 v2.24.12/go.mod h1:exq1pjdrh9uAICdfVKQwqz6MsJmWmQahZuTC6pTO6ro=
|
||||
github.com/tdewolff/parse/v2 v2.8.8 h1:l3yOJ4OUKq1sKeQQxZ7P2yZ6daW/Oq4IDxL98uTOpPI=
|
||||
github.com/tdewolff/parse/v2 v2.8.8/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/parse/v2 v2.8.11 h1:SGyjEy3xEqd+W9WVzTlTQ5GkP/en4a1AZNZVJ1cvgm0=
|
||||
github.com/tdewolff/parse/v2 v2.8.11/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/transip/gotransip/v6 v6.26.1 h1:MeqIjkTBBsZwWAK6giZyMkqLmKMclVHEuTNmoBdx4MA=
|
||||
github.com/transip/gotransip/v6 v6.26.1/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=
|
||||
github.com/transip/gotransip/v6 v6.26.2 h1:pnbDXrkFevOngpi6ertLw6e57lOW+Rk3djJ9AewmJ94=
|
||||
github.com/transip/gotransip/v6 v6.26.2/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
|
|
@ -676,7 +593,6 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
|||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/vercel/terraform-provider-vercel v1.14.1 h1:ghAjFkMMzka4XuoBYdu1OXM/K7FQEj8wUd+xMPPOGrg=
|
||||
github.com/vercel/terraform-provider-vercel v1.14.1/go.mod h1:AdFCiUD0XP8XOi6tnhaCh7I0vyq2TAPmI+GcIp3+7SI=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
|
|
@ -694,6 +610,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
|
|||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
|
||||
|
|
@ -718,44 +636,32 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
|
|||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
|
||||
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
|
@ -777,8 +683,6 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL
|
|||
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
|
||||
|
|
@ -795,8 +699,6 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
|
|
@ -819,8 +721,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
|
|
@ -859,7 +759,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
|
@ -890,8 +789,6 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
|
@ -908,8 +805,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
@ -919,36 +814,27 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
|
|||
gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
|
||||
gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
|
||||
gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
|
||||
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
|
||||
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
|
||||
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
|
||||
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
|
||||
google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI=
|
||||
google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
|
|
|
|||
|
|
@ -26,11 +26,13 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/StackExchange/dnscontrol/v4/models"
|
||||
dnscontrol "github.com/StackExchange/dnscontrol/v4/pkg/providers"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -150,7 +152,11 @@ func NewDNSControlProviderAdapter(configAdapter DNSControlConfigAdapter) (ret ha
|
|||
auditor = p.RecordAuditor
|
||||
}
|
||||
|
||||
return &DNSControlAdapterNSProvider{provider, auditor}, nil
|
||||
return &DNSControlAdapterNSProvider{
|
||||
DNSServiceProvider: provider,
|
||||
RecordAuditor: auditor,
|
||||
providerName: configAdapter.DNSControlName(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DNSControlAdapterNSProvider wraps a DNSControl provider to implement the happyDomain ProviderActuator interface.
|
||||
|
|
@ -160,6 +166,8 @@ type DNSControlAdapterNSProvider struct {
|
|||
DNSServiceProvider dnscontrol.DNSServiceProvider
|
||||
// RecordAuditor validates records for provider-specific requirements
|
||||
RecordAuditor dnscontrol.RecordAuditor
|
||||
// providerName is the DNSControl provider name used for metrics labels
|
||||
providerName string
|
||||
}
|
||||
|
||||
// CanListZones checks if the provider supports listing zones (domains).
|
||||
|
|
@ -169,6 +177,26 @@ func (p *DNSControlAdapterNSProvider) CanListZones() bool {
|
|||
return ok
|
||||
}
|
||||
|
||||
// observeProviderCall starts timing a provider API call and returns a closure
|
||||
// that records the outcome. Intended use:
|
||||
//
|
||||
// defer p.observeProviderCall("op")(&err)
|
||||
//
|
||||
// The returned closure reads *err at defer-execution time, so it observes the
|
||||
// final value of the named return even if it is reassigned later in the
|
||||
// function (including from a recover() block).
|
||||
func (p *DNSControlAdapterNSProvider) observeProviderCall(operation string) func(err *error) {
|
||||
start := time.Now()
|
||||
return func(err *error) {
|
||||
status := "success"
|
||||
if err != nil && *err != nil {
|
||||
status = "error"
|
||||
}
|
||||
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, operation, status).Inc()
|
||||
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, operation).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
}
|
||||
|
||||
// CanCreateDomain checks if the provider supports creating new domains.
|
||||
// Returns true if the provider implements the ZoneCreator interface.
|
||||
func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
|
||||
|
|
@ -182,6 +210,7 @@ func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
|
|||
func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happydns.Record, err error) {
|
||||
var records models.Records
|
||||
|
||||
defer p.observeProviderCall("get_zone_records")(&err)
|
||||
defer func() {
|
||||
if a := recover(); a != nil {
|
||||
err = fmt.Errorf("%s", a)
|
||||
|
|
@ -211,6 +240,8 @@ func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happy
|
|||
// before computing corrections.
|
||||
// Returns a slice of corrections, the total number of corrections needed, and any error.
|
||||
func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []happydns.Record) (ret []*happydns.Correction, nbCorrections int, err error) {
|
||||
defer p.observeProviderCall("get_zone_corrections")(&err)
|
||||
|
||||
var dc *models.DomainConfig
|
||||
dc, err = NewDNSControlDomainConfig(strings.TrimSuffix(domain, "."), rrs)
|
||||
if err != nil {
|
||||
|
|
@ -261,23 +292,31 @@ func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []ha
|
|||
// CreateDomain creates a new zone (domain) on the provider.
|
||||
// The fqdn parameter should be a fully qualified domain name (with or without trailing dot).
|
||||
// Returns an error if the provider doesn't support domain creation or if creation fails.
|
||||
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) error {
|
||||
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) (err error) {
|
||||
defer p.observeProviderCall("create_domain")(&err)
|
||||
|
||||
zc, ok := p.DNSServiceProvider.(dnscontrol.ZoneCreator)
|
||||
if !ok {
|
||||
return fmt.Errorf("Provider doesn't support domain creation.")
|
||||
err = fmt.Errorf("Provider doesn't support domain creation.")
|
||||
return
|
||||
}
|
||||
|
||||
return zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
|
||||
err = zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
|
||||
return
|
||||
}
|
||||
|
||||
// ListZones retrieves a list of all zones (domains) managed by this provider.
|
||||
// Returns a slice of domain names or an error if the provider doesn't support listing
|
||||
// or if the operation fails.
|
||||
func (p *DNSControlAdapterNSProvider) ListZones() ([]string, error) {
|
||||
func (p *DNSControlAdapterNSProvider) ListZones() (zones []string, err error) {
|
||||
defer p.observeProviderCall("list_zones")(&err)
|
||||
|
||||
zl, ok := p.DNSServiceProvider.(dnscontrol.ZoneLister)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Provider doesn't support domain listing.")
|
||||
err = fmt.Errorf("Provider doesn't support domain listing.")
|
||||
return
|
||||
}
|
||||
|
||||
return zl.ListZones()
|
||||
zones, err = zl.ListZones()
|
||||
return
|
||||
}
|
||||
|
|
|
|||
272
internal/adapters/dnscontrol-providers_test.go
Normal file
272
internal/adapters/dnscontrol-providers_test.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package adapter provides tests for DNSControlAdapterNSProvider instrumentation.
|
||||
// These tests live in the internal package so they can construct the struct
|
||||
// directly and set the unexported providerName field used in metric labels.
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnscontrolmodels "github.com/StackExchange/dnscontrol/v4/models"
|
||||
dnscontrol "github.com/StackExchange/dnscontrol/v4/pkg/providers"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
)
|
||||
|
||||
// --- mock DNSServiceProvider -------------------------------------------------
|
||||
|
||||
// mockDNSProvider implements dnscontrol.DNSServiceProvider (i.e. models.DNSProvider).
|
||||
type mockDNSProvider struct {
|
||||
getZoneRecordsErr error
|
||||
getZoneRecordsResult dnscontrolmodels.Records
|
||||
correctionsErr error
|
||||
panicOnGetZoneRecords bool
|
||||
}
|
||||
|
||||
func (m *mockDNSProvider) GetNameservers(domain string) ([]*dnscontrolmodels.Nameserver, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockDNSProvider) GetZoneRecords(domain string, meta map[string]string) (dnscontrolmodels.Records, error) {
|
||||
if m.panicOnGetZoneRecords {
|
||||
panic("simulated provider panic")
|
||||
}
|
||||
return m.getZoneRecordsResult, m.getZoneRecordsErr
|
||||
}
|
||||
|
||||
func (m *mockDNSProvider) GetZoneRecordsCorrections(dc *dnscontrolmodels.DomainConfig, existing dnscontrolmodels.Records) ([]*dnscontrolmodels.Correction, int, error) {
|
||||
return nil, 0, m.correctionsErr
|
||||
}
|
||||
|
||||
// mockZoneLister extends mockDNSProvider with ZoneLister.
|
||||
type mockZoneLister struct {
|
||||
mockDNSProvider
|
||||
listErr error
|
||||
listResult []string
|
||||
}
|
||||
|
||||
func (m *mockZoneLister) ListZones() ([]string, error) {
|
||||
return m.listResult, m.listErr
|
||||
}
|
||||
|
||||
// mockZoneCreator extends mockDNSProvider with ZoneCreator.
|
||||
type mockZoneCreator struct {
|
||||
mockDNSProvider
|
||||
ensureErr error
|
||||
}
|
||||
|
||||
func (m *mockZoneCreator) EnsureZoneExists(domain string, metadata map[string]string) error {
|
||||
return m.ensureErr
|
||||
}
|
||||
|
||||
// --- helpers -----------------------------------------------------------------
|
||||
|
||||
// noopAuditor is a RecordAuditor that approves all records.
|
||||
func noopAuditor(rcs []*dnscontrolmodels.RecordConfig) []error { return nil }
|
||||
|
||||
// newTestAdapter constructs a DNSControlAdapterNSProvider with the given
|
||||
// provider mock and a fixed providerName so metric labels are predictable.
|
||||
func newTestAdapter(provider dnscontrol.DNSServiceProvider) *DNSControlAdapterNSProvider {
|
||||
return &DNSControlAdapterNSProvider{
|
||||
DNSServiceProvider: provider,
|
||||
RecordAuditor: noopAuditor,
|
||||
providerName: "TEST_PROVIDER",
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetZoneRecords ----------------------------------------------------------
|
||||
|
||||
func TestObserveProviderCall_GetZoneRecords_Success(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockDNSProvider{})
|
||||
_, err := a.GetZoneRecords("example.com.")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "success")); got != 1 {
|
||||
t.Errorf("expected counter=1 for success, got %v", got)
|
||||
}
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "error")); got != 0 {
|
||||
t.Errorf("expected error counter=0, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_GetZoneRecords_Error(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockDNSProvider{getZoneRecordsErr: errors.New("upstream timeout")})
|
||||
_, err := a.GetZoneRecords("example.com.")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1, got %v", got)
|
||||
}
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "success")); got != 0 {
|
||||
t.Errorf("expected success counter=0, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_GetZoneRecords_Panic(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockDNSProvider{panicOnGetZoneRecords: true})
|
||||
// The recover() block in GetZoneRecords must catch the panic and return an
|
||||
// error, which the observe closure then records as "error".
|
||||
_, err := a.GetZoneRecords("example.com.")
|
||||
if err == nil {
|
||||
t.Fatal("expected panic to be recovered as an error, got nil")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1 after recovered panic, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetZoneCorrections ------------------------------------------------------
|
||||
|
||||
func TestObserveProviderCall_GetZoneCorrections_Success(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockDNSProvider{})
|
||||
_, _, err := a.GetZoneCorrections("example.com.", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_corrections", "success")); got != 1 {
|
||||
t.Errorf("expected counter=1 for success, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_GetZoneCorrections_Error(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockDNSProvider{getZoneRecordsErr: errors.New("provider down")})
|
||||
_, _, err := a.GetZoneCorrections("example.com.", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_corrections", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CreateDomain ------------------------------------------------------------
|
||||
|
||||
func TestObserveProviderCall_CreateDomain_NotSupported(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
// mockDNSProvider does not implement ZoneCreator, so CreateDomain must fail.
|
||||
a := newTestAdapter(&mockDNSProvider{})
|
||||
err := a.CreateDomain("example.com.")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when provider does not support domain creation")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "create_domain", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_CreateDomain_Success(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockZoneCreator{})
|
||||
err := a.CreateDomain("example.com.")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "create_domain", "success")); got != 1 {
|
||||
t.Errorf("expected success counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_CreateDomain_Error(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockZoneCreator{ensureErr: errors.New("zone already exists")})
|
||||
err := a.CreateDomain("example.com.")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "create_domain", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ListZones ---------------------------------------------------------------
|
||||
|
||||
func TestObserveProviderCall_ListZones_NotSupported(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
// mockDNSProvider does not implement ZoneLister, so ListZones must fail.
|
||||
a := newTestAdapter(&mockDNSProvider{})
|
||||
_, err := a.ListZones()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when provider does not support zone listing")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "list_zones", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_ListZones_Success(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockZoneLister{listResult: []string{"example.com", "example.net"}})
|
||||
zones, err := a.ListZones()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(zones) != 2 {
|
||||
t.Errorf("expected 2 zones, got %d", len(zones))
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "list_zones", "success")); got != 1 {
|
||||
t.Errorf("expected success counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_ListZones_Error(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockZoneLister{listErr: errors.New("API rate limit")})
|
||||
_, err := a.ListZones()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "list_zones", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
66
internal/api-admin/controller/check_controller.go
Normal file
66
internal/api-admin/controller/check_controller.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
apicontroller "git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
)
|
||||
|
||||
// AdminCheckerController handles admin checker-related API endpoints.
|
||||
// It embeds CheckerController and overrides GetCheckerOptions to return a flat
|
||||
// (non-positional) map scoped to nil (global/admin) level.
|
||||
type AdminCheckerController struct {
|
||||
*apicontroller.CheckerController
|
||||
}
|
||||
|
||||
// NewAdminCheckerController creates a new AdminCheckerController.
|
||||
func NewAdminCheckerController(optionsUC *checkerUC.CheckerOptionsUsecase) *AdminCheckerController {
|
||||
return &AdminCheckerController{
|
||||
// countManualTriggers=false because admin has no budgetChecker (nil),
|
||||
// which means the flag is inert on this path — value is for clarity.
|
||||
CheckerController: apicontroller.NewCheckerController(nil, optionsUC, nil, nil, nil, nil, false),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCheckerOptions returns admin-level options (nil scope) for a checker as a flat map.
|
||||
//
|
||||
// @Summary Get admin-level checker options
|
||||
// @Tags admin,checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Success 200 {object} checker.CheckerOptions
|
||||
// @Router /checkers/{checkerId}/options [get]
|
||||
func (cc *AdminCheckerController) GetCheckerOptions(c *gin.Context) {
|
||||
checkerID := c.Param("checkerId")
|
||||
opts, err := cc.OptionsUC.GetCheckerOptions(checkerID, nil, nil, nil)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, opts)
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ func NewDomainController(
|
|||
func (dc *DomainController) ListDomains(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
if user != nil {
|
||||
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter)
|
||||
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter, nil)
|
||||
apidc.GetDomains(c)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
100
internal/api-admin/controller/scheduler_controller.go
Normal file
100
internal/api-admin/controller/scheduler_controller.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
)
|
||||
|
||||
// AdminSchedulerController handles admin scheduler API endpoints.
|
||||
type AdminSchedulerController struct {
|
||||
scheduler *checkerUC.Scheduler
|
||||
}
|
||||
|
||||
// NewAdminSchedulerController creates a new AdminSchedulerController.
|
||||
func NewAdminSchedulerController(scheduler *checkerUC.Scheduler) *AdminSchedulerController {
|
||||
return &AdminSchedulerController{scheduler: scheduler}
|
||||
}
|
||||
|
||||
// GetSchedulerStatus returns the current scheduler status.
|
||||
//
|
||||
// @Summary Get scheduler status
|
||||
// @Tags admin-scheduler
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} checkerUC.SchedulerStatus
|
||||
// @Router /scheduler [get]
|
||||
func (s *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s.scheduler.GetStatus())
|
||||
}
|
||||
|
||||
// EnableScheduler starts the scheduler and returns updated status.
|
||||
//
|
||||
// @Summary Enable the scheduler
|
||||
// @Tags admin-scheduler
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} checkerUC.SchedulerStatus
|
||||
// @Failure 500 {object} object
|
||||
// @Router /scheduler/enable [post]
|
||||
func (s *AdminSchedulerController) EnableScheduler(c *gin.Context) {
|
||||
if err := s.scheduler.SetEnabled(c.Request.Context(), true); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.scheduler.GetStatus())
|
||||
}
|
||||
|
||||
// DisableScheduler stops the scheduler and returns updated status.
|
||||
//
|
||||
// @Summary Disable the scheduler
|
||||
// @Tags admin-scheduler
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} checkerUC.SchedulerStatus
|
||||
// @Failure 500 {object} object
|
||||
// @Router /scheduler/disable [post]
|
||||
func (s *AdminSchedulerController) DisableScheduler(c *gin.Context) {
|
||||
if err := s.scheduler.SetEnabled(c.Request.Context(), false); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.scheduler.GetStatus())
|
||||
}
|
||||
|
||||
// RescheduleUpcoming rebuilds the job queue and returns the new count.
|
||||
//
|
||||
// @Summary Rebuild the scheduler queue
|
||||
// @Tags admin-scheduler
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} map[string]int
|
||||
// @Router /scheduler/reschedule-upcoming [post]
|
||||
func (s *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) {
|
||||
n := s.scheduler.RebuildQueue()
|
||||
c.JSON(http.StatusOK, gin.H{"rescheduled": n})
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ package controller
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
|
|
@ -168,7 +169,20 @@ func (uc *UserController) UpdateUser(c *gin.Context) {
|
|||
}
|
||||
uu.Id = user.Id
|
||||
|
||||
happydns.ApiResponse(c, uu, uc.store.CreateOrUpdateUser(uu))
|
||||
updated, err := uc.userService.UpdateUser(uu.Id, func(u *happydns.User) {
|
||||
// Stamp quota update time if quota fields changed.
|
||||
if uu.Quota != u.Quota {
|
||||
uu.Quota.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
u.Email = uu.Email
|
||||
u.CreatedAt = uu.CreatedAt
|
||||
u.LastSeen = uu.LastSeen
|
||||
u.Settings = uu.Settings
|
||||
u.Quota = uu.Quota
|
||||
})
|
||||
|
||||
happydns.ApiResponse(c, updated, err)
|
||||
}
|
||||
|
||||
// deleteUser removes a specific user from the database.
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ func (zc *ZoneController) DeleteZone(c *gin.Context) {
|
|||
// @Router /users/{uid}/domains/{domain}/zones/{zoneid} [get]
|
||||
// @Router /users/{uid}/providers/{pid}/domains/{domain}/zones/{zoneid} [get]
|
||||
func (zc *ZoneController) GetZone(c *gin.Context) {
|
||||
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService)
|
||||
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService, nil)
|
||||
apizc.GetZone(c)
|
||||
}
|
||||
|
||||
|
|
|
|||
51
internal/api-admin/route/checker.go
Normal file
51
internal/api-admin/route/checker.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api-admin/controller"
|
||||
)
|
||||
|
||||
func declareCheckersRoutes(router *gin.RouterGroup, dep Dependencies) {
|
||||
if dep.CheckerOptionsUC == nil {
|
||||
return
|
||||
}
|
||||
cc := controller.NewAdminCheckerController(dep.CheckerOptionsUC)
|
||||
|
||||
apiCheckersRoutes := router.Group("/checkers")
|
||||
apiCheckersRoutes.GET("", cc.ListCheckers)
|
||||
|
||||
apiCheckerRoutes := apiCheckersRoutes.Group("/:checkerId")
|
||||
apiCheckerRoutes.Use(cc.CheckerHandler)
|
||||
apiCheckerRoutes.GET("", cc.GetChecker)
|
||||
|
||||
apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options")
|
||||
apiCheckerOptionsRoutes.GET("", cc.GetCheckerOptions)
|
||||
apiCheckerOptionsRoutes.POST("", cc.AddCheckerOptions)
|
||||
apiCheckerOptionsRoutes.PUT("", cc.ChangeCheckerOptions)
|
||||
|
||||
apiCheckerOptionRoutes := apiCheckerOptionsRoutes.Group("/:optname")
|
||||
apiCheckerOptionRoutes.GET("", cc.GetCheckerOption)
|
||||
apiCheckerOptionRoutes.PUT("", cc.SetCheckerOption)
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import (
|
|||
|
||||
api "git.happydns.org/happyDomain/internal/api/route"
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -41,14 +42,18 @@ type Dependencies struct {
|
|||
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
|
||||
ZoneImporter happydns.ZoneImporterUsecase
|
||||
ZoneService happydns.ZoneServiceUsecase
|
||||
CheckerOptionsUC *checkerUC.CheckerOptionsUsecase
|
||||
CheckScheduler *checkerUC.Scheduler
|
||||
}
|
||||
|
||||
func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage, dep Dependencies) {
|
||||
apiRoutes := router.Group("/api")
|
||||
|
||||
declareBackupRoutes(cfg, apiRoutes, s)
|
||||
declareCheckersRoutes(apiRoutes, dep)
|
||||
declareDomainRoutes(apiRoutes, dep, s)
|
||||
declareProviderRoutes(apiRoutes, dep, s)
|
||||
declareSchedulerRoutes(apiRoutes, dep)
|
||||
declareSessionsRoutes(cfg, apiRoutes, s)
|
||||
declareUserAuthsRoutes(apiRoutes, dep, s)
|
||||
declareUsersRoutes(apiRoutes, dep, s)
|
||||
|
|
|
|||
41
internal/api-admin/route/scheduler.go
Normal file
41
internal/api-admin/route/scheduler.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api-admin/controller"
|
||||
)
|
||||
|
||||
func declareSchedulerRoutes(router *gin.RouterGroup, dep Dependencies) {
|
||||
if dep.CheckScheduler == nil {
|
||||
return
|
||||
}
|
||||
ctrl := controller.NewAdminSchedulerController(dep.CheckScheduler)
|
||||
|
||||
schedulerRoute := router.Group("/scheduler")
|
||||
schedulerRoute.GET("", ctrl.GetSchedulerStatus)
|
||||
schedulerRoute.POST("/enable", ctrl.EnableScheduler)
|
||||
schedulerRoute.POST("/disable", ctrl.DisableScheduler)
|
||||
schedulerRoute.POST("/reschedule-upcoming", ctrl.RescheduleUpcoming)
|
||||
}
|
||||
272
internal/api/controller/checker.go
Normal file
272
internal/api/controller/checker.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// CheckerController handles checker-related API endpoints.
|
||||
type CheckerController struct {
|
||||
engine happydns.CheckerEngine
|
||||
OptionsUC *checkerUC.CheckerOptionsUsecase
|
||||
planUC *checkerUC.CheckPlanUsecase
|
||||
statusUC *checkerUC.CheckStatusUsecase
|
||||
plannedProvider checkerUC.PlannedJobProvider
|
||||
budgetChecker checkerUC.BudgetChecker
|
||||
countManualTriggers bool
|
||||
}
|
||||
|
||||
// NewCheckerController creates a new CheckerController.
|
||||
//
|
||||
// countManualTriggers controls whether manual (API-triggered) checker runs
|
||||
// count against the user's MaxChecksPerDay budget. When true and
|
||||
// budgetChecker is non-nil, TriggerCheck refuses the request with HTTP 429
|
||||
// once the user is over budget and increments the counter on success.
|
||||
// When false, manual triggers bypass the quota entirely (legacy behavior).
|
||||
// The value is ignored when budgetChecker is nil.
|
||||
func NewCheckerController(
|
||||
engine happydns.CheckerEngine,
|
||||
optionsUC *checkerUC.CheckerOptionsUsecase,
|
||||
planUC *checkerUC.CheckPlanUsecase,
|
||||
statusUC *checkerUC.CheckStatusUsecase,
|
||||
plannedProvider checkerUC.PlannedJobProvider,
|
||||
budgetChecker checkerUC.BudgetChecker,
|
||||
countManualTriggers bool,
|
||||
) *CheckerController {
|
||||
return &CheckerController{
|
||||
engine: engine,
|
||||
OptionsUC: optionsUC,
|
||||
planUC: planUC,
|
||||
statusUC: statusUC,
|
||||
plannedProvider: plannedProvider,
|
||||
budgetChecker: budgetChecker,
|
||||
countManualTriggers: countManualTriggers,
|
||||
}
|
||||
}
|
||||
|
||||
// StatusUC returns the CheckStatusUsecase for use by other controllers.
|
||||
func (cc *CheckerController) StatusUC() *checkerUC.CheckStatusUsecase {
|
||||
return cc.statusUC
|
||||
}
|
||||
|
||||
// targetFromContext builds a CheckTarget from middleware context values.
|
||||
func targetFromContext(c *gin.Context) happydns.CheckTarget {
|
||||
user := middleware.MyUser(c)
|
||||
target := happydns.CheckTarget{}
|
||||
if user != nil {
|
||||
target.UserId = user.Id.String()
|
||||
}
|
||||
if domain, exists := c.Get("domain"); exists {
|
||||
d := domain.(*happydns.Domain)
|
||||
target.DomainId = d.Id.String()
|
||||
}
|
||||
if sid, exists := c.Get("serviceid"); exists {
|
||||
id := sid.(happydns.Identifier)
|
||||
target.ServiceId = id.String()
|
||||
if z, zExists := c.Get("zone"); zExists {
|
||||
zone := z.(*happydns.Zone)
|
||||
if _, svc := zone.FindService(id); svc != nil {
|
||||
target.ServiceType = svc.Type
|
||||
}
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// --- Global checker routes ---
|
||||
|
||||
// ListCheckers returns all registered checker definitions.
|
||||
//
|
||||
// @Summary List available checkers
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]checker.CheckerDefinition
|
||||
// @Router /checkers [get]
|
||||
func (cc *CheckerController) ListCheckers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, checkerPkg.GetCheckers())
|
||||
}
|
||||
|
||||
// GetChecker returns a specific checker definition.
|
||||
//
|
||||
// @Summary Get a checker definition
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Success 200 {object} checker.CheckerDefinition
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /checkers/{checkerId} [get]
|
||||
func (cc *CheckerController) GetChecker(c *gin.Context) {
|
||||
def, _ := c.Get("checker")
|
||||
c.JSON(http.StatusOK, def)
|
||||
}
|
||||
|
||||
// CheckerHandler is a middleware that validates the checkerId path parameter and sets "checker" in context.
|
||||
func (cc *CheckerController) CheckerHandler(c *gin.Context) {
|
||||
checkerID := c.Param("checkerId")
|
||||
def := checkerPkg.FindChecker(checkerID)
|
||||
if def == nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Checker not found"})
|
||||
return
|
||||
}
|
||||
c.Set("checker", def)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// --- Scoped routes (domain/service) ---
|
||||
|
||||
// ListAvailableChecks lists all checkers with their latest status for a target.
|
||||
//
|
||||
// @Summary List available checks with status
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {array} happydns.CheckerStatus
|
||||
// @Router /domains/{domain}/checkers [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers [get]
|
||||
func (cc *CheckerController) ListAvailableChecks(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
|
||||
result, err := cc.statusUC.ListCheckerStatuses(target)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// TriggerCheck manually triggers a checker execution.
|
||||
// By default the check runs asynchronously and returns an Execution (HTTP 202).
|
||||
// Pass ?sync=true to block until the check completes and return a CheckEvaluation (HTTP 200).
|
||||
//
|
||||
// @Summary Trigger a manual check
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param sync query bool false "Run synchronously"
|
||||
// @Param body body happydns.CheckerRunRequest false "Run request with options and enabled rules"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.CheckEvaluation
|
||||
// @Success 202 {object} happydns.Execution
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Failure 429 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions [post]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [post]
|
||||
func (cc *CheckerController) TriggerCheck(c *gin.Context) {
|
||||
cname := c.Param("checkerId")
|
||||
|
||||
var req happydns.CheckerRunRequest
|
||||
// Body is optional; io.EOF means no body was sent, which is valid (no custom options or rules).
|
||||
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
target := targetFromContext(c)
|
||||
if err := cc.OptionsUC.ValidateOptions(cname, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), req.Options, true); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce the daily check quota on manual triggers when configured.
|
||||
// Interval=0 means "long-interval" in UserGater terms, so manual triggers
|
||||
// are only denied at the hard limit — never by interval-aware throttling.
|
||||
if cc.countManualTriggers && cc.budgetChecker != nil && !cc.budgetChecker.AllowWithInterval(target, 0) {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"errmsg": "daily check quota exhausted; try again after 00:00 UTC"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build a temporary plan from enabled rules if provided.
|
||||
var plan *happydns.CheckPlan
|
||||
if len(req.EnabledRules) > 0 {
|
||||
plan = &happydns.CheckPlan{
|
||||
CheckerID: cname,
|
||||
Target: target,
|
||||
Enabled: req.EnabledRules,
|
||||
}
|
||||
}
|
||||
|
||||
exec, err := cc.engine.CreateExecution(cname, target, plan)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
// Count the manual execution against the user's daily budget. Mirrors
|
||||
// the scheduler's onExecute semantics: increment after CreateExecution
|
||||
// succeeds, regardless of whether RunExecution later fails.
|
||||
if cc.countManualTriggers && cc.budgetChecker != nil {
|
||||
cc.budgetChecker.IncrementUsage(target)
|
||||
}
|
||||
|
||||
if c.Query("sync") == "true" {
|
||||
eval, err := cc.engine.RunExecution(c.Request.Context(), exec, plan, req.Options)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, eval)
|
||||
} else {
|
||||
go func() {
|
||||
if _, err := cc.engine.RunExecution(context.WithoutCancel(c.Request.Context()), exec, plan, req.Options); err != nil {
|
||||
log.Printf("async RunExecution error for checker %q execution %v: %v", cname, exec.Id, err)
|
||||
}
|
||||
}()
|
||||
c.JSON(http.StatusAccepted, exec)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExecutionStatus returns the status of an execution.
|
||||
//
|
||||
// @Summary Get execution status
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.Execution
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [get]
|
||||
func (cc *CheckerController) GetExecutionStatus(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
c.JSON(http.StatusOK, exec)
|
||||
}
|
||||
303
internal/api/controller/checker_metrics.go
Normal file
303
internal/api/controller/checker_metrics.go
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// respondWithMetrics writes metrics in the format requested by the Accept header.
|
||||
// JSON is the default (preserving the previous API contract for clients that
|
||||
// send Accept: */* or omit the header). Prometheus text exposition is only
|
||||
// returned when explicitly requested via Accept: text/plain.
|
||||
func respondWithMetrics(c *gin.Context, metrics []happydns.CheckMetric) {
|
||||
if metrics == nil {
|
||||
metrics = []happydns.CheckMetric{}
|
||||
}
|
||||
|
||||
if wantsPrometheusText(c.GetHeader("Accept")) {
|
||||
c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(renderPrometheus(metrics)))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
}
|
||||
|
||||
const maxLimit = 1000
|
||||
|
||||
// wantsPrometheusText returns true when the Accept header explicitly asks for
|
||||
// text/plain (or the Prometheus exposition media type) without also accepting
|
||||
// JSON. This keeps the JSON API the default for browsers and generic clients
|
||||
// while letting `curl -H 'Accept: text/plain'` opt into the Prometheus format.
|
||||
func wantsPrometheusText(accept string) bool {
|
||||
if accept == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(accept, "application/json") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(accept, "text/plain") ||
|
||||
strings.Contains(accept, "application/openmetrics-text")
|
||||
}
|
||||
|
||||
// escapePromLabelValue escapes a label value for the Prometheus text exposition
|
||||
// format. The spec only allows three escape sequences inside label values:
|
||||
// `\\`, `\"` and `\n`. Using fmt's %q is unsafe because it can emit \xNN or
|
||||
// \uNNNN sequences that Prometheus rejects.
|
||||
func escapePromLabelValue(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 2)
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch c := s[i]; c {
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderPrometheus formats metrics as Prometheus text exposition format
|
||||
// (version 0.0.4). It only emits constructs allowed by that format: HELP/TYPE
|
||||
// metadata and untyped samples — no OpenMetrics-only directives such as # UNIT.
|
||||
func renderPrometheus(metrics []happydns.CheckMetric) string {
|
||||
type metricMeta struct {
|
||||
unit string
|
||||
}
|
||||
seen := map[string]metricMeta{}
|
||||
var names []string
|
||||
|
||||
for _, m := range metrics {
|
||||
if _, ok := seen[m.Name]; !ok {
|
||||
seen[m.Name] = metricMeta{unit: m.Unit}
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
var b strings.Builder
|
||||
nameIdx := map[string]int{}
|
||||
for i, name := range names {
|
||||
nameIdx[name] = i
|
||||
}
|
||||
|
||||
// Sort metrics by name order, then by timestamp.
|
||||
sorted := make([]happydns.CheckMetric, len(metrics))
|
||||
copy(sorted, metrics)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
ni, nj := nameIdx[sorted[i].Name], nameIdx[sorted[j].Name]
|
||||
if ni != nj {
|
||||
return ni < nj
|
||||
}
|
||||
return sorted[i].Timestamp.Before(sorted[j].Timestamp)
|
||||
})
|
||||
|
||||
currentName := ""
|
||||
for _, m := range sorted {
|
||||
if m.Name != currentName {
|
||||
currentName = m.Name
|
||||
meta := seen[m.Name]
|
||||
if meta.unit != "" {
|
||||
// Surface the unit as a HELP comment so it stays parseable
|
||||
// under Prometheus text 0.0.4 (which has no # UNIT directive).
|
||||
fmt.Fprintf(&b, "# HELP %s unit: %s\n", m.Name, meta.unit)
|
||||
}
|
||||
fmt.Fprintf(&b, "# TYPE %s untyped\n", m.Name)
|
||||
}
|
||||
|
||||
b.WriteString(m.Name)
|
||||
if len(m.Labels) > 0 {
|
||||
b.WriteByte('{')
|
||||
first := true
|
||||
labelKeys := make([]string, 0, len(m.Labels))
|
||||
for k := range m.Labels {
|
||||
labelKeys = append(labelKeys, k)
|
||||
}
|
||||
sort.Strings(labelKeys)
|
||||
for _, k := range labelKeys {
|
||||
if !first {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
fmt.Fprintf(&b, "%s=\"%s\"", k, escapePromLabelValue(m.Labels[k]))
|
||||
first = false
|
||||
}
|
||||
b.WriteByte('}')
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, " %g", m.Value)
|
||||
if !m.Timestamp.IsZero() {
|
||||
fmt.Fprintf(&b, " %d", m.Timestamp.UnixMilli())
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func getLimitParam(c *gin.Context, defaultLimit int) int {
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
if parsed > maxLimit {
|
||||
return maxLimit
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultLimit
|
||||
}
|
||||
|
||||
// GetUserMetrics returns metrics across all checkers for the authenticated user.
|
||||
//
|
||||
// @Summary Get all user metrics
|
||||
// @Description Returns metrics from all recent executions for the authenticated user. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
|
||||
// @Tags checkers
|
||||
// @Produce json,plain
|
||||
// @Param limit query int false "Maximum number of executions to extract metrics from (default: 100)"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /checkers/metrics [get]
|
||||
func (cc *CheckerController) GetUserMetrics(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
userID := happydns.TargetIdentifier(target.UserId)
|
||||
if userID == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := getLimitParam(c, 100)
|
||||
metrics, err := cc.statusUC.GetMetricsByUser(*userID, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
|
||||
// GetDomainMetrics returns metrics for a domain and its service children.
|
||||
//
|
||||
// @Summary Get domain metrics
|
||||
// @Description Returns metrics from recent executions for a domain and all its services. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
|
||||
// @Tags checkers
|
||||
// @Produce json,plain
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param limit query int false "Maximum number of executions (default: 100)"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /domains/{domain}/checkers/metrics [get]
|
||||
func (cc *CheckerController) GetDomainMetrics(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
domainID := happydns.TargetIdentifier(target.DomainId)
|
||||
if domainID == nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Domain context required"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := getLimitParam(c, 100)
|
||||
metrics, err := cc.statusUC.GetMetricsByDomain(*domainID, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
|
||||
// GetCheckerMetrics returns metrics for a specific checker on a target.
|
||||
//
|
||||
// @Summary Get checker metrics
|
||||
// @Description Returns metrics from recent executions of a specific checker on a target. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
|
||||
// @Tags checkers
|
||||
// @Produce json,plain
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string false "Zone identifier"
|
||||
// @Param subdomain path string false "Subdomain"
|
||||
// @Param serviceid path string false "Service identifier"
|
||||
// @Param limit query int false "Maximum number of executions (default: 100)"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/metrics [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/metrics [get]
|
||||
func (cc *CheckerController) GetCheckerMetrics(c *gin.Context) {
|
||||
checkerID := c.Param("checkerId")
|
||||
target := targetFromContext(c)
|
||||
|
||||
limit := getLimitParam(c, 100)
|
||||
metrics, err := cc.statusUC.GetMetricsByChecker(checkerID, target, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
|
||||
// GetExecutionMetrics returns metrics for a single execution.
|
||||
//
|
||||
// @Summary Get execution metrics
|
||||
// @Description Returns metrics extracted from a single execution's observation snapshot. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
|
||||
// @Tags checkers
|
||||
// @Produce json,plain
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string false "Zone identifier"
|
||||
// @Param subdomain path string false "Subdomain"
|
||||
// @Param serviceid path string false "Service identifier"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/metrics [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/metrics [get]
|
||||
func (cc *CheckerController) GetExecutionMetrics(c *gin.Context) {
|
||||
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
||||
return
|
||||
}
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
exec, err := cc.statusUC.GetExecution(target, execID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
||||
return
|
||||
}
|
||||
|
||||
metrics, err := cc.statusUC.GetMetricsByExecution(target, exec.Id)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
117
internal/api/controller/checker_metrics_test.go
Normal file
117
internal/api/controller/checker_metrics_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/common/expfmt"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestRenderPrometheus_ParsesAsValidExposition(t *testing.T) {
|
||||
// Include a label value with characters that fmt's %q would have escaped
|
||||
// as \xNN / \uNNNN — sequences which are NOT valid in Prometheus text
|
||||
// format. The output must still parse cleanly via the upstream parser.
|
||||
out := renderPrometheus([]happydns.CheckMetric{
|
||||
{
|
||||
Name: "happydomain_check_latency_seconds",
|
||||
Unit: "seconds",
|
||||
Value: 0.123,
|
||||
Timestamp: time.Unix(1700000000, 0),
|
||||
Labels: map[string]string{
|
||||
"target": "exämple.com", // non-ASCII
|
||||
"note": "line1\nline2", // newline (must become \n)
|
||||
"quoted": `he said "hi"`, // quotes
|
||||
"slash": `a\b`, // backslash
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "happydomain_check_latency_seconds",
|
||||
Value: 0.456,
|
||||
Labels: map[string]string{
|
||||
"target": "second.example",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p := expfmt.NewTextParser(model.LegacyValidation)
|
||||
if _, err := p.TextToMetricFamilies(strings.NewReader(out)); err != nil {
|
||||
t.Fatalf("renderPrometheus output is not valid Prometheus text format: %v\noutput:\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPrometheus_EscapesLabelValues(t *testing.T) {
|
||||
out := renderPrometheus([]happydns.CheckMetric{{
|
||||
Name: "x",
|
||||
Value: 1,
|
||||
Labels: map[string]string{
|
||||
"a": `\`,
|
||||
"b": `"`,
|
||||
"c": "\n",
|
||||
},
|
||||
}})
|
||||
if !strings.Contains(out, `a="\\"`) {
|
||||
t.Errorf("backslash not escaped: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `b="\""`) {
|
||||
t.Errorf("quote not escaped: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `c="\n"`) {
|
||||
t.Errorf("newline not escaped: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPrometheus_NoOpenMetricsDirectives(t *testing.T) {
|
||||
out := renderPrometheus([]happydns.CheckMetric{{
|
||||
Name: "x",
|
||||
Unit: "seconds",
|
||||
Value: 1,
|
||||
}})
|
||||
if strings.Contains(out, "# UNIT") {
|
||||
t.Errorf("output contains OpenMetrics-only # UNIT directive incompatible with text/plain;version=0.0.4: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWantsPrometheusText(t *testing.T) {
|
||||
cases := []struct {
|
||||
accept string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"*/*", false},
|
||||
{"application/json", false},
|
||||
{"application/json, text/plain", false}, // explicit JSON wins
|
||||
{"text/plain", true},
|
||||
{"text/plain; version=0.0.4", true},
|
||||
{"application/openmetrics-text; version=1.0.0", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := wantsPrometheusText(tc.accept); got != tc.want {
|
||||
t.Errorf("wantsPrometheusText(%q) = %v, want %v", tc.accept, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
223
internal/api/controller/checker_options.go
Normal file
223
internal/api/controller/checker_options.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// GetCheckerOptions returns layered options for a checker, from least to most specific scope.
|
||||
// The scope is determined by the route context (user-only at /api/checkers, domain/service at scoped routes).
|
||||
//
|
||||
// @Summary Get checker options by scope
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {array} happydns.CheckerOptionsPositional
|
||||
// @Router /checkers/{checkerId}/options [get]
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/options [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [get]
|
||||
func (cc *CheckerController) GetCheckerOptions(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
positionals, err := cc.OptionsUC.GetCheckerOptionsPositional(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId))
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if positionals == nil {
|
||||
positionals = []*happydns.CheckerOptionsPositional{}
|
||||
}
|
||||
|
||||
// Append auto-fill resolved values so the frontend can display them.
|
||||
autoFillOpts, err := cc.OptionsUC.GetAutoFillOptions(checkerID, target)
|
||||
if err == nil && autoFillOpts != nil {
|
||||
positionals = append(positionals, &happydns.CheckerOptionsPositional{
|
||||
CheckName: checkerID,
|
||||
UserId: happydns.TargetIdentifier(target.UserId),
|
||||
DomainId: happydns.TargetIdentifier(target.DomainId),
|
||||
ServiceId: happydns.TargetIdentifier(target.ServiceId),
|
||||
Options: autoFillOpts,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, positionals)
|
||||
}
|
||||
|
||||
// AddCheckerOptions partially merges options at the current scope.
|
||||
//
|
||||
// @Summary Merge checker options
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param options body checker.CheckerOptions true "Options to merge"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} checker.CheckerOptions
|
||||
// @Router /checkers/{checkerId}/options [post]
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/options [post]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [post]
|
||||
func (cc *CheckerController) AddCheckerOptions(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
var opts happydns.CheckerOptions
|
||||
if err := c.ShouldBindJSON(&opts); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
merged, err := cc.OptionsUC.MergeCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), merged, false); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := cc.OptionsUC.AddCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, merged)
|
||||
}
|
||||
|
||||
// ChangeCheckerOptions fully replaces options at the current scope.
|
||||
//
|
||||
// @Summary Replace checker options
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param options body checker.CheckerOptions true "Options to set"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} checker.CheckerOptions
|
||||
// @Router /checkers/{checkerId}/options [put]
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/options [put]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [put]
|
||||
func (cc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
var opts happydns.CheckerOptions
|
||||
if err := c.ShouldBindJSON(&opts); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts, false); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := cc.OptionsUC.SetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, opts)
|
||||
}
|
||||
|
||||
// GetCheckerOption returns a single option value at the current scope.
|
||||
//
|
||||
// @Summary Get a single checker option
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param optname path string true "Option name"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} any
|
||||
// @Router /checkers/{checkerId}/options/{optname} [get]
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [get]
|
||||
func (cc *CheckerController) GetCheckerOption(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
optname := c.Param("optname")
|
||||
val, err := cc.OptionsUC.GetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if val == nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Option not set"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, val)
|
||||
}
|
||||
|
||||
// SetCheckerOption sets a single option value at the current scope.
|
||||
//
|
||||
// @Summary Set a single checker option
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param optname path string true "Option name"
|
||||
// @Param value body any true "Option value"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} any
|
||||
// @Router /checkers/{checkerId}/options/{optname} [put]
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [put]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [put]
|
||||
func (cc *CheckerController) SetCheckerOption(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
optname := c.Param("optname")
|
||||
var value any
|
||||
if err := c.ShouldBindJSON(&value); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
// Validate the full merged options after inserting the key.
|
||||
existing, err := cc.OptionsUC.GetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId))
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
existing[optname] = value
|
||||
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), existing, false); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := cc.OptionsUC.SetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname, value); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, value)
|
||||
}
|
||||
196
internal/api/controller/checker_plans.go
Normal file
196
internal/api/controller/checker_plans.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// PlanHandler is a middleware that validates the planId path parameter,
|
||||
// checks target scope, and sets "plan" in context.
|
||||
func (cc *CheckerController) PlanHandler(c *gin.Context) {
|
||||
planID, err := happydns.NewIdentifierFromString(c.Param("planId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid plan ID"})
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := cc.planUC.GetCheckPlan(targetFromContext(c), planID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("plan", plan)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// ListCheckPlans returns all check plans for a domain.
|
||||
//
|
||||
// @Summary List check plans for a domain
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {array} happydns.CheckPlan
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/plans [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [get]
|
||||
func (cc *CheckerController) ListCheckPlans(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
|
||||
plans, err := cc.planUC.ListCheckPlansByTargetAndChecker(target, checkerID)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, plans)
|
||||
}
|
||||
|
||||
// CreateCheckPlan creates a new check plan.
|
||||
//
|
||||
// @Summary Create a check plan
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param plan body happydns.CheckPlan true "Check plan to create"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 201 {object} happydns.CheckPlan
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/plans [post]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [post]
|
||||
func (cc *CheckerController) CreateCheckPlan(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
|
||||
var plan happydns.CheckPlan
|
||||
if err := c.ShouldBindJSON(&plan); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
plan.Target = target
|
||||
plan.CheckerID = c.Param("checkerId")
|
||||
|
||||
if err := cc.planUC.CreateCheckPlan(&plan); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("cannot create check plan: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, plan)
|
||||
}
|
||||
|
||||
// GetCheckPlan returns a specific check plan.
|
||||
//
|
||||
// @Summary Get a check plan
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param planId path string true "Plan ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.CheckPlan
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [get]
|
||||
func (cc *CheckerController) GetCheckPlan(c *gin.Context) {
|
||||
plan := c.MustGet("plan").(*happydns.CheckPlan)
|
||||
c.JSON(http.StatusOK, plan)
|
||||
}
|
||||
|
||||
// UpdateCheckPlan updates an existing check plan.
|
||||
//
|
||||
// @Summary Update a check plan
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param planId path string true "Plan ID"
|
||||
// @Param plan body happydns.CheckPlan true "Updated check plan"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.CheckPlan
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [put]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [put]
|
||||
func (cc *CheckerController) UpdateCheckPlan(c *gin.Context) {
|
||||
existing := c.MustGet("plan").(*happydns.CheckPlan)
|
||||
|
||||
var plan happydns.CheckPlan
|
||||
if err := c.ShouldBindJSON(&plan); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
plan.Target = targetFromContext(c)
|
||||
plan.CheckerID = c.Param("checkerId")
|
||||
|
||||
updated, err := cc.planUC.UpdateCheckPlan(plan.Target, existing.Id, &plan)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("cannot update check plan: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// DeleteCheckPlan deletes a check plan.
|
||||
//
|
||||
// @Summary Delete a check plan
|
||||
// @Tags checkers
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param planId path string true "Plan ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 204
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [delete]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [delete]
|
||||
func (cc *CheckerController) DeleteCheckPlan(c *gin.Context) {
|
||||
plan := c.MustGet("plan").(*happydns.CheckPlan)
|
||||
|
||||
if err := cc.planUC.DeleteCheckPlan(targetFromContext(c), plan.Id); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
307
internal/api/controller/checker_results.go
Normal file
307
internal/api/controller/checker_results.go
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// ExecutionHandler is a middleware that validates the executionId path parameter,
|
||||
// checks target scope, and sets "execution" in context.
|
||||
func (cc *CheckerController) ExecutionHandler(c *gin.Context) {
|
||||
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
||||
return
|
||||
}
|
||||
|
||||
exec, err := cc.statusUC.GetExecution(targetFromContext(c), execID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("execution", exec)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// ListExecutions returns executions for a checker on a target.
|
||||
//
|
||||
// @Summary List executions for a checker
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param limit query int false "Maximum number of results"
|
||||
// @Param include_planned query bool false "Include upcoming planned executions from the scheduler"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {array} happydns.Execution
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [get]
|
||||
func (cc *CheckerController) ListExecutions(c *gin.Context) {
|
||||
cname := c.Param("checkerId")
|
||||
target := targetFromContext(c)
|
||||
|
||||
limit := getLimitParam(c, 0)
|
||||
|
||||
execs, err := cc.statusUC.ListExecutionsByChecker(cname, target, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if execs == nil {
|
||||
execs = []*happydns.Execution{}
|
||||
}
|
||||
|
||||
if c.Query("include_planned") == "true" || c.Query("include_planned") == "1" {
|
||||
planned := checkerUC.ListPlannedExecutions(cc.plannedProvider, cc.budgetChecker, cname, target)
|
||||
execs = append(planned, execs...)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, execs)
|
||||
}
|
||||
|
||||
// DeleteExecution deletes an execution record.
|
||||
//
|
||||
// @Summary Delete an execution
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [delete]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [delete]
|
||||
func (cc *CheckerController) DeleteExecution(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
|
||||
if err := cc.statusUC.DeleteExecution(targetFromContext(c), exec.Id); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteCheckerExecutions deletes all executions for a checker on a target.
|
||||
//
|
||||
// @Summary Delete all executions for a checker
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions [delete]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [delete]
|
||||
func (cc *CheckerController) DeleteCheckerExecutions(c *gin.Context) {
|
||||
cname := c.Param("checkerId")
|
||||
target := targetFromContext(c)
|
||||
|
||||
if err := cc.statusUC.DeleteExecutionsByChecker(cname, target); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetExecutionObservations returns the observation snapshot for an execution.
|
||||
//
|
||||
// @Summary Get observations for an execution
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.ObservationSnapshot
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations [get]
|
||||
func (cc *CheckerController) GetExecutionObservations(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
|
||||
snap, err := cc.statusUC.GetObservationsByExecution(targetFromContext(c), exec.Id)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, snap)
|
||||
}
|
||||
|
||||
// GetExecutionObservation returns a specific observation key from an execution's snapshot.
|
||||
//
|
||||
// @Summary Get a specific observation for an execution
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param obsKey path string true "Observation key"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} any
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get]
|
||||
func (cc *CheckerController) GetExecutionObservation(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
obsKey := c.Param("obsKey")
|
||||
|
||||
val, err := cc.statusUC.GetSnapshotByExecution(targetFromContext(c), exec.Id, obsKey)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation not available"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", val)
|
||||
}
|
||||
|
||||
// GetExecutionResults returns the evaluation (per-rule states) for an execution.
|
||||
//
|
||||
// @Summary Get results for an execution
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.CheckEvaluation
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results [get]
|
||||
func (cc *CheckerController) GetExecutionResults(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
|
||||
eval, err := cc.statusUC.GetResultsByExecution(targetFromContext(c), exec.Id)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, eval)
|
||||
}
|
||||
|
||||
// GetExecutionResult returns a specific rule's result from an execution.
|
||||
//
|
||||
// @Summary Get a specific rule result for an execution
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param ruleName path string true "Rule name"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} checker.CheckState
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get]
|
||||
func (cc *CheckerController) GetExecutionResult(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
|
||||
eval, err := cc.statusUC.GetResultsByExecution(targetFromContext(c), exec.Id)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"})
|
||||
return
|
||||
}
|
||||
|
||||
ruleName := c.Param("ruleName")
|
||||
for _, state := range eval.States {
|
||||
if state.Code == ruleName {
|
||||
c.JSON(http.StatusOK, state)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Rule result not found"})
|
||||
}
|
||||
|
||||
// GetExecutionHTMLReport returns the HTML report for a specific observation of an execution.
|
||||
//
|
||||
// @Summary Get execution observation HTML report
|
||||
// @Description Returns the full HTML document generated from an observation's data. Only available for observation providers that implement HTML reporting.
|
||||
// @Tags checkers
|
||||
// @Produce html
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param obsKey path string true "Observation key"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {string} string "HTML document"
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
|
||||
func (cc *CheckerController) GetExecutionHTMLReport(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
obsKey := c.Param("obsKey")
|
||||
|
||||
val, err := cc.statusUC.GetSnapshotByExecution(targetFromContext(c), exec.Id, obsKey)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation not available"})
|
||||
return
|
||||
}
|
||||
|
||||
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, val)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if !supported {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("observation %q does not support HTML reports", obsKey))
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
|
||||
}
|
||||
904
internal/api/controller/checker_test.go
Normal file
904
internal/api/controller/checker_test.go
Normal file
|
|
@ -0,0 +1,904 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// --- Stub types ---
|
||||
|
||||
// stubCheckerEngine implements happydns.CheckerEngine for testing.
|
||||
type stubCheckerEngine struct {
|
||||
exec *happydns.Execution
|
||||
eval *happydns.CheckEvaluation
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubCheckerEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.exec != nil {
|
||||
return s.exec, nil
|
||||
}
|
||||
id, _ := happydns.NewRandomIdentifier()
|
||||
return &happydns.Execution{
|
||||
Id: id,
|
||||
CheckerID: checkerID,
|
||||
Target: target,
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionPending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *stubCheckerEngine) RunExecution(ctx context.Context, exec *happydns.Execution, plan *happydns.CheckPlan, runOpts happydns.CheckerOptions) (*happydns.CheckEvaluation, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.eval != nil {
|
||||
return s.eval, nil
|
||||
}
|
||||
return &happydns.CheckEvaluation{
|
||||
CheckerID: exec.CheckerID,
|
||||
Target: exec.Target,
|
||||
States: []happydns.CheckState{{Status: happydns.StatusOK, Code: "ok"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// testObservationProvider is a no-op provider for tests.
|
||||
type testObservationProvider struct{}
|
||||
|
||||
func (p *testObservationProvider) Key() happydns.ObservationKey { return "test_ctrl_obs" }
|
||||
func (p *testObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
return map[string]any{"v": 1}, nil
|
||||
}
|
||||
|
||||
// testHTMLObservationProvider implements CheckerHTMLReporter for HTML report tests.
|
||||
type testHTMLObservationProvider struct{}
|
||||
|
||||
func (p *testHTMLObservationProvider) Key() happydns.ObservationKey { return "test_html_obs" }
|
||||
func (p *testHTMLObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
return map[string]any{"html": true}, nil
|
||||
}
|
||||
func (p *testHTMLObservationProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
|
||||
return "<html><body>test report</body></html>", nil
|
||||
}
|
||||
|
||||
// testCheckRule produces a fixed status.
|
||||
type testCheckRule struct {
|
||||
name string
|
||||
status happydns.Status
|
||||
}
|
||||
|
||||
func (r *testCheckRule) Name() string { return r.name }
|
||||
func (r *testCheckRule) Description() string { return "test rule: " + r.name }
|
||||
func (r *testCheckRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
|
||||
return happydns.CheckState{Status: r.status, Code: r.name}
|
||||
}
|
||||
|
||||
// registerTestChecker registers a checker for controller tests and returns its ID.
|
||||
// Uses a unique name to avoid collisions with other tests.
|
||||
var testCheckerSeq int
|
||||
|
||||
func registerTestChecker() string {
|
||||
testCheckerSeq++
|
||||
id := fmt.Sprintf("ctrl_test_checker_%d", testCheckerSeq)
|
||||
checkerPkg.RegisterObservationProvider(&testObservationProvider{})
|
||||
checkerPkg.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: id,
|
||||
Name: "Controller Test Checker",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&testCheckRule{name: "rule_a", status: happydns.StatusOK},
|
||||
},
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// newTestController creates a CheckerController with in-memory storage.
|
||||
func newTestController(engine happydns.CheckerEngine) *CheckerController {
|
||||
cc, _ := newTestControllerWithStorage(engine)
|
||||
return cc
|
||||
}
|
||||
|
||||
// newTestControllerWithStorage creates a CheckerController and returns the underlying storage.
|
||||
func newTestControllerWithStorage(engine happydns.CheckerEngine) (*CheckerController, storage.Storage) {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
planUC := checkerUC.NewCheckPlanUsecase(store)
|
||||
statusUC := checkerUC.NewCheckStatusUsecase(store, store, store, store)
|
||||
return NewCheckerController(engine, optionsUC, planUC, statusUC, nil, nil, false), store
|
||||
}
|
||||
|
||||
// newTestControllerWithBudget creates a CheckerController with a custom
|
||||
// BudgetChecker and an explicit countManualTriggers flag, for the
|
||||
// manual-trigger quota tests.
|
||||
func newTestControllerWithBudget(engine happydns.CheckerEngine, budget checkerUC.BudgetChecker, countManualTriggers bool) *CheckerController {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
planUC := checkerUC.NewCheckPlanUsecase(store)
|
||||
statusUC := checkerUC.NewCheckStatusUsecase(store, store, store, store)
|
||||
return NewCheckerController(engine, optionsUC, planUC, statusUC, nil, budget, countManualTriggers)
|
||||
}
|
||||
|
||||
// stubBudgetChecker is a minimal BudgetChecker for controller tests.
|
||||
// allow controls AllowWithInterval; increments counts IncrementUsage calls.
|
||||
type stubBudgetChecker struct {
|
||||
allow bool
|
||||
increments int
|
||||
}
|
||||
|
||||
func (s *stubBudgetChecker) RateLimiterFor(_ string) func(time.Duration) bool {
|
||||
return func(time.Duration) bool { return !s.allow }
|
||||
}
|
||||
func (s *stubBudgetChecker) AllowWithInterval(_ happydns.CheckTarget, _ time.Duration) bool {
|
||||
return s.allow
|
||||
}
|
||||
func (s *stubBudgetChecker) IncrementUsage(_ happydns.CheckTarget) { s.increments++ }
|
||||
|
||||
// countingCheckerEngine wraps stubCheckerEngine and counts CreateExecution calls.
|
||||
type countingCheckerEngine struct {
|
||||
stubCheckerEngine
|
||||
created int
|
||||
}
|
||||
|
||||
func (c *countingCheckerEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) {
|
||||
c.created++
|
||||
return c.stubCheckerEngine.CreateExecution(checkerID, target, plan)
|
||||
}
|
||||
|
||||
// --- targetFromContext tests ---
|
||||
|
||||
func TestTargetFromContext_Empty(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
if target.UserId != "" {
|
||||
t.Errorf("expected empty UserId, got %q", target.UserId)
|
||||
}
|
||||
if target.DomainId != "" {
|
||||
t.Errorf("expected empty DomainId, got %q", target.DomainId)
|
||||
}
|
||||
if target.ServiceId != "" {
|
||||
t.Errorf("expected empty ServiceId, got %q", target.ServiceId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetFromContext_WithUser(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
c.Set("LoggedUser", user)
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
if target.UserId != uid.String() {
|
||||
t.Errorf("expected UserId %q, got %q", uid.String(), target.UserId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetFromContext_WithDomain(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
domain := &happydns.Domain{Id: did}
|
||||
c.Set("domain", domain)
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
if target.DomainId != did.String() {
|
||||
t.Errorf("expected DomainId %q, got %q", did.String(), target.DomainId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetFromContext_WithService(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
sid, _ := happydns.NewRandomIdentifier()
|
||||
c.Set("serviceid", happydns.Identifier(sid))
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
if target.ServiceId != sid.String() {
|
||||
t.Errorf("expected ServiceId %q, got %q", sid.String(), target.ServiceId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetFromContext_WithServiceAndZone(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
sid, _ := happydns.NewRandomIdentifier()
|
||||
svc := &happydns.Service{
|
||||
ServiceMeta: happydns.ServiceMeta{
|
||||
Id: sid,
|
||||
Type: "svcs.TestType",
|
||||
},
|
||||
}
|
||||
zone := &happydns.Zone{
|
||||
Services: map[happydns.Subdomain][]*happydns.Service{
|
||||
"": {svc},
|
||||
},
|
||||
}
|
||||
|
||||
c.Set("serviceid", happydns.Identifier(sid))
|
||||
c.Set("zone", zone)
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
if target.ServiceType != "svcs.TestType" {
|
||||
t.Errorf("expected ServiceType %q, got %q", "svcs.TestType", target.ServiceType)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ListCheckers tests ---
|
||||
|
||||
func TestListCheckers_ReturnsRegistered(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/checkers", nil)
|
||||
|
||||
cc.ListCheckers(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := result[checkerID]; !ok {
|
||||
t.Errorf("expected checker %q in response, got keys: %v", checkerID, keysOf(result))
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// --- CheckerHandler tests ---
|
||||
|
||||
func TestCheckerHandler_NotFound(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/checkers/nonexistent", nil)
|
||||
c.Params = gin.Params{{Key: "checkerId", Value: "nonexistent_checker_xyz"}}
|
||||
|
||||
cc.CheckerHandler(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if _, ok := resp["errmsg"]; !ok {
|
||||
t.Error("expected errmsg in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerHandler_Found(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/checkers/"+checkerID, nil)
|
||||
c.Params = gin.Params{{Key: "checkerId", Value: checkerID}}
|
||||
|
||||
// CheckerHandler calls c.Next(), so we need to verify context is set.
|
||||
// Use a gin engine to test the middleware chain.
|
||||
router := gin.New()
|
||||
router.GET("/checkers/:checkerId", cc.CheckerHandler, cc.GetChecker)
|
||||
|
||||
req := httptest.NewRequest("GET", "/checkers/"+checkerID, nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var def map[string]any
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &def); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if def["id"] != checkerID {
|
||||
t.Errorf("expected checker id %q, got %v", checkerID, def["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- TriggerCheck tests ---
|
||||
|
||||
func TestTriggerCheck_Sync_Returns200(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
eval := &happydns.CheckEvaluation{
|
||||
CheckerID: checkerID,
|
||||
States: []happydns.CheckState{{Status: happydns.StatusOK, Code: "ok"}},
|
||||
}
|
||||
engine := &stubCheckerEngine{eval: eval}
|
||||
cc := newTestController(engine)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
|
||||
body, _ := json.Marshal(happydns.CheckerRunRequest{})
|
||||
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions?sync=true", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
|
||||
c.Set("LoggedUser", user)
|
||||
c.Next()
|
||||
}, cc.TriggerCheck)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if result["checkerId"] != checkerID {
|
||||
t.Errorf("expected checkerId %q, got %v", checkerID, result["checkerId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerCheck_Async_Returns202(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
engine := &stubCheckerEngine{}
|
||||
cc := newTestController(engine)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
|
||||
body, _ := json.Marshal(happydns.CheckerRunRequest{})
|
||||
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
|
||||
c.Set("LoggedUser", user)
|
||||
c.Next()
|
||||
}, cc.TriggerCheck)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerCheck_EngineError_Returns500(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
engine := &stubCheckerEngine{err: fmt.Errorf("engine failure")}
|
||||
cc := newTestController(engine)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
|
||||
body, _ := json.Marshal(happydns.CheckerRunRequest{})
|
||||
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
|
||||
c.Set("LoggedUser", user)
|
||||
c.Next()
|
||||
}, cc.TriggerCheck)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// postTrigger is a helper that wires up LoggedUser via middleware and fires
|
||||
// an async POST to TriggerCheck. Used by the budget-enforcement tests below.
|
||||
func postTrigger(cc *CheckerController, user *happydns.User, checkerID string) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(happydns.CheckerRunRequest{})
|
||||
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
|
||||
c.Set("LoggedUser", user)
|
||||
c.Next()
|
||||
}, cc.TriggerCheck)
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// When countManualTriggers=false, the quota is never consulted and the
|
||||
// counter is never incremented — legacy behavior.
|
||||
func TestTriggerCheck_BudgetBypassWhenDisabled(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
engine := &countingCheckerEngine{}
|
||||
// allow=false would normally refuse, but countManualTriggers=false bypasses.
|
||||
budget := &stubBudgetChecker{allow: false}
|
||||
cc := newTestControllerWithBudget(engine, budget, false)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
w := postTrigger(cc, &happydns.User{Id: uid}, checkerID)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202 (bypass), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if budget.increments != 0 {
|
||||
t.Errorf("expected no IncrementUsage calls when flag off, got %d", budget.increments)
|
||||
}
|
||||
if engine.created != 1 {
|
||||
t.Errorf("expected 1 CreateExecution call, got %d", engine.created)
|
||||
}
|
||||
}
|
||||
|
||||
// When countManualTriggers=true but budgetChecker is nil (e.g. checker
|
||||
// subsystem initialised without a gater), the code falls through without
|
||||
// panicking and behaves like the bypass mode.
|
||||
func TestTriggerCheck_NilBudgetCheckerIgnored(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
engine := &countingCheckerEngine{}
|
||||
cc := newTestControllerWithBudget(engine, nil, true)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
w := postTrigger(cc, &happydns.User{Id: uid}, checkerID)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202 (nil-budget fallback), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if engine.created != 1 {
|
||||
t.Errorf("expected 1 CreateExecution call, got %d", engine.created)
|
||||
}
|
||||
}
|
||||
|
||||
// Happy path with flag on and budget allowing: 202 and the usage counter is
|
||||
// bumped exactly once.
|
||||
func TestTriggerCheck_CountsOnSuccess(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
engine := &countingCheckerEngine{}
|
||||
budget := &stubBudgetChecker{allow: true}
|
||||
cc := newTestControllerWithBudget(engine, budget, true)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
w := postTrigger(cc, &happydns.User{Id: uid}, checkerID)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if budget.increments != 1 {
|
||||
t.Errorf("expected IncrementUsage called once, got %d", budget.increments)
|
||||
}
|
||||
if engine.created != 1 {
|
||||
t.Errorf("expected 1 CreateExecution call, got %d", engine.created)
|
||||
}
|
||||
}
|
||||
|
||||
// Over-budget: the request must be refused with 429 and CreateExecution
|
||||
// must NOT be called (no side-effects on a rejected request).
|
||||
func TestTriggerCheck_RefusedWhenOverBudget(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
engine := &countingCheckerEngine{}
|
||||
budget := &stubBudgetChecker{allow: false}
|
||||
cc := newTestControllerWithBudget(engine, budget, true)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
w := postTrigger(cc, &happydns.User{Id: uid}, checkerID)
|
||||
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if engine.created != 0 {
|
||||
t.Errorf("expected no CreateExecution call on 429, got %d", engine.created)
|
||||
}
|
||||
if budget.increments != 0 {
|
||||
t.Errorf("expected no IncrementUsage call on 429, got %d", budget.increments)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetExecutionStatus tests ---
|
||||
|
||||
func TestGetExecutionStatus_ReturnsExecution(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
execID, _ := happydns.NewRandomIdentifier()
|
||||
exec := &happydns.Execution{
|
||||
Id: execID,
|
||||
CheckerID: "test",
|
||||
Status: happydns.ExecutionDone,
|
||||
Result: happydns.CheckState{Status: happydns.StatusOK, Message: "done"},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/executions/"+execID.String(), nil)
|
||||
c.Set("execution", exec)
|
||||
|
||||
cc.GetExecutionStatus(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if result["checkerId"] != "test" {
|
||||
t.Errorf("expected checkerId %q, got %v", "test", result["checkerId"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetChecker tests ---
|
||||
|
||||
func TestGetChecker_ReturnsDefinition(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
def := checkerPkg.FindChecker(checkerID)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/checkers/"+checkerID, nil)
|
||||
c.Set("checker", def)
|
||||
|
||||
cc.GetChecker(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if result["id"] != checkerID {
|
||||
t.Errorf("expected id %q, got %v", checkerID, result["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- ExecutionHandler tests ---
|
||||
|
||||
func TestExecutionHandler_InvalidID(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/executions/not-valid", nil)
|
||||
c.Params = gin.Params{{Key: "executionId", Value: "not-valid"}}
|
||||
|
||||
cc.ExecutionHandler(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionHandler_NotFound(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
fakeID, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/executions/"+fakeID.String(), nil)
|
||||
c.Params = gin.Params{{Key: "executionId", Value: fakeID.String()}}
|
||||
|
||||
cc.ExecutionHandler(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- PlanHandler tests ---
|
||||
|
||||
func TestPlanHandler_InvalidID(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plans/not-valid", nil)
|
||||
c.Params = gin.Params{{Key: "planId", Value: "not-valid"}}
|
||||
|
||||
cc.PlanHandler(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanHandler_NotFound(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
fakeID, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plans/"+fakeID.String(), nil)
|
||||
c.Params = gin.Params{{Key: "planId", Value: fakeID.String()}}
|
||||
|
||||
cc.PlanHandler(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetExecutionHTMLReport tests ---
|
||||
|
||||
// seedExecutionWithObservations creates an execution backed by a snapshot containing the given
|
||||
// observation data. It returns the execution (with ID assigned by the store).
|
||||
func seedExecutionWithObservations(t *testing.T, store storage.Storage, target happydns.CheckTarget, data map[happydns.ObservationKey]json.RawMessage) *happydns.Execution {
|
||||
t.Helper()
|
||||
|
||||
snap := &happydns.ObservationSnapshot{
|
||||
Target: target,
|
||||
CollectedAt: time.Now(),
|
||||
Data: data,
|
||||
}
|
||||
if err := store.CreateSnapshot(snap); err != nil {
|
||||
t.Fatalf("CreateSnapshot: %v", err)
|
||||
}
|
||||
|
||||
eval := &happydns.CheckEvaluation{
|
||||
CheckerID: "html_test_checker",
|
||||
Target: target,
|
||||
SnapshotID: snap.Id,
|
||||
}
|
||||
if err := store.CreateEvaluation(eval); err != nil {
|
||||
t.Fatalf("CreateEvaluation: %v", err)
|
||||
}
|
||||
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "html_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
EvaluationID: &eval.Id,
|
||||
}
|
||||
if err := store.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution: %v", err)
|
||||
}
|
||||
return exec
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register the HTML observation provider once for tests.
|
||||
checkerPkg.RegisterObservationProvider(&testHTMLObservationProvider{})
|
||||
}
|
||||
|
||||
func TestGetExecutionHTMLReport_ObservationsNotAvailable(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
// Create an execution with no evaluation/snapshot backing.
|
||||
fakeExecID, _ := happydns.NewRandomIdentifier()
|
||||
exec := &happydns.Execution{
|
||||
Id: fakeExecID,
|
||||
CheckerID: "html_test_checker",
|
||||
Status: happydns.ExecutionDone,
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/report", nil)
|
||||
c.Set("execution", exec)
|
||||
c.Params = gin.Params{{Key: "obsKey", Value: "test_html_obs"}}
|
||||
|
||||
cc.GetExecutionHTMLReport(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExecutionHTMLReport_ObservationKeyNotFound(t *testing.T) {
|
||||
cc, store := newTestControllerWithStorage(&stubCheckerEngine{})
|
||||
|
||||
target := happydns.CheckTarget{DomainId: "d1"}
|
||||
exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{
|
||||
"test_html_obs": json.RawMessage(`{"v":1}`),
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/report", nil)
|
||||
c.Set("execution", exec)
|
||||
c.Params = gin.Params{{Key: "obsKey", Value: "nonexistent_key"}}
|
||||
|
||||
cc.GetExecutionHTMLReport(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// testNoHTMLObservationProvider is a provider that does NOT implement CheckerHTMLReporter.
|
||||
type testNoHTMLObservationProvider struct{}
|
||||
|
||||
func (p *testNoHTMLObservationProvider) Key() happydns.ObservationKey { return "test_no_html_obs" }
|
||||
func (p *testNoHTMLObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
return map[string]any{"v": 1}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
checkerPkg.RegisterObservationProvider(&testNoHTMLObservationProvider{})
|
||||
}
|
||||
|
||||
func TestGetExecutionHTMLReport_ProviderDoesNotSupportHTML(t *testing.T) {
|
||||
cc, store := newTestControllerWithStorage(&stubCheckerEngine{})
|
||||
|
||||
target := happydns.CheckTarget{DomainId: "d1"}
|
||||
exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{
|
||||
"test_no_html_obs": json.RawMessage(`{"v":1}`),
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/report", nil)
|
||||
c.Set("execution", exec)
|
||||
c.Params = gin.Params{{Key: "obsKey", Value: "test_no_html_obs"}}
|
||||
|
||||
cc.GetExecutionHTMLReport(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 (unsupported), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExecutionHTMLReport_Success(t *testing.T) {
|
||||
cc, store := newTestControllerWithStorage(&stubCheckerEngine{})
|
||||
|
||||
target := happydns.CheckTarget{DomainId: "d1"}
|
||||
exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{
|
||||
"test_html_obs": json.RawMessage(`{"v":1}`),
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/report", nil)
|
||||
c.Set("execution", exec)
|
||||
c.Params = gin.Params{{Key: "obsKey", Value: "test_html_obs"}}
|
||||
|
||||
cc.GetExecutionHTMLReport(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if body != "<html><body>test report</body></html>" {
|
||||
t.Errorf("unexpected body: %s", body)
|
||||
}
|
||||
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("expected Content-Type text/html, got %q", ct)
|
||||
}
|
||||
|
||||
csp := w.Header().Get("Content-Security-Policy")
|
||||
if csp == "" {
|
||||
t.Error("expected Content-Security-Policy header to be set")
|
||||
}
|
||||
|
||||
xcto := w.Header().Get("X-Content-Type-Options")
|
||||
if xcto != "nosniff" {
|
||||
t.Errorf("expected X-Content-Type-Options nosniff, got %q", xcto)
|
||||
}
|
||||
}
|
||||
|
||||
// --- getLimitParam tests ---
|
||||
|
||||
func newContextWithQuery(query string) *gin.Context {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/?"+query, nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestGetLimitParam(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
defaultLimit int
|
||||
expected int
|
||||
}{
|
||||
{"empty query returns default", "", 100, 100},
|
||||
{"valid limit", "limit=50", 100, 50},
|
||||
{"zero returns default", "limit=0", 100, 100},
|
||||
{"negative returns default", "limit=-5", 100, 100},
|
||||
{"non-numeric returns default", "limit=abc", 100, 100},
|
||||
{"large value capped to maxLimit", "limit=1500", 100, 1000},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := newContextWithQuery(tt.query)
|
||||
got := getLimitParam(c, tt.defaultLimit)
|
||||
if got != tt.expected {
|
||||
t.Errorf("getLimitParam(%q, %d) = %d, want %d", tt.query, tt.defaultLimit, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import (
|
|||
"github.com/miekg/dns"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -37,13 +38,15 @@ type DomainController struct {
|
|||
domainService happydns.DomainUsecase
|
||||
remoteZoneImporter happydns.RemoteZoneImporterUsecase
|
||||
zoneImporter happydns.ZoneImporterUsecase
|
||||
checkStatusUC *checkerUC.CheckStatusUsecase
|
||||
}
|
||||
|
||||
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase) *DomainController {
|
||||
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, checkStatusUC *checkerUC.CheckStatusUsecase) *DomainController {
|
||||
return &DomainController{
|
||||
domainService: domainService,
|
||||
remoteZoneImporter: remoteZoneImporter,
|
||||
zoneImporter: zoneImporter,
|
||||
checkStatusUC: checkStatusUC,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +59,7 @@ func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporte
|
|||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {array} happydns.Domain
|
||||
// @Success 200 {array} happydns.DomainWithCheckStatus
|
||||
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Unable to retrieve user's domains"
|
||||
// @Router /domains [get]
|
||||
|
|
@ -73,7 +76,25 @@ func (dc *DomainController) GetDomains(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, domains)
|
||||
var statusByDomain map[string]*happydns.Status
|
||||
if dc.checkStatusUC != nil {
|
||||
var err error
|
||||
statusByDomain, err = dc.checkStatusUC.GetWorstDomainStatuses(user.Id)
|
||||
if err != nil {
|
||||
log.Printf("GetWorstDomainStatuses: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]*happydns.DomainWithCheckStatus, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
entry := &happydns.DomainWithCheckStatus{Domain: d}
|
||||
if statusByDomain != nil {
|
||||
entry.LastCheckStatus = statusByDomain[d.Id.String()]
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// AddDomain appends a new domain to those managed.
|
||||
|
|
|
|||
86
internal/api/controller/domain_info.go
Normal file
86
internal/api/controller/domain_info.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type DomainInfoController struct {
|
||||
diuService happydns.DomainInfoUsecase
|
||||
}
|
||||
|
||||
func NewDomainInfoController(diuService happydns.DomainInfoUsecase) *DomainInfoController {
|
||||
return &DomainInfoController{
|
||||
diuService: diuService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDomainInfo retrieves domain's administrative information.
|
||||
//
|
||||
// @Summary Get domain administrative information
|
||||
// @Schemes
|
||||
// @Description Retrieve domain's administrative information.
|
||||
// @Tags domains
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Param domain path string true "Domain name"
|
||||
// @Success 200 {object} happydns.DomainInfo
|
||||
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domaininfo/{domain} [post]
|
||||
func (dc *DomainInfoController) GetDomainInfo(c *gin.Context) {
|
||||
domain := c.Param("domain")
|
||||
if dn, ok := c.Get("domain"); ok {
|
||||
domain = dn.(*happydns.Domain).DomainName
|
||||
}
|
||||
|
||||
domain = dns.Fqdn(strings.TrimSpace(domain))
|
||||
if domain == "." {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: "empty domain name"})
|
||||
return
|
||||
}
|
||||
if _, ok := dns.IsDomainName(domain); !ok {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: "invalid domain name"})
|
||||
return
|
||||
}
|
||||
|
||||
info, err := dc.diuService.GetDomainInfo(c.Request.Context(), happydns.Origin(domain))
|
||||
if err != nil {
|
||||
if errors.Is(err, happydns.ErrDomainDoesNotExist) {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: err.Error()})
|
||||
} else {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
296
internal/api/controller/domain_test.go
Normal file
296
internal/api/controller/domain_test.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// --- Stub types for domain tests ---
|
||||
|
||||
// stubDomainUsecase implements happydns.DomainUsecase for testing.
|
||||
type stubDomainUsecase struct {
|
||||
domains []*happydns.Domain
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubDomainUsecase) CreateDomain(ctx context.Context, user *happydns.User, input *happydns.DomainCreationInput) (*happydns.Domain, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *stubDomainUsecase) DeleteDomain(id happydns.Identifier) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *stubDomainUsecase) ExtendsDomainWithZoneMeta(d *happydns.Domain) (*happydns.DomainWithZoneMetadata, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *stubDomainUsecase) GetUserDomain(user *happydns.User, id happydns.Identifier) (*happydns.Domain, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *stubDomainUsecase) GetUserDomainByFQDN(user *happydns.User, fqdn string) ([]*happydns.Domain, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *stubDomainUsecase) ListUserDomains(user *happydns.User) ([]*happydns.Domain, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.domains, nil
|
||||
}
|
||||
func (s *stubDomainUsecase) UpdateDomain(id happydns.Identifier, user *happydns.User, fn func(*happydns.Domain)) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// newDomainTestContext creates a gin context with a logged-in user and a recorder.
|
||||
func newDomainTestContext(user *happydns.User) (*httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/domains", nil)
|
||||
if user != nil {
|
||||
c.Set("LoggedUser", user)
|
||||
}
|
||||
return w, c
|
||||
}
|
||||
|
||||
// --- GetDomains tests ---
|
||||
|
||||
func TestGetDomains_Unauthenticated(t *testing.T) {
|
||||
dc := NewDomainController(&stubDomainUsecase{}, nil, nil, nil)
|
||||
|
||||
w, c := newDomainTestContext(nil)
|
||||
dc.GetDomains(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_ListError(t *testing.T) {
|
||||
stub := &stubDomainUsecase{err: fmt.Errorf("db failure")}
|
||||
dc := NewDomainController(stub, nil, nil, nil)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
w, c := newDomainTestContext(user)
|
||||
dc.GetDomains(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_EmptyList(t *testing.T) {
|
||||
stub := &stubDomainUsecase{domains: []*happydns.Domain{}}
|
||||
dc := NewDomainController(stub, nil, nil, nil)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
w, c := newDomainTestContext(user)
|
||||
dc.GetDomains(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var result []happydns.DomainWithCheckStatus
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0 domains, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_NilCheckStatusUC(t *testing.T) {
|
||||
did1, _ := happydns.NewRandomIdentifier()
|
||||
did2, _ := happydns.NewRandomIdentifier()
|
||||
stub := &stubDomainUsecase{
|
||||
domains: []*happydns.Domain{
|
||||
{Id: did1, DomainName: "example.com."},
|
||||
{Id: did2, DomainName: "example.org."},
|
||||
},
|
||||
}
|
||||
dc := NewDomainController(stub, nil, nil, nil)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
w, c := newDomainTestContext(user)
|
||||
dc.GetDomains(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var result []happydns.DomainWithCheckStatus
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 domains, got %d", len(result))
|
||||
}
|
||||
|
||||
for _, d := range result {
|
||||
if d.LastCheckStatus != nil {
|
||||
t.Errorf("expected nil LastCheckStatus when checkStatusUC is nil, got %v for domain %s", *d.LastCheckStatus, d.DomainName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_WithCheckStatuses(t *testing.T) {
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did1, _ := happydns.NewRandomIdentifier()
|
||||
did2, _ := happydns.NewRandomIdentifier()
|
||||
did3, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
stub := &stubDomainUsecase{
|
||||
domains: []*happydns.Domain{
|
||||
{Id: did1, DomainName: "warn.example.com.", Owner: uid},
|
||||
{Id: did2, DomainName: "ok.example.com.", Owner: uid},
|
||||
{Id: did3, DomainName: "unchecked.example.com.", Owner: uid},
|
||||
},
|
||||
}
|
||||
|
||||
store, err := inmemory.Instantiate()
|
||||