New checker: domain contact consistency
Add ContactInfo struct to DomainInfo and extract contact data (registrant, admin, tech) from both RDAP and WHOIS responses. Introduce a new domain_contact checker that compares actual contact fields against user-specified expected values, with redaction detection for privacy-protected domains. The WHOIS observation provider now also exposes contact data so the new rule can reuse the same lookup as domain_expiry.
This commit is contained in:
parent
1f2c56d9c3
commit
3fe1566370
8 changed files with 889 additions and 9 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -44,8 +44,9 @@ const (
|
|||
|
||||
// WHOISData represents WHOIS observation data.
|
||||
type WHOISData struct {
|
||||
ExpiryDate time.Time `json:"expiryDate"`
|
||||
Registrar string `json:"registrar"`
|
||||
ExpiryDate time.Time `json:"expiryDate"`
|
||||
Registrar string `json:"registrar"`
|
||||
Contacts map[string]*happydns.ContactInfo `json:"contacts,omitempty"`
|
||||
}
|
||||
|
||||
// whoisProvider is a placeholder WHOIS observation provider.
|
||||
|
|
@ -78,6 +79,7 @@ func (p *whoisProvider) Collect(ctx context.Context, opts happydns.CheckerOption
|
|||
return &WHOISData{
|
||||
ExpiryDate: *info.ExpirationDate,
|
||||
Registrar: registrar,
|
||||
Contacts: info.Contacts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,14 +26,27 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
type ContactInfo struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Organization string `json:"organization,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Street string `json:"street,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
Province string `json:"province,omitempty"`
|
||||
PostalCode string `json:"postal_code,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
}
|
||||
|
||||
type DomainInfo struct {
|
||||
Name string `json:"name"`
|
||||
Nameservers []string `json:"nameservers"`
|
||||
CreationDate *time.Time `json:"creation"`
|
||||
ExpirationDate *time.Time `json:"expiration"`
|
||||
Registrar string `json:"registrar"`
|
||||
RegistrarURL *string `json:"registrar_url"`
|
||||
Status []string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
Nameservers []string `json:"nameservers"`
|
||||
CreationDate *time.Time `json:"creation"`
|
||||
ExpirationDate *time.Time `json:"expiration"`
|
||||
Registrar string `json:"registrar"`
|
||||
RegistrarURL *string `json:"registrar_url"`
|
||||
Status []string `json:"status"`
|
||||
Contacts map[string]*ContactInfo `json:"contacts,omitempty"`
|
||||
}
|
||||
|
||||
type DomainInfoGetter func(context.Context, Origin) (*DomainInfo, error)
|
||||
|
|
|
|||
|
|
@ -106,6 +106,46 @@ func mapRDAPDomain(domainInfo *rdap.Domain) (*happydns.DomainInfo, error) {
|
|||
name = domainInfo.LDHName
|
||||
}
|
||||
|
||||
// Contacts
|
||||
rdapRoleMap := map[string]string{
|
||||
"registrant": "registrant",
|
||||
"administrative": "admin",
|
||||
"technical": "tech",
|
||||
}
|
||||
contacts := make(map[string]*happydns.ContactInfo)
|
||||
for _, ent := range domainInfo.Entities {
|
||||
if ent.VCard == nil || ent.Roles == nil {
|
||||
continue
|
||||
}
|
||||
for _, role := range ent.Roles {
|
||||
key, ok := rdapRoleMap[role]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ci := &happydns.ContactInfo{
|
||||
Name: ent.VCard.Name(),
|
||||
Email: ent.VCard.Email(),
|
||||
Street: ent.VCard.StreetAddress(),
|
||||
City: ent.VCard.Locality(),
|
||||
Province: ent.VCard.Region(),
|
||||
PostalCode: ent.VCard.PostalCode(),
|
||||
Country: ent.VCard.Country(),
|
||||
Phone: ent.VCard.Tel(),
|
||||
}
|
||||
if props := ent.VCard.Get("org"); len(props) > 0 {
|
||||
if s, ok := props[0].Value.(string); ok {
|
||||
ci.Organization = s
|
||||
}
|
||||
}
|
||||
contacts[key] = ci
|
||||
}
|
||||
}
|
||||
|
||||
var contactsPtr map[string]*happydns.ContactInfo
|
||||
if len(contacts) > 0 {
|
||||
contactsPtr = contacts
|
||||
}
|
||||
|
||||
return &happydns.DomainInfo{
|
||||
Name: name,
|
||||
Nameservers: nameservers,
|
||||
|
|
@ -114,5 +154,6 @@ func mapRDAPDomain(domainInfo *rdap.Domain) (*happydns.DomainInfo, error) {
|
|||
Registrar: registrar,
|
||||
RegistrarURL: registrarURL,
|
||||
Status: domainInfo.Status,
|
||||
Contacts: contactsPtr,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
211
pkg/domaininfo/rdap_mapper_test.go
Normal file
211
pkg/domaininfo/rdap_mapper_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 domaininfo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/openrdap/rdap"
|
||||
)
|
||||
|
||||
// mustVCard parses a jCard JSON literal and fails the test on error.
|
||||
func mustVCard(t *testing.T, jcard string) *rdap.VCard {
|
||||
t.Helper()
|
||||
v, err := rdap.NewVCard([]byte(jcard))
|
||||
if err != nil {
|
||||
t.Fatalf("NewVCard: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
const registrarVCard = `["vcard",[
|
||||
["version",{},"text","4.0"],
|
||||
["fn",{},"text","Acme Registrar"],
|
||||
["url",{},"uri","https://acme.example"]
|
||||
]]`
|
||||
|
||||
const registrantVCard = `["vcard",[
|
||||
["version",{},"text","4.0"],
|
||||
["fn",{},"text","Alice Example"],
|
||||
["org",{},"text","Example Inc"],
|
||||
["email",{},"text","alice@example.com"],
|
||||
["adr",{},"text",["","","123 Main St","Springfield","IL","62701","US"]],
|
||||
["tel",{"type":["voice"]},"uri","tel:+1-555-0100"]
|
||||
]]`
|
||||
|
||||
func TestMapRDAPDomain_Full(t *testing.T) {
|
||||
in := &rdap.Domain{
|
||||
LDHName: "example.com",
|
||||
UnicodeName: "",
|
||||
Status: []string{"clientTransferProhibited"},
|
||||
Nameservers: []rdap.Nameserver{
|
||||
{LDHName: "ns1.example.com"},
|
||||
{UnicodeName: "ns2.exämple.com", LDHName: "ns2.xn--exmple-cua.com"},
|
||||
},
|
||||
Events: []rdap.Event{
|
||||
{Action: "registration", Date: "2000-01-01T00:00:00Z"},
|
||||
{Action: "expiration", Date: "2027-06-01T00:00:00Z"},
|
||||
{Action: "last changed", Date: "2024-01-01T00:00:00Z"},
|
||||
},
|
||||
Entities: []rdap.Entity{
|
||||
{
|
||||
Roles: []string{"registrar"},
|
||||
VCard: mustVCard(t, registrarVCard),
|
||||
},
|
||||
{
|
||||
Roles: []string{"registrant"},
|
||||
VCard: mustVCard(t, registrantVCard),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := mapRDAPDomain(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if out.Name != "example.com" {
|
||||
t.Errorf("Name = %q", out.Name)
|
||||
}
|
||||
if out.Registrar != "Acme Registrar" {
|
||||
t.Errorf("Registrar = %q", out.Registrar)
|
||||
}
|
||||
if out.RegistrarURL == nil || *out.RegistrarURL != "https://acme.example" {
|
||||
t.Errorf("RegistrarURL = %v", out.RegistrarURL)
|
||||
}
|
||||
|
||||
if out.ExpirationDate == nil || out.ExpirationDate.Year() != 2027 {
|
||||
t.Errorf("ExpirationDate = %v", out.ExpirationDate)
|
||||
}
|
||||
if out.CreationDate == nil || out.CreationDate.Year() != 2000 {
|
||||
t.Errorf("CreationDate = %v", out.CreationDate)
|
||||
}
|
||||
|
||||
if len(out.Nameservers) != 2 || out.Nameservers[0] != "ns1.example.com" || out.Nameservers[1] != "ns2.exämple.com" {
|
||||
t.Errorf("Nameservers = %v (expected unicode preference)", out.Nameservers)
|
||||
}
|
||||
if len(out.Status) != 1 || out.Status[0] != "clientTransferProhibited" {
|
||||
t.Errorf("Status = %v", out.Status)
|
||||
}
|
||||
|
||||
if out.Contacts == nil {
|
||||
t.Fatal("Contacts is nil")
|
||||
}
|
||||
r := out.Contacts["registrant"]
|
||||
if r == nil {
|
||||
t.Fatal("registrant missing")
|
||||
}
|
||||
if r.Name != "Alice Example" {
|
||||
t.Errorf("registrant Name = %q", r.Name)
|
||||
}
|
||||
if r.Organization != "Example Inc" {
|
||||
t.Errorf("registrant Organization = %q", r.Organization)
|
||||
}
|
||||
if r.Email != "alice@example.com" {
|
||||
t.Errorf("registrant Email = %q", r.Email)
|
||||
}
|
||||
if r.Country != "US" {
|
||||
t.Errorf("registrant Country = %q", r.Country)
|
||||
}
|
||||
if r.Phone == "" {
|
||||
t.Errorf("registrant Phone empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapRDAPDomain_RoleMapping(t *testing.T) {
|
||||
mk := func(role string) rdap.Entity {
|
||||
return rdap.Entity{
|
||||
Roles: []string{role},
|
||||
VCard: mustVCard(t, `["vcard",[["version",{},"text","4.0"],["fn",{},"text","`+role+`"]]]`),
|
||||
}
|
||||
}
|
||||
in := &rdap.Domain{
|
||||
LDHName: "example.com",
|
||||
Entities: []rdap.Entity{
|
||||
mk("registrant"),
|
||||
mk("administrative"),
|
||||
mk("technical"),
|
||||
mk("abuse"), // unmapped: should be skipped
|
||||
},
|
||||
}
|
||||
out, err := mapRDAPDomain(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"registrant", "admin", "tech"} {
|
||||
if _, ok := out.Contacts[want]; !ok {
|
||||
t.Errorf("missing contact role %q", want)
|
||||
}
|
||||
}
|
||||
if _, ok := out.Contacts["abuse"]; ok {
|
||||
t.Error("abuse role should not be present")
|
||||
}
|
||||
if len(out.Contacts) != 3 {
|
||||
t.Errorf("got %d contacts, want 3", len(out.Contacts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapRDAPDomain_NoEntities(t *testing.T) {
|
||||
in := &rdap.Domain{LDHName: "bare.example"}
|
||||
out, err := mapRDAPDomain(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Registrar != "Unknown" {
|
||||
t.Errorf("Registrar = %q, want Unknown", out.Registrar)
|
||||
}
|
||||
if out.RegistrarURL != nil {
|
||||
t.Errorf("RegistrarURL = %v, want nil", out.RegistrarURL)
|
||||
}
|
||||
if out.Contacts != nil {
|
||||
t.Errorf("Contacts = %v, want nil", out.Contacts)
|
||||
}
|
||||
if out.ExpirationDate != nil || out.CreationDate != nil {
|
||||
t.Error("dates should be nil with no events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapRDAPDomain_UnicodeNamePreference(t *testing.T) {
|
||||
in := &rdap.Domain{
|
||||
LDHName: "xn--bcher-kva.example",
|
||||
UnicodeName: "bücher.example",
|
||||
}
|
||||
out, err := mapRDAPDomain(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Name != "bücher.example" {
|
||||
t.Errorf("Name = %q, want unicode form", out.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapRDAPDomain_BadEventDate(t *testing.T) {
|
||||
in := &rdap.Domain{
|
||||
LDHName: "example.com",
|
||||
Events: []rdap.Event{
|
||||
{Action: "expiration", Date: "not a date"},
|
||||
},
|
||||
}
|
||||
if _, err := mapRDAPDomain(in); err == nil {
|
||||
t.Error("expected error on malformed event date")
|
||||
}
|
||||
}
|
||||
|
|
@ -85,6 +85,35 @@ func mapWhoisResult(result *whoisparser.WhoisInfo) *happydns.DomainInfo {
|
|||
status = result.Domain.Status
|
||||
}
|
||||
|
||||
// Contacts
|
||||
contacts := make(map[string]*happydns.ContactInfo)
|
||||
whoisContacts := map[string]*whoisparser.Contact{
|
||||
"registrant": result.Registrant,
|
||||
"admin": result.Administrative,
|
||||
"tech": result.Technical,
|
||||
}
|
||||
for key, wc := range whoisContacts {
|
||||
if wc == nil {
|
||||
continue
|
||||
}
|
||||
contacts[key] = &happydns.ContactInfo{
|
||||
Name: wc.Name,
|
||||
Organization: wc.Organization,
|
||||
Email: wc.Email,
|
||||
Street: wc.Street,
|
||||
City: wc.City,
|
||||
Province: wc.Province,
|
||||
PostalCode: wc.PostalCode,
|
||||
Country: wc.Country,
|
||||
Phone: wc.Phone,
|
||||
}
|
||||
}
|
||||
|
||||
var contactsPtr map[string]*happydns.ContactInfo
|
||||
if len(contacts) > 0 {
|
||||
contactsPtr = contacts
|
||||
}
|
||||
|
||||
return &happydns.DomainInfo{
|
||||
Name: name,
|
||||
Nameservers: nameservers,
|
||||
|
|
@ -93,5 +122,6 @@ func mapWhoisResult(result *whoisparser.WhoisInfo) *happydns.DomainInfo {
|
|||
Registrar: registrar,
|
||||
RegistrarURL: registrarURL,
|
||||
Status: status,
|
||||
Contacts: contactsPtr,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
116
pkg/domaininfo/whois_mapper_test.go
Normal file
116
pkg/domaininfo/whois_mapper_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// 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 domaininfo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
whoisparser "github.com/likexian/whois-parser"
|
||||
)
|
||||
|
||||
func TestMapWhoisResult_Full(t *testing.T) {
|
||||
exp := time.Date(2027, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
created := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
in := &whoisparser.WhoisInfo{
|
||||
Domain: &whoisparser.Domain{
|
||||
Domain: "example.com",
|
||||
NameServers: []string{"ns1.example.com", "ns2.example.com"},
|
||||
Status: []string{"clientTransferProhibited"},
|
||||
CreatedDateInTime: &created,
|
||||
ExpirationDateInTime: &exp,
|
||||
},
|
||||
Registrar: &whoisparser.Contact{
|
||||
Name: "Acme Registrar",
|
||||
ReferralURL: "https://acme.example",
|
||||
},
|
||||
Registrant: &whoisparser.Contact{
|
||||
Name: "Alice",
|
||||
Organization: "Example Inc",
|
||||
Email: "alice@example.com",
|
||||
Country: "US",
|
||||
},
|
||||
Administrative: &whoisparser.Contact{Name: "Admin"},
|
||||
Technical: nil,
|
||||
}
|
||||
|
||||
out := mapWhoisResult(in)
|
||||
if out.Name != "example.com" {
|
||||
t.Errorf("Name = %q", out.Name)
|
||||
}
|
||||
if len(out.Nameservers) != 2 {
|
||||
t.Errorf("Nameservers = %v", out.Nameservers)
|
||||
}
|
||||
if out.ExpirationDate == nil || !out.ExpirationDate.Equal(exp) {
|
||||
t.Errorf("ExpirationDate = %v", out.ExpirationDate)
|
||||
}
|
||||
if out.CreationDate == nil || !out.CreationDate.Equal(created) {
|
||||
t.Errorf("CreationDate = %v", out.CreationDate)
|
||||
}
|
||||
if out.Registrar != "Acme Registrar" {
|
||||
t.Errorf("Registrar = %q", out.Registrar)
|
||||
}
|
||||
if out.RegistrarURL == nil || *out.RegistrarURL != "https://acme.example" {
|
||||
t.Errorf("RegistrarURL = %v", out.RegistrarURL)
|
||||
}
|
||||
if len(out.Status) != 1 || out.Status[0] != "clientTransferProhibited" {
|
||||
t.Errorf("Status = %v", out.Status)
|
||||
}
|
||||
|
||||
if out.Contacts == nil {
|
||||
t.Fatal("expected contacts map")
|
||||
}
|
||||
if r := out.Contacts["registrant"]; r == nil || r.Name != "Alice" || r.Email != "alice@example.com" || r.Organization != "Example Inc" || r.Country != "US" {
|
||||
t.Errorf("registrant = %+v", r)
|
||||
}
|
||||
if a := out.Contacts["admin"]; a == nil || a.Name != "Admin" {
|
||||
t.Errorf("admin = %+v", a)
|
||||
}
|
||||
if _, ok := out.Contacts["tech"]; ok {
|
||||
t.Error("tech should be absent when nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapWhoisResult_NilRegistrarAndContacts(t *testing.T) {
|
||||
in := &whoisparser.WhoisInfo{
|
||||
Domain: &whoisparser.Domain{Domain: "bare.example"},
|
||||
}
|
||||
out := mapWhoisResult(in)
|
||||
if out.Registrar != "Unknown" {
|
||||
t.Errorf("Registrar = %q, want Unknown", out.Registrar)
|
||||
}
|
||||
if out.RegistrarURL != nil {
|
||||
t.Errorf("RegistrarURL = %v, want nil", out.RegistrarURL)
|
||||
}
|
||||
if out.Contacts != nil {
|
||||
t.Errorf("Contacts = %v, want nil", out.Contacts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapWhoisResult_NilDomain(t *testing.T) {
|
||||
// Defensive: parser shouldn't normally produce this, but the mapper
|
||||
// must not panic on a nil Domain.
|
||||
out := mapWhoisResult(&whoisparser.WhoisInfo{})
|
||||
if out.Name != "" || out.Nameservers != nil || out.ExpirationDate != nil {
|
||||
t.Errorf("expected zero-valued domain fields, got %+v", out)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue