whoissdk/lookup.go

534 lines
14 KiB
Go
Raw Normal View History

2026-03-19 11:53:07 +08:00
package whois
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"b612.me/starnet"
)
// LookupMode controls how RDAP and WHOIS are used.
type LookupMode string
const (
LookupModeAutoPreferRDAP LookupMode = "auto-rdap"
LookupModeWHOISOnly LookupMode = "whois-only"
LookupModeRDAPOnly LookupMode = "rdap-only"
LookupModeBothPreferRDAP LookupMode = "both-rdap"
LookupModeBothPreferWHOIS LookupMode = "both-whois"
)
const (
LookupSourceRDAP = "rdap"
LookupSourceWHOIS = "whois"
LookupSourceMerged = "merged"
)
var ErrLookupClientNil = errors.New("whois/lookup: client is nil")
// LookupMeta records lookup path and fallback details.
type LookupMeta struct {
Mode LookupMode
Source string
RDAPEndpoint string
RDAPStatus int
RDAPError string
WHOISError string
WarningList []string
}
// Warnings returns copy of warning list.
func (m LookupMeta) Warnings() []string {
return copyStringSlice(m.WarningList)
}
// LookupOptions controls hybrid lookup behavior.
type LookupOptions struct {
Mode LookupMode
Strategy LookupStrategy
WhoisOptions QueryOptions
WhoisServers []string
RDAPClient *RDAPClient
RDAPBootstrap *RDAPBootstrapLoadOptions
RDAPRetry *RDAPRetryPolicy
Ops LookupOps
RDAPRequestOpts []starnet.RequestOpt
}
// LookupOpt mutates LookupOptions.
type LookupOpt func(*LookupOptions)
func defaultLookupOptions() LookupOptions {
return LookupOptions{
Mode: LookupModeAutoPreferRDAP,
Strategy: defaultLookupStrategy(),
WhoisOptions: QueryOptions{Level: QueryAuto},
Ops: LookupOps{},
}
}
// WithLookupMode sets hybrid lookup mode.
func WithLookupMode(mode LookupMode) LookupOpt {
return func(o *LookupOptions) { o.Mode = mode }
}
// WithLookupWhoisOptions sets whois query options for lookup.
func WithLookupWhoisOptions(opt QueryOptions) LookupOpt {
return func(o *LookupOptions) { o.WhoisOptions = opt }
}
// WithLookupWhoisServers sets explicit whois server candidates.
func WithLookupWhoisServers(servers ...string) LookupOpt {
return func(o *LookupOptions) { o.WhoisServers = append([]string(nil), servers...) }
}
// WithLookupRDAPClient sets custom RDAP client.
func WithLookupRDAPClient(c *RDAPClient) LookupOpt {
return func(o *LookupOptions) { o.RDAPClient = c }
}
// WithLookupRDAPRetryPolicy sets RDAP retry policy for lookup path.
func WithLookupRDAPRetryPolicy(policy RDAPRetryPolicy) LookupOpt {
return func(o *LookupOptions) {
p := normalizeRDAPRetryPolicy(policy)
o.RDAPRetry = &p
}
}
// WithLookupRDAPBootstrapLoadOptions sets layered bootstrap load options used
// when RDAP client is not provided explicitly.
func WithLookupRDAPBootstrapLoadOptions(opt RDAPBootstrapLoadOptions) LookupOpt {
return func(o *LookupOptions) {
clone := opt.Clone()
o.RDAPBootstrap = &clone
}
}
// WithLookupRDAPBootstrapCache sets layered bootstrap cache ttl/key.
// It only affects auto-created RDAP client path when RDAPClient is not set.
func WithLookupRDAPBootstrapCache(ttl time.Duration, cacheKey string) LookupOpt {
return func(o *LookupOptions) {
boot := ensureLookupRDAPBootstrap(o)
boot.CacheTTL = ttl
boot.CacheKey = strings.TrimSpace(cacheKey)
}
}
// WithLookupRDAPBootstrapLocalFiles appends local bootstrap overlay files.
func WithLookupRDAPBootstrapLocalFiles(paths ...string) LookupOpt {
return func(o *LookupOptions) {
boot := ensureLookupRDAPBootstrap(o)
boot.LocalFiles = append(boot.LocalFiles, paths...)
}
}
// WithLookupRDAPBootstrapRemoteRefresh enables/disables remote refresh.
func WithLookupRDAPBootstrapRemoteRefresh(enabled bool, remoteURL string) LookupOpt {
return func(o *LookupOptions) {
boot := ensureLookupRDAPBootstrap(o)
boot.RefreshRemote = enabled
boot.RemoteURL = strings.TrimSpace(remoteURL)
}
}
// WithLookupRDAPBootstrapIgnoreRemoteError controls whether remote refresh errors are ignored.
func WithLookupRDAPBootstrapIgnoreRemoteError(enabled bool) LookupOpt {
return func(o *LookupOptions) {
boot := ensureLookupRDAPBootstrap(o)
boot.IgnoreRemoteError = enabled
}
}
// WithLookupRDAPBootstrapAllowStaleOnError controls stale cache fallback on refresh failure.
func WithLookupRDAPBootstrapAllowStaleOnError(enabled bool) LookupOpt {
return func(o *LookupOptions) {
boot := ensureLookupRDAPBootstrap(o)
boot.AllowStaleOnError = enabled
}
}
// WithLookupRDAPRequestOpts sets RDAP-only starnet request options.
// Note: these options only apply to RDAP HTTP calls, not TCP WHOIS calls.
func WithLookupRDAPRequestOpts(opts ...starnet.RequestOpt) LookupOpt {
return func(o *LookupOptions) {
o.RDAPRequestOpts = append([]starnet.RequestOpt(nil), opts...)
}
}
// Lookup is a unified entry for RDAP/WHOIS hybrid query.
func (c *Client) Lookup(domain string, opts ...LookupOpt) (Result, LookupMeta, error) {
return c.LookupContext(context.Background(), domain, opts...)
}
// LookupContext is context-aware unified RDAP/WHOIS query.
func (c *Client) LookupContext(ctx context.Context, domain string, opts ...LookupOpt) (Result, LookupMeta, error) {
if c == nil {
return Result{}, LookupMeta{}, ErrLookupClientNil
}
normalizedInput, targetKind, err := normalizeLookupTargetInput(domain)
if err != nil {
return Result{}, LookupMeta{}, err
}
domain = normalizedInput
cfg := defaultLookupOptions()
for _, opt := range opts {
if opt != nil {
opt(&cfg)
}
}
cfg.Mode = normalizeLookupMode(cfg.Mode)
resolvedMode := cfg.resolveMode(domain)
if targetKind != lookupTargetDomain {
resolvedMode = cfg.Mode
}
meta := LookupMeta{Mode: resolvedMode}
switch resolvedMode {
case LookupModeWHOISOnly:
whoisRes, whoisErr := c.lookupWHOIS(ctx, domain, cfg)
meta.WHOISError = errString(whoisErr)
if whoisErr != nil {
return Result{}, meta, whoisErr
}
meta.Source = LookupSourceWHOIS
return whoisRes, meta, nil
case LookupModeRDAPOnly:
rdapRes, rdapResp, rdapErr := c.lookupRDAP(ctx, domain, targetKind, cfg)
fillRDAPMeta(&meta, rdapResp, rdapErr)
if rdapErr != nil {
if nf, ok := rdapNotFoundResult(domain, rdapErr); ok {
meta.Source = LookupSourceRDAP
meta.WarningList = append(meta.WarningList, "rdap returned 404 not found")
return nf, meta, nil
}
return Result{}, meta, rdapErr
}
meta.Source = LookupSourceRDAP
return rdapRes, meta, nil
case LookupModeBothPreferRDAP, LookupModeBothPreferWHOIS:
rdapRes, rdapResp, rdapErr := c.lookupRDAP(ctx, domain, targetKind, cfg)
whoisRes, whoisErr := c.lookupWHOIS(ctx, domain, cfg)
fillRDAPMeta(&meta, rdapResp, rdapErr)
meta.WHOISError = errString(whoisErr)
if rdapErr != nil && whoisErr != nil {
return Result{}, meta, combineLookupErrors(rdapErr, whoisErr)
}
if rdapErr == nil && whoisErr == nil {
meta.Source = LookupSourceMerged
preferRDAP := resolvedMode == LookupModeBothPreferRDAP
return mergeLookupResults(rdapRes, whoisRes, preferRDAP), meta, nil
}
if rdapErr == nil {
meta.Source = LookupSourceRDAP
meta.WarningList = append(meta.WarningList, "whois fallback failed")
return rdapRes, meta, nil
}
meta.Source = LookupSourceWHOIS
meta.WarningList = append(meta.WarningList, "rdap fallback failed")
return whoisRes, meta, nil
default:
fallthrough
case LookupModeAutoPreferRDAP:
rdapRes, rdapResp, rdapErr := c.lookupRDAP(ctx, domain, targetKind, cfg)
fillRDAPMeta(&meta, rdapResp, rdapErr)
if rdapErr == nil {
meta.Source = LookupSourceRDAP
return rdapRes, meta, nil
}
if nf, ok := rdapNotFoundResult(domain, rdapErr); ok {
meta.Source = LookupSourceRDAP
meta.WarningList = append(meta.WarningList, "rdap returned 404 not found")
return nf, meta, nil
}
whoisRes, whoisErr := c.lookupWHOIS(ctx, domain, cfg)
meta.WHOISError = errString(whoisErr)
if whoisErr != nil {
return Result{}, meta, combineLookupErrors(rdapErr, whoisErr)
}
meta.Source = LookupSourceWHOIS
meta.WarningList = append(meta.WarningList, "rdap lookup failed, switched to whois")
return whoisRes, meta, nil
}
}
func (c *Client) lookupWHOIS(ctx context.Context, domain string, cfg LookupOptions) (Result, error) {
whoisClient := c.cloneForLookup()
whoisTimeout := cfg.effectiveWHOISTimeout(c.timeout)
whoisClient.SetTimeout(whoisTimeout)
dialer, err := cfg.effectiveWHOISDialer(c.timeout)
if err != nil {
return Result{}, fmt.Errorf("prepare whois dialer: %w", err)
}
if dialer != nil {
whoisClient.SetDialer(dialer)
}
opt := cfg.WhoisOptions
if len(opt.OverrideServers) == 0 && opt.OverrideServer == "" && len(cfg.WhoisServers) > 0 {
opt.OverrideServers = normalizeServerList(cfg.WhoisServers)
if len(opt.OverrideServers) > 0 {
opt.OverrideServer = opt.OverrideServers[0]
}
}
return whoisClient.WhoisWithOptionsContext(ctx, domain, opt)
}
func ensureLookupRDAPBootstrap(o *LookupOptions) *RDAPBootstrapLoadOptions {
if o.RDAPBootstrap == nil {
o.RDAPBootstrap = &RDAPBootstrapLoadOptions{}
}
return o.RDAPBootstrap
}
func (c *Client) lookupRDAP(ctx context.Context, domain string, targetKind lookupTargetKind, cfg LookupOptions) (Result, *RDAPResponse, error) {
rdc := cfg.RDAPClient
var err error
if rdc == nil {
if cfg.RDAPBootstrap != nil {
rdc, err = NewRDAPClientWithLayeredBootstrap(ctx, *cfg.RDAPBootstrap)
} else {
rdc, err = NewRDAPClient()
}
if err != nil {
return Result{}, nil, err
}
}
if cfg.RDAPRetry != nil {
rdc.SetRetryPolicy(*cfg.RDAPRetry)
}
rdapReqOpts := cfg.effectiveRDAPRequestOpts()
rdapResp, err := rdc.Query(ctx, domain, rdapReqOpts...)
if err != nil {
return Result{}, nil, err
}
res, err := rdapResponseToResult(domain, rdapResp, targetKind)
if err != nil {
return Result{}, rdapResp, err
}
return res, rdapResp, nil
}
func (c *Client) cloneForLookup() *Client {
if c == nil {
return nil
}
out := &Client{
extCache: make(map[string]string),
dialer: c.dialer,
timeout: c.timeout,
elapsed: c.elapsed,
negativeCacheTTL: c.negativeCacheTTL,
}
c.mu.Lock()
for k, v := range c.extCache {
out.extCache[k] = v
}
c.mu.Unlock()
return out
}
func normalizeLookupMode(mode LookupMode) LookupMode {
switch LookupMode(strings.TrimSpace(strings.ToLower(string(mode)))) {
case LookupModeWHOISOnly:
return LookupModeWHOISOnly
case LookupModeRDAPOnly:
return LookupModeRDAPOnly
case LookupModeBothPreferRDAP:
return LookupModeBothPreferRDAP
case LookupModeBothPreferWHOIS:
return LookupModeBothPreferWHOIS
default:
return LookupModeAutoPreferRDAP
}
}
func fillRDAPMeta(meta *LookupMeta, resp *RDAPResponse, err error) {
if meta == nil {
return
}
if resp != nil {
meta.RDAPEndpoint = resp.Endpoint
meta.RDAPStatus = resp.StatusCode
}
if err != nil {
meta.RDAPError = err.Error()
var httpErr *RDAPHTTPError
if errors.As(err, &httpErr) {
meta.RDAPEndpoint = httpErr.Endpoint
meta.RDAPStatus = httpErr.StatusCode
}
}
}
func rdapNotFoundResult(domain string, err error) (Result, bool) {
var httpErr *RDAPHTTPError
if !errors.As(err, &httpErr) {
return Result{}, false
}
if httpErr.StatusCode != http.StatusNotFound {
return Result{}, false
}
out := Result{
exists: false,
domain: domain,
rawData: string(httpErr.Body),
}
out.meta = buildResultMeta(out, "rdap", httpErr.Endpoint)
out.meta.Charset = "utf-8"
return out, true
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func combineLookupErrors(rdapErr, whoisErr error) error {
if rdapErr == nil {
return whoisErr
}
if whoisErr == nil {
return rdapErr
}
return fmt.Errorf("rdap error: %w; whois error: %v", rdapErr, whoisErr)
}
func mergeLookupResults(rdapResult, whoisResult Result, preferRDAP bool) Result {
if preferRDAP {
return mergeResultWithFallback(rdapResult, whoisResult)
}
return mergeResultWithFallback(whoisResult, rdapResult)
}
func mergeResultWithFallback(primary, fallback Result) Result {
out := primary
if !out.exists && fallback.exists {
out.exists = true
}
if out.domain == "" {
out.domain = fallback.domain
}
if out.domainID == "" {
out.domainID = fallback.domainID
}
if out.registrar == "" {
out.registrar = fallback.registrar
}
if !out.hasRegisterDate && fallback.hasRegisterDate {
out.hasRegisterDate = true
out.registerDate = fallback.registerDate
}
if !out.hasUpdateDate && fallback.hasUpdateDate {
out.hasUpdateDate = true
out.updateDate = fallback.updateDate
}
if !out.hasExpireDate && fallback.hasExpireDate {
out.hasExpireDate = true
out.expireDate = fallback.expireDate
}
if out.dnssec == "" {
out.dnssec = fallback.dnssec
}
if out.whoisSer == "" {
out.whoisSer = fallback.whoisSer
}
if out.ianaID == "" {
out.ianaID = fallback.ianaID
}
out.statusRaw = appendUniqueStrings(out.statusRaw, fallback.statusRaw...)
out.nsServers = appendUniqueStrings(out.nsServers, fallback.nsServers...)
out.nsIps = appendUniqueStrings(out.nsIps, fallback.nsIps...)
out.registerInfo = mergePersonalInfo(out.registerInfo, fallback.registerInfo)
out.adminInfo = mergePersonalInfo(out.adminInfo, fallback.adminInfo)
out.techInfo = mergePersonalInfo(out.techInfo, fallback.techInfo)
out.rawData = mergeRawData(primary.rawData, fallback.rawData)
server := primary.meta.Server
if server == "" {
server = fallback.meta.Server
}
out.meta = buildResultMeta(out, LookupSourceMerged, server)
out.meta.Charset = combineCharset(primary.meta.Charset, fallback.meta.Charset)
return out
}
func mergeRawData(primary, fallback string) string {
primary = strings.TrimSpace(primary)
fallback = strings.TrimSpace(fallback)
switch {
case primary == "" && fallback == "":
return ""
case primary == "":
return fallback
case fallback == "":
return primary
case primary == fallback:
return primary
default:
return primary + "\n\n----- FALLBACK DATA -----\n\n" + fallback
}
}
func mergePersonalInfo(primary, fallback PersonalInfo) PersonalInfo {
out := primary
if out.FirstName == "" {
out.FirstName = fallback.FirstName
}
if out.LastName == "" {
out.LastName = fallback.LastName
}
if out.Name == "" {
out.Name = fallback.Name
}
if out.Org == "" {
out.Org = fallback.Org
}
if out.Fax == "" {
out.Fax = fallback.Fax
}
if out.FaxExt == "" {
out.FaxExt = fallback.FaxExt
}
if out.Addr == "" {
out.Addr = fallback.Addr
}
if out.City == "" {
out.City = fallback.City
}
if out.State == "" {
out.State = fallback.State
}
if out.Country == "" {
out.Country = fallback.Country
}
if out.Zip == "" {
out.Zip = fallback.Zip
}
if out.Phone == "" {
out.Phone = fallback.Phone
}
if out.PhoneExt == "" {
out.PhoneExt = fallback.PhoneExt
}
if out.Email == "" {
out.Email = fallback.Email
}
return out
}