526 lines
14 KiB
Go
526 lines
14 KiB
Go
package whois
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"b612.me/starnet"
|
|
)
|
|
|
|
var (
|
|
ErrRDAPClientNil = errors.New("whois/rdap: client is nil")
|
|
ErrRDAPServerNotFound = errors.New("whois/rdap: rdap server not found")
|
|
ErrRDAPDomainInvalid = errors.New("whois/rdap: domain is invalid")
|
|
ErrRDAPIPInvalid = errors.New("whois/rdap: ip is invalid")
|
|
ErrRDAPASNInvalid = errors.New("whois/rdap: asn is invalid")
|
|
ErrRDAPQueryInvalid = errors.New("whois/rdap: query target is invalid")
|
|
)
|
|
|
|
var (
|
|
defaultRDAPIPServers = []string{
|
|
"https://rdap.arin.net/registry/",
|
|
"https://rdap.db.ripe.net/",
|
|
"https://rdap.apnic.net/",
|
|
"https://rdap.lacnic.net/rdap/",
|
|
"https://rdap.afrinic.net/rdap/",
|
|
}
|
|
defaultRDAPASNServers = []string{
|
|
"https://rdap.arin.net/registry/",
|
|
"https://rdap.db.ripe.net/",
|
|
"https://rdap.apnic.net/",
|
|
"https://rdap.lacnic.net/rdap/",
|
|
"https://rdap.afrinic.net/rdap/",
|
|
}
|
|
)
|
|
|
|
// RDAPRetryPolicy controls retry behavior for transient RDAP failures.
|
|
// MaxAttempts is total attempts per endpoint including first try.
|
|
type RDAPRetryPolicy struct {
|
|
MaxAttempts int
|
|
BaseDelay time.Duration
|
|
MaxDelay time.Duration
|
|
RetryOn429 bool
|
|
RetryOn5xx bool
|
|
RetryOnNetwork bool
|
|
}
|
|
|
|
// RDAPHTTPError represents a non-2xx RDAP HTTP response.
|
|
type RDAPHTTPError struct {
|
|
Endpoint string
|
|
StatusCode int
|
|
Body []byte
|
|
RetryAfter time.Duration
|
|
}
|
|
|
|
func (e *RDAPHTTPError) Error() string {
|
|
if e == nil {
|
|
return "whois/rdap: unknown http error"
|
|
}
|
|
return fmt.Sprintf("whois/rdap: query %s status=%d body=%q",
|
|
e.Endpoint, e.StatusCode, truncateRDAPBody(e.Body, 240))
|
|
}
|
|
|
|
// RDAPResponse contains raw RDAP response data.
|
|
type RDAPResponse struct {
|
|
Domain string
|
|
Endpoint string
|
|
StatusCode int
|
|
Body []byte
|
|
}
|
|
|
|
// RDAPClient is an RDAP query client based on starnet.
|
|
type RDAPClient struct {
|
|
serverMap map[string][]string
|
|
defaultOpts []starnet.RequestOpt
|
|
retryPolicy RDAPRetryPolicy
|
|
ipServers []string
|
|
asnServers []string
|
|
}
|
|
|
|
// NewRDAPClient creates client from embedded bootstrap.
|
|
func NewRDAPClient(opts ...starnet.RequestOpt) (*RDAPClient, error) {
|
|
bootstrap, err := LoadEmbeddedRDAPBootstrap()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return NewRDAPClientWithBootstrap(bootstrap, opts...)
|
|
}
|
|
|
|
// NewRDAPClientWithLayeredBootstrap creates client from layered bootstrap sources.
|
|
func NewRDAPClientWithLayeredBootstrap(ctx context.Context, loadOpt RDAPBootstrapLoadOptions, opts ...starnet.RequestOpt) (*RDAPClient, error) {
|
|
var (
|
|
bootstrap *RDAPBootstrap
|
|
err error
|
|
)
|
|
if loadOpt.CacheTTL > 0 {
|
|
bootstrap, err = LoadRDAPBootstrapLayeredCached(ctx, loadOpt)
|
|
} else {
|
|
bootstrap, err = LoadRDAPBootstrapLayered(ctx, loadOpt)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return NewRDAPClientWithBootstrap(bootstrap, opts...)
|
|
}
|
|
|
|
// NewRDAPClientWithBootstrap creates client from custom bootstrap.
|
|
func NewRDAPClientWithBootstrap(bootstrap *RDAPBootstrap, opts ...starnet.RequestOpt) (*RDAPClient, error) {
|
|
if bootstrap == nil {
|
|
return nil, errors.New("whois/rdap: bootstrap is nil")
|
|
}
|
|
serverMap := bootstrap.ServerMap()
|
|
if len(serverMap) == 0 {
|
|
return nil, errors.New("whois/rdap: bootstrap map is empty")
|
|
}
|
|
outOpts := make([]starnet.RequestOpt, len(opts))
|
|
copy(outOpts, opts)
|
|
return &RDAPClient{
|
|
serverMap: serverMap,
|
|
defaultOpts: outOpts,
|
|
retryPolicy: defaultRDAPRetryPolicy(),
|
|
ipServers: copyStringSlice(defaultRDAPIPServers),
|
|
asnServers: copyStringSlice(defaultRDAPASNServers),
|
|
}, nil
|
|
}
|
|
|
|
func defaultRDAPRetryPolicy() RDAPRetryPolicy {
|
|
return RDAPRetryPolicy{
|
|
MaxAttempts: 2,
|
|
BaseDelay: 300 * time.Millisecond,
|
|
MaxDelay: 2 * time.Second,
|
|
RetryOn429: true,
|
|
RetryOn5xx: true,
|
|
RetryOnNetwork: true,
|
|
}
|
|
}
|
|
|
|
func normalizeRDAPRetryPolicy(p RDAPRetryPolicy) RDAPRetryPolicy {
|
|
if p.MaxAttempts <= 0 {
|
|
p.MaxAttempts = 1
|
|
}
|
|
if p.BaseDelay <= 0 {
|
|
p.BaseDelay = 200 * time.Millisecond
|
|
}
|
|
if p.MaxDelay <= 0 {
|
|
p.MaxDelay = 2 * time.Second
|
|
}
|
|
if p.MaxDelay < p.BaseDelay {
|
|
p.MaxDelay = p.BaseDelay
|
|
}
|
|
return p
|
|
}
|
|
|
|
// RetryPolicy returns current retry policy copy.
|
|
func (c *RDAPClient) RetryPolicy() RDAPRetryPolicy {
|
|
if c == nil {
|
|
return RDAPRetryPolicy{}
|
|
}
|
|
return c.retryPolicy
|
|
}
|
|
|
|
// SetRetryPolicy updates retry policy.
|
|
func (c *RDAPClient) SetRetryPolicy(p RDAPRetryPolicy) *RDAPClient {
|
|
if c == nil {
|
|
return c
|
|
}
|
|
c.retryPolicy = normalizeRDAPRetryPolicy(p)
|
|
return c
|
|
}
|
|
|
|
// SetIPServers overrides RDAP IP lookup server list.
|
|
func (c *RDAPClient) SetIPServers(servers ...string) *RDAPClient {
|
|
if c == nil {
|
|
return c
|
|
}
|
|
c.ipServers = normalizeRDAPServers(servers, defaultRDAPIPServers)
|
|
return c
|
|
}
|
|
|
|
// SetASNServers overrides RDAP ASN lookup server list.
|
|
func (c *RDAPClient) SetASNServers(servers ...string) *RDAPClient {
|
|
if c == nil {
|
|
return c
|
|
}
|
|
c.asnServers = normalizeRDAPServers(servers, defaultRDAPASNServers)
|
|
return c
|
|
}
|
|
|
|
// ServersForTLD returns RDAP servers for tld.
|
|
func (c *RDAPClient) ServersForTLD(tld string) []string {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
return copyStringSlice(c.serverMap[normalizeRDAPTLD(tld)])
|
|
}
|
|
|
|
// Query auto-detects query type and performs RDAP query.
|
|
func (c *RDAPClient) Query(ctx context.Context, query string, opts ...starnet.RequestOpt) (*RDAPResponse, error) {
|
|
if c == nil {
|
|
return nil, ErrRDAPClientNil
|
|
}
|
|
normalized, kind, err := normalizeLookupTargetInput(query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrRDAPQueryInvalid, err)
|
|
}
|
|
switch kind {
|
|
case lookupTargetDomain:
|
|
return c.QueryDomain(ctx, normalized, opts...)
|
|
case lookupTargetIP:
|
|
return c.QueryIP(ctx, normalized, opts...)
|
|
case lookupTargetASN:
|
|
return c.QueryASN(ctx, normalized, opts...)
|
|
default:
|
|
return nil, fmt.Errorf("%w: unsupported target=%q", ErrRDAPQueryInvalid, query)
|
|
}
|
|
}
|
|
|
|
// QueryDomain queries RDAP for domain and returns raw response.
|
|
func (c *RDAPClient) QueryDomain(ctx context.Context, domain string, opts ...starnet.RequestOpt) (*RDAPResponse, error) {
|
|
if c == nil {
|
|
return nil, ErrRDAPClientNil
|
|
}
|
|
normalizedDomain, tld, err := normalizeRDAPDomain(domain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
servers := c.ServersForTLD(tld)
|
|
if len(servers) == 0 {
|
|
return nil, fmt.Errorf("%w: tld=%s", ErrRDAPServerNotFound, tld)
|
|
}
|
|
return c.queryByServers(ctx, normalizedDomain, "domain", normalizedDomain, servers, opts...)
|
|
}
|
|
|
|
// QueryIP queries RDAP for IP.
|
|
func (c *RDAPClient) QueryIP(ctx context.Context, ip string, opts ...starnet.RequestOpt) (*RDAPResponse, error) {
|
|
if c == nil {
|
|
return nil, ErrRDAPClientNil
|
|
}
|
|
addr := net.ParseIP(strings.TrimSpace(ip))
|
|
if addr == nil {
|
|
return nil, fmt.Errorf("%w: ip=%q", ErrRDAPIPInvalid, ip)
|
|
}
|
|
normalized := addr.String()
|
|
servers := normalizeRDAPServers(c.ipServers, defaultRDAPIPServers)
|
|
if len(servers) == 0 {
|
|
return nil, fmt.Errorf("%w: ip", ErrRDAPServerNotFound)
|
|
}
|
|
return c.queryByServers(ctx, normalized, "ip", normalized, servers, opts...)
|
|
}
|
|
|
|
// QueryASN queries RDAP for ASN.
|
|
func (c *RDAPClient) QueryASN(ctx context.Context, asn string, opts ...starnet.RequestOpt) (*RDAPResponse, error) {
|
|
if c == nil {
|
|
return nil, ErrRDAPClientNil
|
|
}
|
|
num, err := normalizeASNNumeric(asn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrRDAPASNInvalid, err)
|
|
}
|
|
label := "AS" + num
|
|
servers := normalizeRDAPServers(c.asnServers, defaultRDAPASNServers)
|
|
if len(servers) == 0 {
|
|
return nil, fmt.Errorf("%w: asn", ErrRDAPServerNotFound)
|
|
}
|
|
return c.queryByServers(ctx, label, "autnum", num, servers, opts...)
|
|
}
|
|
|
|
// QueryDomainJSON queries RDAP and unmarshals JSON body into out.
|
|
func (c *RDAPClient) QueryDomainJSON(ctx context.Context, domain string, out interface{}, opts ...starnet.RequestOpt) (*RDAPResponse, error) {
|
|
if out == nil {
|
|
return nil, errors.New("whois/rdap: output object is nil")
|
|
}
|
|
resp, err := c.QueryDomain(ctx, domain, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(resp.Body, out); err != nil {
|
|
return nil, fmt.Errorf("whois/rdap: decode json failed: %w", err)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (c *RDAPClient) queryByServers(ctx context.Context, query, resource, resourceValue string, servers []string, opts ...starnet.RequestOpt) (*RDAPResponse, error) {
|
|
reqOpts := c.buildRequestOptions(opts...)
|
|
var lastErr error
|
|
for _, server := range normalizeRDAPServers(servers, nil) {
|
|
endpoint, err := buildRDAPResourceEndpoint(server, resource, resourceValue)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
resp, err := c.queryEndpointWithRetry(ctx, query, endpoint, reqOpts)
|
|
if err == nil {
|
|
return resp, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("%w: resource=%s query=%s", ErrRDAPServerNotFound, resource, query)
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
func (c *RDAPClient) queryEndpointWithRetry(ctx context.Context, query, endpoint string, reqOpts []starnet.RequestOpt) (*RDAPResponse, error) {
|
|
policy := normalizeRDAPRetryPolicy(c.retryPolicy)
|
|
var lastErr error
|
|
for attempt := 1; attempt <= policy.MaxAttempts; attempt++ {
|
|
resp, err := c.queryEndpointOnce(ctx, query, endpoint, reqOpts)
|
|
if err == nil {
|
|
return resp, nil
|
|
}
|
|
lastErr = err
|
|
shouldRetry, delay := shouldRetryRDAPError(policy, err)
|
|
if !shouldRetry || attempt >= policy.MaxAttempts {
|
|
break
|
|
}
|
|
if !sleepWithContext(ctx, retryDelayForAttempt(policy, attempt, delay)) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
break
|
|
}
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
func (c *RDAPClient) queryEndpointOnce(ctx context.Context, query, endpoint string, reqOpts []starnet.RequestOpt) (*RDAPResponse, error) {
|
|
resp, err := starnet.NewSimpleRequestWithContext(ctx, endpoint, http.MethodGet, reqOpts...).Do()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("whois/rdap: query %s failed: %w", endpoint, err)
|
|
}
|
|
|
|
body, readErr := resp.Body().Bytes()
|
|
statusCode := resp.StatusCode
|
|
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
|
|
_ = resp.Close()
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("whois/rdap: read %s body failed: %w", endpoint, readErr)
|
|
}
|
|
if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices {
|
|
return &RDAPResponse{
|
|
Domain: query,
|
|
Endpoint: endpoint,
|
|
StatusCode: statusCode,
|
|
Body: body,
|
|
}, nil
|
|
}
|
|
|
|
return nil, &RDAPHTTPError{
|
|
Endpoint: endpoint,
|
|
StatusCode: statusCode,
|
|
Body: append([]byte(nil), body...),
|
|
RetryAfter: retryAfter,
|
|
}
|
|
}
|
|
|
|
func shouldRetryRDAPError(policy RDAPRetryPolicy, err error) (bool, time.Duration) {
|
|
if err == nil {
|
|
return false, 0
|
|
}
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return false, 0
|
|
}
|
|
var httpErr *RDAPHTTPError
|
|
if errors.As(err, &httpErr) {
|
|
switch {
|
|
case httpErr.StatusCode == http.StatusTooManyRequests && policy.RetryOn429:
|
|
return true, httpErr.RetryAfter
|
|
case httpErr.StatusCode >= 500 && httpErr.StatusCode <= 599 && policy.RetryOn5xx:
|
|
return true, httpErr.RetryAfter
|
|
default:
|
|
return false, 0
|
|
}
|
|
}
|
|
return policy.RetryOnNetwork, 0
|
|
}
|
|
|
|
func retryDelayForAttempt(policy RDAPRetryPolicy, attempt int, retryAfter time.Duration) time.Duration {
|
|
delay := policy.BaseDelay
|
|
for i := 1; i < attempt; i++ {
|
|
delay *= 2
|
|
if delay >= policy.MaxDelay {
|
|
delay = policy.MaxDelay
|
|
break
|
|
}
|
|
}
|
|
if retryAfter > delay {
|
|
delay = retryAfter
|
|
}
|
|
if delay < 0 {
|
|
return 0
|
|
}
|
|
if delay > policy.MaxDelay {
|
|
return policy.MaxDelay
|
|
}
|
|
return delay
|
|
}
|
|
|
|
func sleepWithContext(ctx context.Context, d time.Duration) bool {
|
|
if d <= 0 {
|
|
return true
|
|
}
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-timer.C:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func parseRetryAfter(raw string) time.Duration {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
if sec, err := strconv.Atoi(raw); err == nil && sec > 0 {
|
|
return time.Duration(sec) * time.Second
|
|
}
|
|
if t, err := http.ParseTime(raw); err == nil {
|
|
d := time.Until(t)
|
|
if d > 0 {
|
|
return d
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func normalizeRDAPServers(servers []string, fallback []string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(servers))
|
|
appendUnique := func(v string) {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return
|
|
}
|
|
k := strings.ToLower(v)
|
|
if _, ok := seen[k]; ok {
|
|
return
|
|
}
|
|
seen[k] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
for _, v := range servers {
|
|
appendUnique(v)
|
|
}
|
|
if len(out) == 0 {
|
|
for _, v := range fallback {
|
|
appendUnique(v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (c *RDAPClient) buildRequestOptions(extra ...starnet.RequestOpt) []starnet.RequestOpt {
|
|
opts := []starnet.RequestOpt{
|
|
starnet.WithHeader("Accept", "application/rdap+json, application/json"),
|
|
starnet.WithUserAgent("b612-whois-rdap/1.0"),
|
|
starnet.WithTimeout(15 * time.Second),
|
|
starnet.WithAutoFetch(true),
|
|
}
|
|
opts = append(opts, c.defaultOpts...)
|
|
opts = append(opts, extra...)
|
|
return opts
|
|
}
|
|
|
|
func normalizeRDAPDomain(domain string) (string, string, error) {
|
|
domain, err := normalizeLookupDomainInput(domain)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("%w: %v", ErrRDAPDomainInvalid, err)
|
|
}
|
|
tld := normalizeRDAPTLD(getExtension(domain))
|
|
if tld == "" {
|
|
return "", "", fmt.Errorf("%w: cannot resolve tld for domain=%q", ErrRDAPDomainInvalid, domain)
|
|
}
|
|
return domain, tld, nil
|
|
}
|
|
|
|
func buildRDAPDomainEndpoint(baseURL, domain string) (string, error) {
|
|
return buildRDAPResourceEndpoint(baseURL, "domain", domain)
|
|
}
|
|
|
|
func buildRDAPResourceEndpoint(baseURL, resource, key string) (string, error) {
|
|
baseURL = strings.TrimSpace(baseURL)
|
|
if baseURL == "" {
|
|
return "", errors.New("whois/rdap: rdap base url is empty")
|
|
}
|
|
resource = strings.Trim(strings.TrimSpace(resource), "/")
|
|
key = strings.TrimSpace(key)
|
|
if resource == "" || key == "" {
|
|
return "", errors.New("whois/rdap: invalid rdap resource endpoint")
|
|
}
|
|
|
|
u, err := url.Parse(baseURL)
|
|
if err != nil || (u.Host == "" && u.Scheme == "") {
|
|
u, err = url.Parse("https://" + baseURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("whois/rdap: invalid rdap base url=%q: %w", baseURL, err)
|
|
}
|
|
}
|
|
if u.Scheme == "" {
|
|
u.Scheme = "https"
|
|
}
|
|
|
|
basePath := strings.TrimRight(u.Path, "/")
|
|
u.Path = basePath + "/" + resource + "/" + url.PathEscape(key)
|
|
u.RawQuery = ""
|
|
u.Fragment = ""
|
|
return u.String(), nil
|
|
}
|
|
|
|
func truncateRDAPBody(data []byte, max int) string {
|
|
if max <= 0 || len(data) <= max {
|
|
return string(data)
|
|
}
|
|
return string(data[:max]) + "...(truncated)"
|
|
}
|