New checker: domain contact consistency and display contacts in WHOIS page
All checks were successful
continuous-integration/drone/push Build is passing

Add ContactInfo struct to DomainInfo model 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. Display extracted contacts in the frontend
WHOIS lookup page with localized labels (en/fr).
This commit is contained in:
nemunaire 2026-03-19 10:37:41 +07:00
commit af4a098e5c
7 changed files with 264 additions and 14 deletions

View file

@ -38,6 +38,10 @@ func (p *DomainRegistrationCheck) Options() happydns.CheckerOptionsDocumentation
{Id: "warningDays", Type: "number", Label: "Days before expiration to warn", Default: DEFAULT_WARNING_DAYS},
{Id: "criticalDays", Type: "number", Label: "Days before expiration to alert", Default: DEFAULT_CRITICAL_DAYS},
{Id: "requiredStatuses", Type: "string", Label: "Required lock statuses (comma-separated)", Default: "clientTransferProhibited"},
{Id: "expectedName", Type: "string", Label: "Expected registrant name"},
{Id: "expectedOrganization", Type: "string", Label: "Expected organization"},
{Id: "expectedEmail", Type: "string", Label: "Expected email"},
{Id: "checkRoles", Type: "string", Label: "Contact roles to check (comma-separated)", Default: "registrant"},
},
}
}
@ -69,21 +73,33 @@ func (p *DomainRegistrationCheck) RunCheck(ctx context.Context, options happydns
}
}
// 4b. Extract contact options
expectedName, _ := options["expectedName"].(string)
expectedOrg, _ := options["expectedOrganization"].(string)
expectedEmail, _ := options["expectedEmail"].(string)
checkRolesStr := "registrant"
if v, ok := options["checkRoles"].(string); ok && v != "" {
checkRolesStr = v
}
// 5. Evaluate sub-checks
expirationStatus, expirationLine := checkExpiration(info, warningDays, criticalDays)
lockStatus, lockLine := checkLockStatus(info, requiredStatusesStr)
contactStatus, contactLine := checkContacts(info, checkRolesStr, expectedName, expectedOrg, expectedEmail)
// 6. Combine results: worst status wins (lower value = more severe)
finalStatus := min(expirationStatus, lockStatus)
finalStatus := min(expirationStatus, lockStatus, contactStatus)
statusLine := expirationLine
if lockLine != "" {
statusLine = expirationLine + "; " + lockLine
var parts []string
for _, part := range []string{expirationLine, lockLine, contactLine} {
if part != "" {
parts = append(parts, part)
}
}
return &happydns.CheckResult{
Status: finalStatus,
StatusLine: statusLine,
StatusLine: strings.Join(parts, "; "),
Report: info,
}, nil
}
@ -138,6 +154,85 @@ func checkLockStatus(info *happydns.DomainInfo, requiredStatusesStr string) (hap
return happydns.CheckResultStatusOK, fmt.Sprintf("All required statuses present: %s", strings.Join(requiredStatuses, ", "))
}
func checkContacts(info *happydns.DomainInfo, checkRolesStr, expectedName, expectedOrg, expectedEmail string) (happydns.CheckResultStatus, string) {
if expectedName == "" && expectedOrg == "" && expectedEmail == "" {
return happydns.CheckResultStatusOK, ""
}
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.CheckResultStatusOK, ""
}
worstStatus := happydns.CheckResultStatusOK
var lines []string
for _, role := range roles {
contact, found := info.Contacts[role]
if !found || contact == nil {
lines = append(lines, fmt.Sprintf("%s: contact not found", role))
worstStatus = min(worstStatus, happydns.CheckResultStatusUnknown)
continue
}
if isRedacted(contact) {
lines = append(lines, fmt.Sprintf("%s: contact info is redacted/private", role))
worstStatus = min(worstStatus, happydns.CheckResultStatusInfo)
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, ", ")))
worstStatus = min(worstStatus, happydns.CheckResultStatusWarn)
} else {
lines = append(lines, fmt.Sprintf("%s: contact info matches", role))
}
}
return worstStatus, strings.Join(lines, "; ")
}
// isRedacted checks whether a contact's info appears to be privacy-protected or redacted.
func isRedacted(c *happydns.ContactInfo) bool {
redactedPatterns := []string{
"redacted",
"privacy",
"not disclosed",
"whoisguard",
"withheld",
"data protected",
"contact privacy",
}
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
}
// extractInt reads an int/float64 option with a default fallback.
func extractInt(options happydns.CheckerOptions, key string, def int) int {
if v, ok := options[key]; ok {

View file

@ -31,14 +31,27 @@ var (
DomainDoesNotExist = errors.New("domain name doesn't exist")
)
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)

View file

@ -99,6 +99,46 @@ func GetDomainRDAPInfo(ctx context.Context, domain happydns.Origin) (*happydns.D
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,
@ -107,5 +147,6 @@ func GetDomainRDAPInfo(ctx context.Context, domain happydns.Origin) (*happydns.D
Registrar: registrar,
RegistrarURL: registrar_url,
Status: domainInfo.Status,
Contacts: contactsPtr,
}, nil
}

View file

@ -63,6 +63,35 @@ func GetDomainWhoisInfo(ctx context.Context, domain happydns.Origin) (*happydns.
registrar_url = &result.Registrar.ReferralURL
}
// 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: result.Domain.Domain,
Nameservers: result.Domain.NameServers,
@ -71,5 +100,6 @@ func GetDomainWhoisInfo(ctx context.Context, domain happydns.Origin) (*happydns.
Registrar: registrar,
RegistrarURL: registrar_url,
Status: result.Domain.Status,
Contacts: contactsPtr,
}, nil
}

