New checker: domain contact consistency and display contacts in WHOIS page
All checks were successful
continuous-integration/drone/push Build is passing
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:
parent
cb373f776e
commit
af4a098e5c
7 changed files with 264 additions and 14 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue