Skip to content

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 →