View file

@ -35,6 +35,7 @@
} from "@sveltestrap/sveltestrap";
import type { DomainInfo } from "$lib/model/domaininfo";
import type { HappydnsContactInfo } from "$lib/api-base/types.gen";
import { t } from "$lib/translations";
interface Props {
@ -44,6 +45,23 @@
let { info, domain }: Props = $props();
const contactRoleLabels: Record<string, string> = {
registrant: "domaininfo.contact-registrant",
admin: "domaininfo.contact-admin",
tech: "domaininfo.contact-tech",
};
function contactFields(c: HappydnsContactInfo): Array<{ label: string; value: string }> {
const fields: Array<{ label: string; value: string }> = [];
if (c.name) fields.push({ label: $t("domaininfo.contact-name"), value: c.name });
if (c.organization) fields.push({ label: $t("domaininfo.contact-organization"), value: c.organization });
if (c.email) fields.push({ label: $t("domaininfo.contact-email"), value: c.email });
if (c.phone) fields.push({ label: $t("domaininfo.contact-phone"), value: c.phone });
const addrParts = [c.street, c.city, c.province, c.postal_code, c.country].filter(Boolean);
if (addrParts.length > 0) fields.push({ label: $t("domaininfo.contact-address"), value: addrParts.join(", ") });
return fields;
}
function statusColor(code: string): string {
const lc = code.toLowerCase();
if (lc === "ok" || lc === "active") return "success";
@ -225,3 +243,36 @@
</Col>
{/if}
</Row>
<!-- Contacts -->
{#if info.contacts && Object.keys(info.contacts).length > 0}
<h5 class="fw-bold mt-2 mb-3">
<i class="bi bi-person-lines-fill me-1"></i>
{$t("domaininfo.contacts")}
</h5>
<Row>
{#each Object.entries(info.contacts) as [role, contact]}
<Col md={6} lg={4}>
<Card class="mb-4">
<CardHeader>
<CardTitle class="h6 mb-0 fw-bold">
<i class="bi bi-person me-1"></i>
{$t(contactRoleLabels[role] ?? role)}
</CardTitle>
</CardHeader>
<CardBody>
{#each contactFields(contact) as field}
<p class="mb-1">
<span class="text-muted small">{field.label}</span><br />
<span class="fw-semibold">{field.value}</span>
</p>
{/each}
{#if contactFields(contact).length === 0}
<p class="text-muted mb-0">{$t("domaininfo.contact-no-data")}</p>
{/if}
</CardBody>
</Card>
</Col>
{/each}
</Row>
{/if}

View file

@ -222,7 +222,17 @@
"serverHold": "The domain is on hold by the registry.",
"pendingTransfer": "A transfer to another registrar is pending.",
"pendingDelete": "The domain is pending deletion."
}
},
"contacts": "Contacts",
"contact-registrant": "Registrant",
"contact-admin": "Administrative",
"contact-tech": "Technical",
"contact-name": "Name",
"contact-organization": "Organization",
"contact-email": "Email",
"contact-phone": "Phone",
"contact-address": "Address",
"contact-no-data": "No contact information available"
},
"errors": {
"404": {

View file

@ -195,7 +195,17 @@
"serverHold": "Le domaine est suspendu par le registre.",
"pendingTransfer": "Un transfert vers un autre registrar est en cours.",
"pendingDelete": "Le domaine est en attente de suppression."
}
},
"contacts": "Contacts",
"contact-registrant": "Titulaire",
"contact-admin": "Administratif",
"contact-tech": "Technique",
"contact-name": "Nom",
"contact-organization": "Organisation",
"contact-email": "E-mail",
"contact-phone": "Téléphone",
"contact-address": "Adresse",
"contact-no-data": "Aucune information de contact disponible"
},
"errors": {
"404": {