WHOIS & RDAP Lookup — Go source
Look up registration data for any domain, IP address, or ASN — straight from your browser to the authoritative RDAP registry over HTTPS. No backend, no database of lookups, fully private.
This is the Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package whois is the Go twin of CosmoDev's src/lib/whois.ts (dual source: the
// web lib is TypeScript, the CLI lib is Go — kept in lock-step). Pure +
// deterministic, never panics. The table-driven tests in whois_test.go share
// vectors with src/lib/whois.test.ts so the two implementations are held to the
// same contract.
//
// This twin covers the PARSE layer only: input classification, IANA bootstrap
// resolution, RDAP URL construction, and RDAP-response normalization. The
// network fetch lives in the cosmodev CLI binary (not this package) — the twin
// spec forbids network/HTTP deps here. Splitting fetch from parse keeps the
// twin contract intact and gives a testable surface with no network flakiness.
package whois
import (
"encoding/json"
"net"
"net/url"
"regexp"
"strconv"
"strings"
)
// Kind is the classified type of a lookup target.
type Kind string
const (
KindDomain Kind = "domain"
KindIPv4 Kind = "ipv4"
KindIPv6 Kind = "ipv6"
KindASN Kind = "asn"
KindInvalid Kind = "invalid"
)
// ClassifiedInput is a normalized lookup target. It mirrors ClassifiedInput in
// src/lib/whois.ts.
type ClassifiedInput struct {
Kind Kind
Value string // RDAP path segment (lowercased domain, canonical IP, bare ASN)
TLD string // last label, for domain inputs
}
var (
asnRE = regexp.MustCompile(`^(?i)(?:as)?(\d{1,10})$`)
labelRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`)
schemeRE = regexp.MustCompile(`(?i)^[a-z][a-z0-9+.-]*://`)
prefixRE = regexp.MustCompile(`(?i)^(?:whois|rdap):`)
bracketRE = regexp.MustCompile(`^\[([0-9a-fA-F:.]+)\](?::\d+)?$`)
hostPortRE = regexp.MustCompile(`^([^:[\]]*\.[^:[\]]*):\d+$`)
)
// stripToHost strips scheme, userinfo, path, and an optional port down to the
// bare host. It mirrors stripToHost() in the TS lib.
func stripToHost(raw string) string {
s := strings.TrimSpace(raw)
s = schemeRE.ReplaceAllLiteralString(s, "")
s = prefixRE.ReplaceAllLiteralString(s, "")
if at := strings.LastIndex(s, "@"); at >= 0 {
s = s[at+1:]
}
if i := strings.IndexAny(s, "/?#"); i >= 0 {
s = s[:i]
}
if m := bracketRE.FindStringSubmatch(s); m != nil {
return m[1]
}
s = hostPortRE.ReplaceAllString(s, "$1")
return s
}
// ClassifyInput classifies a user-typed lookup target. Never panics. It mirrors
// classifyInput() in src/lib/whois.ts and must agree with it on every shared
// vector in whois_test.go.
func ClassifyInput(raw string) ClassifiedInput {
host := stripToHost(raw)
if host == "" {
return ClassifiedInput{Kind: KindInvalid}
}
// ASN: bare or AS-prefixed number with no dots.
if m := asnRE.FindStringSubmatch(host); m != nil {
if n, err := strconv.Atoi(m[1]); err == nil && n > 0 {
return ClassifiedInput{Kind: KindASN, Value: strconv.Itoa(n)}
}
}
// IP address (net.ParseIP handles both IPv4 and IPv6 validation).
if ip := net.ParseIP(host); ip != nil {
if ip.To4() != nil {
return ClassifiedInput{Kind: KindIPv4, Value: ip.String()}
}
return ClassifiedInput{Kind: KindIPv6, Value: ip.String()}
}
// Domain: must have a dot and every label must be valid.
lower := strings.ToLower(host)
lower = strings.TrimRight(lower, ".")
if !strings.Contains(lower, ".") {
return ClassifiedInput{Kind: KindInvalid}
}
labels := strings.Split(lower, ".")
for _, l := range labels {
if !labelRE.MatchString(l) {
return ClassifiedInput{Kind: KindInvalid}
}
}
return ClassifiedInput{Kind: KindDomain, Value: lower, TLD: labels[len(labels)-1]}
}
// BootstrapEntry is one row of the IANA RDAP bootstrap: match keys (TLDs, CIDRs,
// or ASN ranges) and the RDAP server URL(s) that serve them.
type BootstrapEntry struct {
Keys []string
URLs []string
}
// ParseBootstrap parses an IANA RDAP bootstrap file (dns/ipv4/ipv6/asn .json)
// into normalized entries. Returns nil on any malformed input (never panics).
func ParseBootstrap(data []byte) []BootstrapEntry {
var doc struct {
Services [][][]string `json:"services"`
}
if json.Unmarshal(data, &doc) != nil {
return nil
}
var out []BootstrapEntry
for _, entry := range doc.Services {
if len(entry) >= 2 && len(entry[0]) > 0 && len(entry[1]) > 0 {
out = append(out, BootstrapEntry{Keys: entry[0], URLs: entry[1]})
}
}
return out
}
func firstURL(e BootstrapEntry) string {
if len(e.URLs) > 0 {
return e.URLs[0]
}
return ""
}
// ResolveServer finds the first URL for an exact case-insensitive key match
// (the dns.json path). Mirrors resolveServer() in the TS lib.
func ResolveServer(boot []BootstrapEntry, key string) string {
t := strings.ToLower(key)
for _, e := range boot {
for _, k := range e.Keys {
if strings.ToLower(k) == t {
return firstURL(e)
}
}
}
return ""
}
// ResolveIPServer finds the authoritative server for an IP address via CIDR
// containment (the ipv4/ipv6.json path). Mirrors resolveIpServer() in the TS
// lib; uses net.ParseCIDR for the containment check.
func ResolveIPServer(boot []BootstrapEntry, ipStr string) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ""
}
for _, e := range boot {
for _, cidr := range e.Keys {
if _, network, err := net.ParseCIDR(cidr); err == nil {
if network.Contains(ip) {
return firstURL(e)
}
}
}
}
return ""
}
// asnInRange reports whether asn falls in a bootstrap range key ("lo-hi" or a
// bare number). Mirrors asnInRange() in the TS lib.
func asnInRange(key string, asn int) bool {
k := strings.TrimSpace(key)
if before, after, found := strings.Cut(k, "-"); found {
lo, err1 := strconv.Atoi(before)
hi, err2 := strconv.Atoi(after)
if err1 != nil || err2 != nil {
return false
}
return asn >= lo && asn <= hi
}
n, err := strconv.Atoi(k)
if err != nil {
return false
}
return n == asn
}
// ResolveASNServer finds the authoritative server for an ASN (the asn.json
// path). Mirrors resolveAsnServer() in the TS lib.
func ResolveASNServer(boot []BootstrapEntry, asn int) string {
for _, e := range boot {
for _, k := range e.Keys {
if asnInRange(k, asn) {
return firstURL(e)
}
}
}
return ""
}
// ObjectPath returns the RDAP path segment type ("domain" | "ip" | "autnum")
// for a classified input, or "" for invalid input.
func ObjectPath(c ClassifiedInput) string {
switch c.Kind {
case KindDomain:
return "domain"
case KindIPv4, KindIPv6:
return "ip"
case KindASN:
return "autnum"
}
return ""
}
// BuildRDAPURL builds an RDAP query URL, collapsing trailing slashes on base.
// Mirrors buildRdapUrl() in the TS lib.
func BuildRDAPURL(base string, c ClassifiedInput) string {
p := ObjectPath(c)
if p == "" {
return ""
}
return strings.TrimRight(base, "/") + "/" + p + "/" + c.Value
}
// --- RDAP response normalization ----------------------------------------
// Event is a single lifecycle event on a record (registration, expiration, …).
type Event struct {
Action string
Date string
Actor string
}
// Entity is a contact (registrar, registrant, abuse, …).
type Entity struct {
Roles []string
Handle string
Name string
Email string
}
// Nameserver is a delegated nameserver.
type Nameserver struct {
LDHName string
HostName string
}
// SecureDNS carries the DNSSEC signing state.
type SecureDNS struct {
DelegationSigned bool
KeyTag int
}
// Notice is a registry notice (terms, remarks, …).
type Notice struct {
Title string
Description string
}
// WhoisRecord is the normalized RDAP response. Mirrors WhoisRecord in the TS
// lib; the zero value is a safe "unknown" record.
type WhoisRecord struct {
ObjectClass string
LDHName string
UnicodeName string
Handle string
Status []string
Events []Event
Entities []Entity
Nameservers []Nameserver
SecureDNS *SecureDNS
Notices []Notice
}
func asMap(v any) map[string]any {
if m, ok := v.(map[string]any); ok {
return m
}
return nil
}
func asString(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func asStringSlice(v any) []string {
arr, ok := v.([]any)
if !ok {
return nil
}
out := []string{}
for _, x := range arr {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
}
// parseVCard pulls the fn (name) and first email out of a jCard/vCard array.
// Mirrors parseVcard() in the TS lib.
func parseVCard(v any) (name, email string) {
arr, ok := v.([]any)
if !ok || len(arr) < 2 {
return
}
props, ok := arr[1].([]any)
if !ok {
return
}
for _, p := range props {
prop, ok := p.([]any)
if !ok || len(prop) < 4 {
continue
}
kind := asString(prop[0])
val := asString(prop[3])
if kind == "fn" && name == "" && val != "" {
name = val
}
if kind == "email" && email == "" && val != "" {
email = val
}
}
return
}
// NormalizeRDAP best-effort normalizes an RDAP JSON response into a stable
// WhoisRecord. Never panics; the zero value is returned for any bad input.
// Mirrors normalizeRdapResponse() in src/lib/whois.ts.
func NormalizeRDAP(data []byte) WhoisRecord {
rec := WhoisRecord{ObjectClass: "unknown"}
var doc map[string]any
if json.Unmarshal(data, &doc) != nil || doc == nil {
return rec
}
if s := asString(doc["objectClassName"]); s != "" {
rec.ObjectClass = s
}
rec.LDHName = asString(doc["ldhName"])
rec.UnicodeName = asString(doc["unicodeName"])
rec.Handle = asString(doc["handle"])
rec.Status = asStringSlice(doc["status"])
if arr, ok := doc["events"].([]any); ok {
for _, e := range arr {
em := asMap(e)
if em == nil {
continue
}
action := asString(em["eventAction"])
if action == "" {
continue
}
rec.Events = append(rec.Events, Event{
Action: action,
Date: asString(em["eventDate"]),
Actor: asString(em["eventActor"]),
})
}
}
if arr, ok := doc["entities"].([]any); ok {
for _, en := range arr {
em := asMap(en)
if em == nil {
continue
}
name, email := parseVCard(em["vcardArray"])
rec.Entities = append(rec.Entities, Entity{
Roles: asStringSlice(em["roles"]),
Handle: asString(em["handle"]),
Name: name,
Email: email,
})
}
}
if arr, ok := doc["nameservers"].([]any); ok {
for _, ns := range arr {
nm := asMap(ns)
if nm == nil {
continue
}
rec.Nameservers = append(rec.Nameservers, Nameserver{
LDHName: asString(nm["ldhName"]),
HostName: asString(nm["hostName"]),
})
}
}
if sm := asMap(doc["secureDNS"]); sm != nil {
sd := &SecureDNS{}
if b, ok := sm["delegationSigned"].(bool); ok {
sd.DelegationSigned = b
}
if arr, ok := sm["dsData"].([]any); ok && len(arr) > 0 {
if dm := asMap(arr[0]); dm != nil {
if kt, ok := dm["keyTag"].(float64); ok {
sd.KeyTag = int(kt)
}
}
}
rec.SecureDNS = sd
}
if arr, ok := doc["notices"].([]any); ok {
for _, nt := range arr {
nm := asMap(nt)
if nm == nil {
continue
}
desc := ""
parts := asStringSlice(nm["description"])
if len(parts) > 0 {
desc = strings.Join(parts, " ")
}
rec.Notices = append(rec.Notices, Notice{
Title: asString(nm["title"]),
Description: desc,
})
}
}
return rec
}
var rdapObjectPathRe = regexp.MustCompile(`/(domain|ip|autnum|nameserver|entity)/[^/]+`)
// IsAllowlistedRdapUrl reports whether the relay may fetch rawURL: https
// only, no userinfo, host on the bootstrap allowlist (exact match or a
// label-boundary subdomain — a bare string suffix would admit attacker
// registrations), and an RDAP object-path shape. Mirrors
// isAllowlistedRdapUrl in src/lib/whois.ts (shared vectors).
func IsAllowlistedRdapUrl(rawURL string, boot []BootstrapEntry) bool {
u, err := url.Parse(rawURL)
if err != nil || u.Scheme != "https" || u.User != nil {
return false
}
if !rdapObjectPathRe.MatchString(u.Path) {
return false
}
host := strings.ToLower(u.Hostname())
for _, e := range boot {
for _, server := range e.URLs {
su, err := url.Parse(server)
if err != nil {
continue
}
allowed := strings.ToLower(su.Hostname())
if host == allowed || strings.HasSuffix(host, "."+allowed) {
return true
}
}
}
return false
}
// BuildRelayURL returns the relative relay URL for a validated RDAP URL.
// Mirrors buildRelayUrl in src/lib/whois.ts (shared vectors).
func BuildRelayURL(rdapURL string) string {
return "/api/rdap?url=" + url.QueryEscape(rdapURL)
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →