New checker: domain lock status
Some checks are pending
continuous-integration/drone/push Build is pending

This commit is contained in:
nemunaire 2026-04-08 12:17:40 +07:00
commit e9b20aa958
3 changed files with 311 additions and 0 deletions

View file

@ -47,6 +47,7 @@ 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.
@ -80,6 +81,7 @@ func (p *whoisProvider) Collect(ctx context.Context, opts happydns.CheckerOption
ExpiryDate: *info.ExpirationDate,
Registrar: registrar,
Contacts: info.Contacts,
Status: info.Status,
}, nil
}

159
checkers/domain_lock.go Normal file
View file

@ -0,0 +1,159 @@
// 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.StatusOK,
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: "requiredStatuses",
Type: "string",
Label: "Required lock statuses",
Description: "Comma-separated list of EPP status codes that must be present on the domain (e.g. clientTransferProhibited, clientUpdateProhibited, clientDeleteProhibited).",
Default: defaultRequiredLockStatuses,
Placeholder: defaultRequiredLockStatuses,
},
},
},
Rules: []happydns.CheckRule{
&domainLockRule{},
},
Interval: &happydns.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
})
}

View file

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