293 lines
7.4 KiB
Go
293 lines
7.4 KiB
Go
package whois
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
type rdapDomainDoc struct {
|
|
ObjectClassName string `json:"objectClassName"`
|
|
LDHName string `json:"ldhName"`
|
|
UnicodeName string `json:"unicodeName"`
|
|
Handle string `json:"handle"`
|
|
Status []string `json:"status"`
|
|
Nameservers []rdapNameServer `json:"nameservers"`
|
|
Events []rdapEvent `json:"events"`
|
|
Entities []rdapEntityDoc `json:"entities"`
|
|
}
|
|
|
|
type rdapNameServer struct {
|
|
LDHName string `json:"ldhName"`
|
|
UnicodeName string `json:"unicodeName"`
|
|
}
|
|
|
|
type rdapEvent struct {
|
|
EventAction string `json:"eventAction"`
|
|
EventDate string `json:"eventDate"`
|
|
}
|
|
|
|
type rdapEntityDoc struct {
|
|
Handle string `json:"handle"`
|
|
Roles []string `json:"roles"`
|
|
PublicIDs []rdapPublicID `json:"publicIds"`
|
|
VCard any `json:"vcardArray"`
|
|
}
|
|
|
|
type rdapPublicID struct {
|
|
Type string `json:"type"`
|
|
Identifier string `json:"identifier"`
|
|
}
|
|
|
|
func rdapResponseToResult(query string, resp *RDAPResponse, targetKind lookupTargetKind) (Result, error) {
|
|
if resp == nil {
|
|
return Result{}, fmt.Errorf("whois/rdap: nil response")
|
|
}
|
|
var doc rdapDomainDoc
|
|
if err := json.Unmarshal(resp.Body, &doc); err != nil {
|
|
return Result{}, fmt.Errorf("whois/rdap: decode domain response failed: %w", err)
|
|
}
|
|
|
|
normalizedQuery := strings.TrimSpace(query)
|
|
if targetKind == lookupTargetDomain {
|
|
normalizedQuery = strings.ToLower(strings.Trim(normalizedQuery, "."))
|
|
}
|
|
result := Result{
|
|
exists: false,
|
|
domain: normalizedQuery,
|
|
rawData: string(resp.Body),
|
|
whoisSer: resp.Endpoint,
|
|
}
|
|
|
|
switch targetKind {
|
|
case lookupTargetDomain:
|
|
if n := strings.TrimSpace(doc.LDHName); n != "" {
|
|
result.domain = strings.ToLower(n)
|
|
result.exists = true
|
|
} else if n := strings.TrimSpace(doc.UnicodeName); n != "" {
|
|
result.domain = n
|
|
result.exists = true
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(doc.ObjectClassName), "domain") {
|
|
result.exists = true
|
|
}
|
|
case lookupTargetIP, lookupTargetASN:
|
|
if strings.TrimSpace(doc.ObjectClassName) != "" || strings.TrimSpace(doc.Handle) != "" {
|
|
result.exists = true
|
|
}
|
|
if result.domain == "" {
|
|
result.domain = normalizedQuery
|
|
}
|
|
}
|
|
if h := strings.TrimSpace(doc.Handle); h != "" {
|
|
result.domainID = h
|
|
}
|
|
|
|
result.statusRaw = appendUniqueStrings(result.statusRaw, normalizeStringSlice(doc.Status)...)
|
|
for _, ns := range doc.Nameservers {
|
|
v := strings.TrimSpace(ns.LDHName)
|
|
if v == "" {
|
|
v = strings.TrimSpace(ns.UnicodeName)
|
|
}
|
|
if v != "" {
|
|
result.nsServers = appendUniqueStrings(result.nsServers, v)
|
|
}
|
|
}
|
|
|
|
for _, ev := range doc.Events {
|
|
action := strings.ToLower(strings.TrimSpace(ev.EventAction))
|
|
if action == "" {
|
|
continue
|
|
}
|
|
t := parseDateAuto(strings.TrimSpace(ev.EventDate))
|
|
if t.IsZero() {
|
|
continue
|
|
}
|
|
switch {
|
|
case strings.Contains(action, "registration"),
|
|
strings.Contains(action, "registered"),
|
|
strings.Contains(action, "creation"):
|
|
if !result.hasRegisterDate {
|
|
result.hasRegisterDate = true
|
|
result.registerDate = t
|
|
}
|
|
case strings.Contains(action, "expiration"),
|
|
strings.Contains(action, "expiry"),
|
|
strings.Contains(action, "expire"):
|
|
if !result.hasExpireDate {
|
|
result.hasExpireDate = true
|
|
result.expireDate = t
|
|
}
|
|
case strings.Contains(action, "changed"),
|
|
strings.Contains(action, "update"):
|
|
if !result.hasUpdateDate {
|
|
result.hasUpdateDate = true
|
|
result.updateDate = t
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, ent := range doc.Entities {
|
|
info := parseRDAPEntityPersonalInfo(ent.VCard)
|
|
isRegistrar, isRegistrant, isAdmin, isTech := classifyRDAPEntityRoles(ent.Roles)
|
|
|
|
if isRegistrar {
|
|
if result.registrar == "" {
|
|
result.registrar = firstNonEmpty(info.Org, info.Name, ent.Handle)
|
|
}
|
|
if result.ianaID == "" {
|
|
for _, pid := range ent.PublicIDs {
|
|
typed := strings.ToLower(strings.TrimSpace(pid.Type))
|
|
if strings.Contains(typed, "iana") {
|
|
result.ianaID = strings.TrimSpace(pid.Identifier)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if isRegistrant {
|
|
result.registerInfo = mergePersonalInfo(result.registerInfo, info)
|
|
}
|
|
if isAdmin {
|
|
result.adminInfo = mergePersonalInfo(result.adminInfo, info)
|
|
}
|
|
if isTech {
|
|
result.techInfo = mergePersonalInfo(result.techInfo, info)
|
|
}
|
|
}
|
|
|
|
result.meta = buildResultMeta(result, "rdap", resp.Endpoint)
|
|
result.meta.Charset = "utf-8"
|
|
return result, nil
|
|
}
|
|
|
|
func parseRDAPEntityPersonalInfo(vcard any) PersonalInfo {
|
|
info := PersonalInfo{}
|
|
root, ok := vcard.([]any)
|
|
if !ok || len(root) < 2 {
|
|
return info
|
|
}
|
|
props, ok := root[1].([]any)
|
|
if !ok {
|
|
return info
|
|
}
|
|
|
|
for _, p := range props {
|
|
kv, ok := p.([]any)
|
|
if !ok || len(kv) < 4 {
|
|
continue
|
|
}
|
|
key := strings.ToLower(strings.TrimSpace(fmt.Sprint(kv[0])))
|
|
val := kv[3]
|
|
switch key {
|
|
case "fn":
|
|
info.Name = firstNonEmpty(info.Name, rdapValueString(val))
|
|
case "org":
|
|
info.Org = firstNonEmpty(info.Org, rdapValueString(val))
|
|
case "email":
|
|
info.Email = firstNonEmpty(info.Email, rdapValueString(val))
|
|
case "tel":
|
|
info.Phone = firstNonEmpty(info.Phone, rdapValueString(val))
|
|
case "adr":
|
|
addr, city, state, zip, country := rdapAddressFields(val)
|
|
info.Addr = firstNonEmpty(info.Addr, addr)
|
|
info.City = firstNonEmpty(info.City, city)
|
|
info.State = firstNonEmpty(info.State, state)
|
|
info.Zip = firstNonEmpty(info.Zip, zip)
|
|
info.Country = firstNonEmpty(info.Country, country)
|
|
}
|
|
}
|
|
return info
|
|
}
|
|
|
|
func rdapAddressFields(v any) (addr, city, state, zip, country string) {
|
|
arr, ok := v.([]any)
|
|
if !ok {
|
|
return rdapValueString(v), "", "", "", ""
|
|
}
|
|
get := func(i int) string {
|
|
if i < 0 || i >= len(arr) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(rdapValueString(arr[i]))
|
|
}
|
|
addr = strings.TrimSpace(strings.Join(compactStrings([]string{get(0), get(1), get(2)}), " "))
|
|
city = get(3)
|
|
state = get(4)
|
|
zip = get(5)
|
|
country = get(6)
|
|
return
|
|
}
|
|
|
|
func rdapValueString(v any) string {
|
|
switch x := v.(type) {
|
|
case string:
|
|
return strings.TrimSpace(x)
|
|
case []any:
|
|
parts := make([]string, 0, len(x))
|
|
for _, item := range x {
|
|
s := strings.TrimSpace(rdapValueString(item))
|
|
if s != "" {
|
|
parts = append(parts, s)
|
|
}
|
|
}
|
|
return strings.Join(parts, " ")
|
|
default:
|
|
return strings.TrimSpace(fmt.Sprint(v))
|
|
}
|
|
}
|
|
|
|
func classifyRDAPEntityRoles(roles []string) (isRegistrar, isRegistrant, isAdmin, isTech bool) {
|
|
for _, role := range roles {
|
|
r := strings.ToLower(strings.TrimSpace(role))
|
|
switch {
|
|
case strings.Contains(r, "registrar"):
|
|
isRegistrar = true
|
|
case strings.Contains(r, "registrant"), strings.Contains(r, "holder"):
|
|
isRegistrant = true
|
|
case strings.Contains(r, "administrative"), strings.Contains(r, "admin"):
|
|
isAdmin = true
|
|
case strings.Contains(r, "technical"), strings.Contains(r, "tech"):
|
|
isTech = true
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func normalizeStringSlice(in []string) []string {
|
|
out := make([]string, 0, len(in))
|
|
seen := make(map[string]struct{}, len(in))
|
|
for _, v := range in {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[v]; ok {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func compactStrings(in []string) []string {
|
|
out := make([]string, 0, len(in))
|
|
for _, s := range in {
|
|
s = strings.TrimSpace(s)
|
|
if s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if strings.TrimSpace(v) != "" {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return ""
|
|
}
|