534 lines
14 KiB
Go
534 lines
14 KiB
Go
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
|
|
}
|