diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3070f9 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# sdk/whois + +`b612.me/sdk/whois` 是一个面向 Go 的域名信息查询 SDK,提供传统 WHOIS 与 RDAP 的统一查询入口。 + +## 功能概览 + +- 统一查询入口:`Lookup` / `LookupContext` +- 查询模式:`whois-only`、`rdap-only`、`auto-rdap`、`both-rdap`、`both-whois` +- 域名与 IP/ASN RDAP 查询 +- RDAP Bootstrap(内置 + 本地覆盖 + 远程刷新) +- 结构化解析结果 + 原始返回数据 +- 统一代理配置(RDAP + WHOIS)与协议级覆盖 +- 上下文超时、重试、错误分类与质量元信息 + +## 安装 + +```bash +go get b612.me/sdk/whois +``` + +## 快速开始 + +```go +package main + +import ( + "fmt" + + "b612.me/sdk/whois" +) + +func main() { + c := whois.NewClient() + + res, meta, err := c.Lookup("example.com", + whois.WithLookupMode(whois.LookupModeAutoPreferRDAP), + ) + if err != nil { + panic(err) + } + + fmt.Println("source:", meta.Source) + fmt.Println("exists:", res.Exists()) + fmt.Println("domain:", res.Domain()) + fmt.Println("registrar:", res.Registrar()) +} +``` + +## 查询模式说明 + +- `LookupModeWHOISOnly`:仅 WHOIS +- `LookupModeRDAPOnly`:仅 RDAP +- `LookupModeAutoPreferRDAP`:先 RDAP,失败时回退 WHOIS +- `LookupModeBothPreferRDAP`:都查,合并结果,优先 RDAP 字段 +- `LookupModeBothPreferWHOIS`:都查,合并结果,优先 WHOIS 字段 + +## 统一代理配置 + +从当前版本开始,支持统一代理配置: + +- `WithLookupProxy(...)`:设置 RDAP + WHOIS 共享代理 +- `WithLookupRDAPProxy(...)`:仅覆盖 RDAP 代理 +- `WithLookupWHOISProxy(...)`:仅覆盖 WHOIS 代理 +- `WithLookupWHOISDialer(...)`:直接注入 WHOIS TCP Dialer(优先级最高) + +优先级规则: + +- RDAP:`RDAP.Proxy > Common.Proxy` +- WHOIS:`WHOIS.Dialer > WHOIS.Proxy > Common.Proxy` + +注意: + +- WHOIS 是 TCP 协议,推荐/默认使用 `socks5://` 代理 +- 仅写 `host:port` 时会自动按 `socks5://host:port` 处理 +- WHOIS 不支持 `http://` 代理;若传入会快速返回错误 + +示例: + +```go +res, meta, err := c.Lookup("example.com", + whois.WithLookupMode(whois.LookupModeAutoPreferRDAP), + whois.WithLookupProxy("socks5://127.0.0.1:1080"), +) +_ = res +_ = meta +_ = err +``` + +## RDAP Bootstrap 更新 + +可使用 SDK 提供的方法刷新 RDAP Bootstrap 数据(示例): + +```go +_, err := whois.UpdateDefaultRDAPBootstrapFileFromURL(ctx, "rdap_dns.json") +if err != nil { + // handle error +} +``` + +## 测试 + +```bash +go test ./... +``` + +## License + +本项目使用 Apache License 2.0,详见 `LICENSE` 文件。 diff --git a/charset.go b/charset.go new file mode 100644 index 0000000..611b16d --- /dev/null +++ b/charset.go @@ -0,0 +1,78 @@ +package whois + +import ( + "bytes" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/encoding" + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/encoding/traditionalchinese" +) + +func decodeWhoisPayload(raw []byte) (string, string) { + trimmed := bytes.Trim(raw, "\x00") + if len(trimmed) == 0 { + return "", "unknown" + } + + if bytes.HasPrefix(trimmed, []byte{0xEF, 0xBB, 0xBF}) { + trimmed = trimmed[3:] + } + if utf8.Valid(trimmed) { + return string(trimmed), "utf-8" + } + + type candidate struct { + name string + enc encoding.Encoding + } + candidates := []candidate{ + {name: "gb18030", enc: simplifiedchinese.GB18030}, + {name: "gbk", enc: simplifiedchinese.GBK}, + {name: "big5", enc: traditionalchinese.Big5}, + } + + bestText := string(trimmed) + bestName := "binary" + bestScore := -1 << 30 + + for _, c := range candidates { + decoded, err := c.enc.NewDecoder().Bytes(trimmed) + if err != nil { + continue + } + text := string(decoded) + score := scoreDecodedText(text) + if score > bestScore { + bestScore = score + bestText = text + bestName = c.name + } + } + return bestText, bestName +} + +func scoreDecodedText(s string) int { + if strings.TrimSpace(s) == "" { + return -10000 + } + + score := 0 + for _, r := range s { + switch { + case r == utf8.RuneError: + score -= 80 + case r == '\n' || r == '\r' || r == '\t': + score += 2 + case unicode.IsLetter(r) || unicode.IsDigit(r): + score += 3 + case unicode.IsPrint(r): + score += 1 + default: + score -= 2 + } + } + return score +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..6ff4b5b --- /dev/null +++ b/client.go @@ -0,0 +1,732 @@ +package whois + +import ( + "context" + "fmt" + "io" + "net" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/net/proxy" +) + +const ( + defWhoisServer = "whois.iana.org" + defWhoisPort = "43" + defTimeout = 30 * time.Second + defReferralMaxDepth = 1 + asnPrefix = "AS" +) + +var DefaultClient = NewClient() +var defaultWhoisMap = map[string]string{} +var whoisNegativeCache sync.Map + +type Client struct { + extCache map[string]string + mu sync.Mutex + dialer proxy.Dialer + timeout time.Duration + elapsed time.Duration + negativeCacheTTL time.Duration +} + +type rawQueryResult struct { + Data string + Server string + Charset string +} + +type rawWhoisStep struct { + Data string + Server string + Charset string +} + +type negativeCacheEntry struct { + Result *Result + Domain string + Code ErrorCode + Server string + Reason string + ExpireAt time.Time +} + +func NewClient() *Client { + return &Client{ + dialer: &net.Dialer{Timeout: defTimeout}, + extCache: make(map[string]string), + timeout: defTimeout, + negativeCacheTTL: 0, + } +} + +func (c *Client) SetDialer(d proxy.Dialer) *Client { c.dialer = d; return c } +func (c *Client) SetTimeout(t time.Duration) *Client { c.timeout = t; return c } +func (c *Client) SetNegativeCacheTTL(ttl time.Duration) *Client { + c.negativeCacheTTL = ttl + return c +} + +func (c *Client) ClearNegativeCache(keys ...string) { + clearNegativeCache(keys...) +} + +func (c *Client) Whois(domain string, servers ...string) (Result, error) { + return c.WhoisContext(context.Background(), domain, servers...) +} + +func (c *Client) WhoisContext(ctx context.Context, domain string, servers ...string) (Result, error) { + opt := QueryOptions{Level: QueryAuto, ReferralMaxDepth: defReferralMaxDepth} + if len(servers) > 0 { + opt.OverrideServers = normalizeServerList(servers) + if len(opt.OverrideServers) > 0 { + opt.OverrideServer = opt.OverrideServers[0] + } + } + return c.WhoisWithOptionsContext(ctx, domain, opt) +} + +func (c *Client) WhoisWithOptions(domain string, opt QueryOptions) (Result, error) { + return c.WhoisWithOptionsContext(context.Background(), domain, opt) +} + +func (c *Client) WhoisWithOptionsContext(ctx context.Context, domain string, opt QueryOptions) (Result, error) { + normalizedDomain, err := normalizeQueryDomainInput(domain) + if err != nil { + return Result{}, err + } + domain = normalizedDomain + + cacheTTL := c.effectiveNegativeCacheTTL(opt) + cacheKey := "" + if cacheTTL > 0 && !hasOverrideWhoisServers(opt) { + cacheKey = negativeCacheKey(domain, opt) + if cachedResult, cachedErr, ok := loadNegativeCache(cacheKey, time.Now()); ok { + if cachedErr != nil { + return Result{}, cachedErr + } + return cachedResult, nil + } + } + + raw, err := c.queryRawWithLevelContext(ctx, domain, opt) + if err != nil { + if cacheKey != "" && IsCode(err, ErrorCodeNoWhoisServer) { + storeNegativeCache(cacheKey, cacheTTL, negativeCacheEntry{ + Domain: domain, + Code: ErrorCodeNoWhoisServer, + Server: "", + Reason: "registry whois server not found", + ExpireAt: time.Now().Add(cacheTTL), + }) + } + return Result{}, err + } + out, err := parse(domain, raw.Data) + if err != nil { + return Result{}, err + } + out.meta = buildResultMeta(out, "whois", raw.Server) + out.meta.Charset = raw.Charset + if cacheKey != "" && out.meta.ReasonCode == ErrorCodeNotFound { + resultCopy := cloneResult(out) + storeNegativeCache(cacheKey, cacheTTL, negativeCacheEntry{ + Result: &resultCopy, + Domain: domain, + Code: ErrorCodeNotFound, + Server: out.meta.Server, + Reason: out.meta.Reason, + ExpireAt: time.Now().Add(cacheTTL), + }) + } + return out, nil +} + +func (c *Client) queryRawWithLevel(domain string, opt QueryOptions) (string, error) { + out, err := c.queryRawWithLevelContext(context.Background(), domain, opt) + if err != nil { + return "", err + } + return out.Data, nil +} + +func (c *Client) queryRawWithLevelContext(ctx context.Context, domain string, opt QueryOptions) (rawQueryResult, error) { + if ctx == nil { + ctx = context.Background() + } + normalizedDomain, err := normalizeQueryDomainInput(domain) + if err != nil { + return rawQueryResult{}, err + } + domain = normalizedDomain + + overrideServers := normalizeServerList(opt.OverrideServers) + if len(overrideServers) == 0 && opt.OverrideServer != "" { + overrideServers = []string{opt.OverrideServer} + } + if len(overrideServers) > 0 { + return c.rawQueryFallbackContext(ctx, domain, overrideServers) + } + + registryServer, registryPort, err := c.resolveRegistryServerContext(ctx, domain) + if err != nil { + return rawQueryResult{}, err + } + referralDepth := effectiveReferralMaxDepth(opt.ReferralMaxDepth) + chain, err := c.queryWhoisChainContext(ctx, domain, registryServer, registryPort, referralDepth) + if err != nil { + return rawQueryResult{}, err + } + if len(chain) == 0 { + return rawQueryResult{}, newWhoisError(ErrorCodeEmptyResponse, domain, net.JoinHostPort(registryServer, registryPort), "empty whois response", ErrEmptyResponse) + } + + switch opt.Level { + case QueryRegistryOnly: + first := chain[0] + return rawQueryResult{Data: first.Data, Server: first.Server, Charset: first.Charset}, nil + case QueryRegistrarOnly: + if len(chain) == 1 { + first := chain[0] + return rawQueryResult{Data: first.Data, Server: first.Server, Charset: first.Charset}, nil + } + last := chain[len(chain)-1] + return rawQueryResult{Data: last.Data, Server: last.Server, Charset: chainCombinedCharset(chain)}, nil + case QueryBoth: + if len(chain) == 1 { + first := chain[0] + return rawQueryResult{Data: first.Data, Server: first.Server, Charset: first.Charset}, nil + } + return rawQueryResult{ + Data: formatWhoisChainData(chain, true), + Server: chainServerPath(chain), + Charset: chainCombinedCharset(chain), + }, nil + case QueryAuto: + fallthrough + default: + if len(chain) == 1 { + first := chain[0] + return rawQueryResult{Data: first.Data, Server: first.Server, Charset: first.Charset}, nil + } + return rawQueryResult{ + Data: formatWhoisChainData(chain, false), + Server: chainServerPath(chain), + Charset: chainCombinedCharset(chain), + }, nil + } +} + +func (c *Client) resolveRegistryServer(domain string) (string, string, error) { + return c.resolveRegistryServerContext(context.Background(), domain) +} + +func (c *Client) resolveRegistryServerContext(ctx context.Context, domain string) (string, string, error) { + if v, ok := defaultWhoisMap[getExtension(domain)]; ok { + if host, port := normalizeWhoisServer(v); host != "" { + return host, port, nil + } + } + ext := getExtension(domain) + + c.mu.Lock() + cache, ok := c.extCache[ext] + c.mu.Unlock() + if ok { + if host, port, err := net.SplitHostPort(cache); err == nil { + return host, port, nil + } + if host, port := normalizeWhoisServer(cache); host != "" { + return host, port, nil + } + } + + result, _, err := c.rawQueryContext(ctx, ext, defWhoisServer, defWhoisPort) + if err != nil { + return "", "", fmt.Errorf("whois: query for whois server failed: %w", err) + } + server, port := getServer(result) + if server == "" { + return "", "", newWhoisError(ErrorCodeNoWhoisServer, domain, net.JoinHostPort(defWhoisServer, defWhoisPort), "registry whois server not found", ErrNoWhoisServer) + } + c.mu.Lock() + c.extCache[ext] = net.JoinHostPort(server, port) + c.mu.Unlock() + return server, port, nil +} + +func (c *Client) rawQueryFallback(domain string, servers []string) (string, error) { + out, err := c.rawQueryFallbackContext(context.Background(), domain, servers) + if err != nil { + return "", err + } + return out.Data, nil +} + +func (c *Client) rawQuery(domain, server, port string) (string, error) { + data, _, err := c.rawQueryContext(context.Background(), domain, server, port) + return data, err +} + +func (c *Client) rawQueryFallbackContext(ctx context.Context, domain string, servers []string) (rawQueryResult, error) { + if ctx == nil { + ctx = context.Background() + } + var lastErr error + for _, server := range normalizeServerList(servers) { + host, port := normalizeWhoisServer(server) + if host == "" { + continue + } + data, charset, err := c.rawQueryContext(ctx, domain, host, port) + if err == nil { + return rawQueryResult{ + Data: data, + Server: net.JoinHostPort(host, port), + Charset: charset, + }, nil + } + lastErr = fmt.Errorf("%s:%s: %w", host, port, err) + } + if lastErr == nil { + return rawQueryResult{}, newWhoisError(ErrorCodeNoWhoisServer, domain, "", "no valid whois server", ErrNoWhoisServer) + } + return rawQueryResult{}, fmt.Errorf("whois: all whois servers failed: %w", lastErr) +} + +func (c *Client) rawQueryContext(ctx context.Context, domain, server, port string) (string, string, error) { + if ctx == nil { + ctx = context.Background() + } + start := time.Now() + queryText := domain + if server == "whois.arin.net" { + if IsASN(domain) { + queryText = "a + " + domain + } else { + queryText = "n + " + domain + } + } + serverAddr := net.JoinHostPort(server, port) + conn, err := c.dialContext(ctx, "tcp", serverAddr) + if err != nil { + if ctx.Err() != nil { + return "", "", ctx.Err() + } + return "", "", wrapNetworkError(domain, serverAddr, err, "connect failed") + } + defer conn.Close() + + cancelWatchDone := make(chan struct{}) + defer close(cancelWatchDone) + go func() { + select { + case <-ctx.Done(): + _ = conn.SetDeadline(time.Now()) + _ = conn.Close() + case <-cancelWatchDone: + } + }() + + if err := conn.SetWriteDeadline(c.effectiveDeadline(ctx)); err != nil { + return "", "", wrapNetworkError(domain, serverAddr, err, "set write deadline failed") + } + if _, err = conn.Write([]byte(queryText + "\r\n")); err != nil { + if ctx.Err() != nil { + return "", "", ctx.Err() + } + return "", "", wrapNetworkError(domain, serverAddr, err, "write failed") + } + if err := conn.SetReadDeadline(c.effectiveDeadline(ctx)); err != nil { + return "", "", wrapNetworkError(domain, serverAddr, err, "set read deadline failed") + } + buf, err := io.ReadAll(conn) + if err != nil { + if ctx.Err() != nil { + return "", "", ctx.Err() + } + return "", "", wrapNetworkError(domain, serverAddr, err, "read failed") + } + + decoded, charset := decodeWhoisPayload(buf) + data := strings.TrimSpace(decoded) + if data == "" { + return "", charset, newWhoisError(ErrorCodeEmptyResponse, domain, serverAddr, "empty whois response", ErrEmptyResponse) + } + data = fmt.Sprintf("%s\n\n%% Query time: %d msec\n%% WHEN: %s\n", + data, time.Since(start).Milliseconds(), start.Format("Mon Jan 02 15:04:05 MST 2006")) + return data, charset, nil +} + +func (c *Client) dialContext(ctx context.Context, network, address string) (net.Conn, error) { + d := c.dialer + if d == nil { + d = &net.Dialer{Timeout: c.timeout} + } + if cd, ok := d.(interface { + DialContext(context.Context, string, string) (net.Conn, error) + }); ok { + return cd.DialContext(ctx, network, address) + } + + type dialRet struct { + conn net.Conn + err error + } + ch := make(chan dialRet) + abandon := make(chan struct{}) + go func() { + conn, err := d.Dial(network, address) + select { + case <-abandon: + if conn != nil { + _ = conn.Close() + } + case ch <- dialRet{conn: conn, err: err}: + } + }() + + select { + case <-ctx.Done(): + close(abandon) + return nil, ctx.Err() + case ret := <-ch: + return ret.conn, ret.err + } +} + +func (c *Client) effectiveDeadline(ctx context.Context) time.Time { + timeout := c.timeout + if timeout <= 0 { + timeout = defTimeout + } + deadline := time.Now().Add(timeout) + if d, ok := ctx.Deadline(); ok && d.Before(deadline) { + return d + } + return deadline +} + +func combineCharset(a, b string) string { + a = strings.TrimSpace(strings.ToLower(a)) + b = strings.TrimSpace(strings.ToLower(b)) + switch { + case a == "" && b == "": + return "" + case a == "": + return b + case b == "": + return a + case a == b: + return a + default: + return "mixed" + } +} + +func getExtension(domain string) string { + ext := domain + if net.ParseIP(domain) == nil { + parts := strings.Split(domain, ".") + ext = parts[len(parts)-1] + } + if strings.Contains(ext, "/") { + ext = strings.Split(ext, "/")[0] + } + return ext +} + +func getServer(data string) (string, string) { + lines := strings.Split(data, "\n") + for _, raw := range lines { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + key, val, ok := splitKV(line) + if !ok { + continue + } + switch strings.ToLower(strings.TrimSpace(key)) { + case "registrar whois server", "whois server", "whois", "referralserver", "refer": + if host, port := normalizeWhoisServer(val); host != "" { + return host, port + } + } + } + return "", "" +} + +func normalizeServerList(servers []string) []string { + out := make([]string, 0, len(servers)) + seen := make(map[string]struct{}, len(servers)) + for _, s := range servers { + s = strings.TrimSpace(s) + if s == "" { + continue + } + k := strings.ToLower(s) + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, s) + } + return out +} + +func normalizeWhoisServer(raw string) (string, string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "" + } + raw = strings.Trim(raw, "<>") + if fields := strings.Fields(raw); len(fields) > 0 { + raw = fields[0] + } + + if strings.Contains(raw, "://") { + if u, err := url.Parse(raw); err == nil && u.Host != "" { + raw = u.Host + } + } + + raw = strings.TrimPrefix(raw, "whois:") + raw = strings.TrimPrefix(raw, "rwhois:") + raw = strings.Trim(raw, "/") + if raw == "" { + return "", "" + } + + if host, port, err := net.SplitHostPort(raw); err == nil { + host = strings.TrimSpace(strings.ToLower(host)) + port = strings.TrimSpace(port) + if host != "" && port != "" { + return host, port + } + } + + if strings.Count(raw, ":") == 1 { + v := strings.SplitN(raw, ":", 2) + host := strings.TrimSpace(strings.ToLower(v[0])) + port := strings.TrimSpace(v[1]) + if host != "" && port != "" { + if _, err := strconv.Atoi(port); err == nil { + return host, port + } + } + } + + return strings.ToLower(raw), defWhoisPort +} + +func effectiveReferralMaxDepth(v int) int { + if v < 0 { + return 0 + } + if v == 0 { + return defReferralMaxDepth + } + if v > 16 { + return 16 + } + return v +} + +func (c *Client) queryWhoisChainContext(ctx context.Context, domain, server, port string, referralMaxDepth int) ([]rawWhoisStep, error) { + steps := make([]rawWhoisStep, 0, 2) + visited := make(map[string]struct{}, referralMaxDepth+1) + host := server + p := port + + for { + addr := strings.ToLower(net.JoinHostPort(host, p)) + if _, seen := visited[addr]; seen { + break + } + visited[addr] = struct{}{} + + data, charset, err := c.rawQueryContext(ctx, domain, host, p) + if err != nil { + if len(steps) == 0 { + return nil, err + } + return steps, nil + } + steps = append(steps, rawWhoisStep{ + Data: data, + Server: net.JoinHostPort(host, p), + Charset: charset, + }) + + if len(steps)-1 >= referralMaxDepth { + break + } + refHost, refPort := getServer(data) + if refHost == "" { + break + } + refAddr := strings.ToLower(net.JoinHostPort(refHost, refPort)) + if _, seen := visited[refAddr]; seen { + break + } + host, p = refHost, refPort + } + return steps, nil +} + +func formatWhoisChainData(chain []rawWhoisStep, withRegistrarMarker bool) string { + if len(chain) == 0 { + return "" + } + if len(chain) == 1 { + return chain[0].Data + } + parts := make([]string, 0, len(chain)) + parts = append(parts, chain[0].Data) + for i := 1; i < len(chain); i++ { + if withRegistrarMarker { + if i == 1 { + parts = append(parts, "----- REGISTRAR WHOIS -----") + } else { + parts = append(parts, fmt.Sprintf("----- REFERRAL WHOIS #%d -----", i+1)) + } + } + parts = append(parts, chain[i].Data) + } + sep := "\n" + if withRegistrarMarker { + sep = "\n\n" + } + return strings.Join(parts, sep) +} + +func chainServerPath(chain []rawWhoisStep) string { + if len(chain) == 0 { + return "" + } + parts := make([]string, 0, len(chain)) + for _, step := range chain { + s := strings.TrimSpace(step.Server) + if s == "" { + continue + } + parts = append(parts, s) + } + return strings.Join(parts, " -> ") +} + +func chainCombinedCharset(chain []rawWhoisStep) string { + charset := "" + for _, step := range chain { + charset = combineCharset(charset, step.Charset) + } + return charset +} + +func (c *Client) effectiveNegativeCacheTTL(opt QueryOptions) time.Duration { + if opt.NegativeCacheTTL > 0 { + return opt.NegativeCacheTTL + } + if opt.NegativeCacheTTL < 0 { + return 0 + } + if c == nil { + return 0 + } + if c.negativeCacheTTL > 0 { + return c.negativeCacheTTL + } + return 0 +} + +func hasOverrideWhoisServers(opt QueryOptions) bool { + if strings.TrimSpace(opt.OverrideServer) != "" { + return true + } + for _, s := range opt.OverrideServers { + if strings.TrimSpace(s) != "" { + return true + } + } + return false +} + +func negativeCacheKey(domain string, opt QueryOptions) string { + return fmt.Sprintf("whois-neg|level=%d|domain=%s", opt.Level, strings.ToLower(strings.TrimSpace(domain))) +} + +func loadNegativeCache(key string, now time.Time) (Result, error, bool) { + v, ok := whoisNegativeCache.Load(key) + if !ok { + return Result{}, nil, false + } + entry, ok := v.(negativeCacheEntry) + if !ok { + whoisNegativeCache.Delete(key) + return Result{}, nil, false + } + if !entry.ExpireAt.After(now) { + whoisNegativeCache.Delete(key) + return Result{}, nil, false + } + if entry.Result != nil { + return cloneResult(*entry.Result), nil, true + } + cause := ErrNoWhoisServer + if entry.Code == ErrorCodeNotFound { + cause = ErrNotFound + } + return Result{}, newWhoisError(entry.Code, entry.Domain, entry.Server, entry.Reason, cause), true +} + +func storeNegativeCache(key string, ttl time.Duration, entry negativeCacheEntry) { + if ttl <= 0 { + return + } + if entry.ExpireAt.IsZero() { + entry.ExpireAt = time.Now().Add(ttl) + } + if entry.Result != nil { + r := cloneResult(*entry.Result) + entry.Result = &r + } + whoisNegativeCache.Store(key, entry) +} + +func clearNegativeCache(keys ...string) { + if len(keys) == 0 { + whoisNegativeCache.Range(func(k, _ interface{}) bool { + whoisNegativeCache.Delete(k) + return true + }) + return + } + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + whoisNegativeCache.Delete(key) + } +} + +func cloneResult(in Result) Result { + out := in + out.statusRaw = append([]string(nil), in.statusRaw...) + out.nsServers = append([]string(nil), in.nsServers...) + out.nsIps = append([]string(nil), in.nsIps...) + return out +} + +func IsASN(s string) bool { + s = strings.ToUpper(s) + s = strings.TrimPrefix(s, asnPrefix) + _, err := strconv.Atoi(s) + return err == nil +} diff --git a/client_dial_test.go b/client_dial_test.go new file mode 100644 index 0000000..917445f --- /dev/null +++ b/client_dial_test.go @@ -0,0 +1,78 @@ +package whois + +import ( + "context" + "errors" + "net" + "sync" + "testing" + "time" +) + +func TestDialContextCancelClosesLateConnection(t *testing.T) { + left, right := net.Pipe() + defer right.Close() + + spyConn := &closeSpyConn{ + Conn: left, + closed: make(chan struct{}), + } + dialer := &blockingDialer{ + ready: make(chan struct{}), + release: make(chan struct{}), + conn: spyConn, + } + c := NewClient().SetDialer(dialer) + + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond) + defer cancel() + + errCh := make(chan error, 1) + go func() { + _, err := c.dialContext(ctx, "tcp", "example.com:43") + errCh <- err + }() + + <-dialer.ready + err := <-errCh + if err == nil { + t.Fatal("expected context cancellation error") + } + if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { + t.Fatalf("unexpected error: %v", err) + } + + close(dialer.release) + select { + case <-spyConn.closed: + case <-time.After(time.Second): + t.Fatal("expected late connection to be closed after context cancel") + } +} + +type blockingDialer struct { + ready chan struct{} + release chan struct{} + conn net.Conn + err error + once sync.Once +} + +func (d *blockingDialer) Dial(_ string, _ string) (net.Conn, error) { + d.once.Do(func() { close(d.ready) }) + <-d.release + return d.conn, d.err +} + +type closeSpyConn struct { + net.Conn + closed chan struct{} + once sync.Once +} + +func (c *closeSpyConn) Close() error { + c.once.Do(func() { + close(c.closed) + }) + return c.Conn.Close() +} diff --git a/client_internal_test.go b/client_internal_test.go new file mode 100644 index 0000000..8a142e5 --- /dev/null +++ b/client_internal_test.go @@ -0,0 +1,223 @@ +package whois + +import ( + "bufio" + "context" + "errors" + "net" + "strings" + "testing" + "time" + + "golang.org/x/text/encoding/simplifiedchinese" +) + +func TestGetServerReferralVariants(t *testing.T) { + cases := []struct { + name string + raw string + host string + port string + }{ + { + name: "registrar whois server", + raw: "Registrar WHOIS Server: whois.markmonitor.com", + host: "whois.markmonitor.com", + port: "43", + }, + { + name: "referral server with scheme and port", + raw: "ReferralServer: whois://whois.example.net:4343", + host: "whois.example.net", + port: "4343", + }, + { + name: "refer short key", + raw: "refer: whois.nic.io", + host: "whois.nic.io", + port: "43", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + host, port := getServer(tc.raw + "\n") + if host != tc.host || port != tc.port { + t.Fatalf("unexpected server, got=%s:%s want=%s:%s", host, port, tc.host, tc.port) + } + }) + } +} + +func TestNormalizeServerListDedupAndTrim(t *testing.T) { + got := normalizeServerList([]string{" whois.a.com ", "", "WHOIS.A.COM", "whois.b.com"}) + if len(got) != 2 { + t.Fatalf("unexpected size: %d", len(got)) + } + if got[0] != "whois.a.com" || got[1] != "whois.b.com" { + t.Fatalf("unexpected values: %#v", got) + } +} + +func TestWhoisWithOptionsContextCanceled(t *testing.T) { + addr, shutdown := startMockWhoisServerWithDelay(t, strings.Join([]string{ + "Domain Name: EXAMPLE.COM", + "Registrar: TEST-REG", + "", + }, "\n"), 500*time.Millisecond) + defer shutdown() + + c := NewClient().SetTimeout(2 * time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) + defer cancel() + + _, err := c.WhoisWithOptionsContext(ctx, "example.com", QueryOptions{ + Level: QueryAuto, + OverrideServer: addr, + }) + if err == nil { + t.Fatal("expected context timeout error") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected context deadline exceeded, got: %v", err) + } +} + +func TestWhoisEmptyResponseTypedError(t *testing.T) { + addr, shutdown := startMockWhoisServer(t, "") + defer shutdown() + + c := NewClient() + _, err := c.WhoisWithOptions("example.com", QueryOptions{ + Level: QueryAuto, + OverrideServer: addr, + }) + if err == nil { + t.Fatal("expected empty response error") + } + if !errors.Is(err, ErrEmptyResponse) { + t.Fatalf("expected ErrEmptyResponse, got: %v", err) + } +} + +func TestResultMetaAndTypedError(t *testing.T) { + addr, shutdown := startMockWhoisServer(t, strings.Join([]string{ + "Domain Name: EXAMPLE.COM", + "", + }, "\n")) + defer shutdown() + + c := NewClient() + r, err := c.WhoisWithOptions("example.com", QueryOptions{ + Level: QueryAuto, + OverrideServer: addr, + }) + if err != nil { + t.Fatalf("WhoisWithOptions() error: %v", err) + } + meta := r.Meta() + if meta.Source != "whois" { + t.Fatalf("unexpected source: %q", meta.Source) + } + if meta.Server == "" || !strings.Contains(meta.Server, ":") { + t.Fatalf("unexpected server: %q", meta.Server) + } + if meta.RawLen <= 0 { + t.Fatalf("unexpected raw length: %d", meta.RawLen) + } + if meta.ReasonCode != ErrorCodeParseWeak { + t.Fatalf("unexpected reason code: %s", meta.ReasonCode) + } + if r.TypedError() == nil || !errors.Is(r.TypedError(), ErrParseWeak) { + t.Fatalf("expected typed parse-weak error, got: %v", r.TypedError()) + } +} + +func TestAccessDeniedResultReason(t *testing.T) { + addr, shutdown := startMockWhoisServer(t, strings.Join([]string{ + "Requests of this client are not permitted. Please use https://www.nic.ch/whois/ for queries.", + "", + }, "\n")) + defer shutdown() + + c := NewClient() + r, err := c.WhoisWithOptions("nic.ch", QueryOptions{ + Level: QueryAuto, + OverrideServer: addr, + }) + if err != nil { + t.Fatalf("WhoisWithOptions() error: %v", err) + } + if r.Meta().ReasonCode != ErrorCodeAccessDenied { + t.Fatalf("unexpected reason code: %s", r.Meta().ReasonCode) + } + if !errors.Is(r.TypedError(), ErrAccessDenied) { + t.Fatalf("expected ErrAccessDenied, got: %v", r.TypedError()) + } +} + +func TestWhoisDetectLegacyCharsetGBK(t *testing.T) { + raw := strings.Join([]string{ + "Domain Name: test.com", + "Registrar: 测试注册商", + "Name Server: ns1.test.com", + "", + }, "\n") + gbkBytes, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(raw)) + if err != nil { + t.Fatalf("encode GBK failed: %v", err) + } + + addr, shutdown := startRawWhoisServer(t, gbkBytes) + defer shutdown() + + c := NewClient() + r, err := c.WhoisWithOptions("test.com", QueryOptions{ + Level: QueryAuto, + OverrideServer: addr, + }) + if err != nil { + t.Fatalf("WhoisWithOptions() error: %v", err) + } + + if r.Meta().Charset != "gbk" && r.Meta().Charset != "gb18030" { + t.Fatalf("unexpected charset: %q", r.Meta().Charset) + } + if !strings.Contains(r.RawData(), "测试注册商") { + t.Fatalf("decoded raw data does not contain expected chinese text: %q", r.RawData()) + } +} + +func startRawWhoisServer(t *testing.T, payload []byte) (addr string, shutdown func()) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen raw whois failed: %v", err) + } + done := make(chan struct{}) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-done: + return + default: + return + } + } + go func(c net.Conn) { + defer c.Close() + _ = c.SetDeadline(time.Now().Add(5 * time.Second)) + _, _ = bufio.NewReader(c).ReadString('\n') + if len(payload) > 0 { + _, _ = c.Write(payload) + } + }(conn) + } + }() + return ln.Addr().String(), func() { + close(done) + _ = ln.Close() + } +} diff --git a/client_referral_cache_test.go b/client_referral_cache_test.go new file mode 100644 index 0000000..b30ec1e --- /dev/null +++ b/client_referral_cache_test.go @@ -0,0 +1,142 @@ +package whois + +import ( + "bufio" + "fmt" + "net" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestWhoisReferralLoopGuard(t *testing.T) { + old, hadOld := defaultWhoisMap["loop"] + defer func() { + if hadOld { + defaultWhoisMap["loop"] = old + } else { + delete(defaultWhoisMap, "loop") + } + }() + + var addrA, addrB string + var hitA, hitB atomic.Int32 + + addrB, stopB := startWhoisServerWithHandler(t, &hitB, func(_ string) string { + return strings.Join([]string{ + "Domain Name: EXAMPLE.LOOP", + "Registrar: LOOP-REG-B", + "Whois Server: " + addrA, + "", + }, "\n") + }) + defer stopB() + + addrA, stopA := startWhoisServerWithHandler(t, &hitA, func(_ string) string { + return strings.Join([]string{ + "Domain Name: EXAMPLE.LOOP", + "Registrar: LOOP-REG-A", + "Whois Server: " + addrB, + "", + }, "\n") + }) + defer stopA() + + defaultWhoisMap["loop"] = addrA + + c := NewClient() + r, err := c.WhoisWithOptions("example.loop", QueryOptions{ + Level: QueryBoth, + ReferralMaxDepth: 8, + }) + if err != nil { + t.Fatalf("WhoisWithOptions() error: %v", err) + } + if !r.Exists() { + t.Fatal("expected exists=true") + } + if got := hitA.Load(); got != 1 { + t.Fatalf("expected A hit once (loop prevented), got=%d", got) + } + if got := hitB.Load(); got != 1 { + t.Fatalf("expected B hit once, got=%d", got) + } +} + +func TestWhoisNegativeCacheNotFound(t *testing.T) { + clearNegativeCache() + old, hadOld := defaultWhoisMap["nfcache"] + defer func() { + if hadOld { + defaultWhoisMap["nfcache"] = old + } else { + delete(defaultWhoisMap, "nfcache") + } + clearNegativeCache() + }() + + var hit atomic.Int32 + addr, stop := startWhoisServerWithHandler(t, &hit, func(_ string) string { + return strings.Join([]string{ + "No match for \"EXAMPLE.NFCACHE\"", + "", + }, "\n") + }) + defer stop() + defaultWhoisMap["nfcache"] = addr + + c := NewClient().SetNegativeCacheTTL(time.Minute) + r1, err := c.WhoisWithOptions("example.nfcache", QueryOptions{Level: QueryAuto}) + if err != nil { + t.Fatalf("first WhoisWithOptions() error: %v", err) + } + r2, err := c.WhoisWithOptions("example.nfcache", QueryOptions{Level: QueryAuto}) + if err != nil { + t.Fatalf("second WhoisWithOptions() error: %v", err) + } + if r1.Exists() || r2.Exists() { + t.Fatal("expected cached not-found results") + } + if got := hit.Load(); got != 1 { + t.Fatalf("expected one whois query hit with negative cache, got=%d", got) + } +} + +func startWhoisServerWithHandler(t *testing.T, hit *atomic.Int32, handler func(query string) string) (addr string, shutdown func()) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen mock whois failed: %v", err) + } + done := make(chan struct{}) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-done: + return + default: + return + } + } + go func(c net.Conn) { + defer c.Close() + _ = c.SetDeadline(time.Now().Add(5 * time.Second)) + line, _ := bufio.NewReader(c).ReadString('\n') + if hit != nil { + hit.Add(1) + } + resp := handler(strings.TrimSpace(line)) + if resp != "" { + _, _ = fmt.Fprint(c, resp) + } + }(conn) + } + }() + return ln.Addr().String(), func() { + close(done) + _ = ln.Close() + } +} diff --git a/date_parse.go b/date_parse.go new file mode 100644 index 0000000..fbc283e --- /dev/null +++ b/date_parse.go @@ -0,0 +1,132 @@ +package whois + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +func parseDateAuto(s string) time.Time { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{} + } + + withZoneLayouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.999999999-0700", + "2006-01-02T15:04:05-0700", + "2006-01-02 15:04:05 -0700", + "2006-01-02 15:04:05 -07:00", + "2006-01-02 15:04:05 MST", + "2006/01/02 15:04:05 (MST)", + "02-Jan-2006 15:04:05 MST", + "02-01-2006 15:04:05 -0700", + } + localLayouts := []string{ + "2006-01-02T15:04:05.999999999", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02", + "2006/01/02 15:04:05", + "2006/01/02", + "2006.01.02 15:04:05", + "2006.01.02", + "02-Jan-2006", + "02.01.2006 15:04:05", + "02.01.2006", + "2.1.2006 15:04:05", + "2.1.2006", + "02-01-2006 15:04:05", + "02-01-2006", + "Mon Jan 2 15:04:05 2006", + } + + for _, candidate := range normalizeDateCandidates(s) { + for _, l := range withZoneLayouts { + if t, err := time.Parse(l, candidate); err == nil { + return t.In(time.Local) + } + } + for _, l := range localLayouts { + if t, err := time.ParseInLocation(l, candidate, time.Local); err == nil { + return t + } + } + } + return time.Time{} +} + +func normalizeDateCandidates(s string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, 6) + push := func(v string) { + v = strings.TrimSpace(v) + if v == "" { + return + } + if _, ok := seen[v]; ok { + return + } + seen[v] = struct{}{} + out = append(out, v) + } + + push(s) + push(strings.Trim(s, "\"'")) + if i := strings.Index(s, " ("); i > 0 && strings.HasSuffix(s, ")") { + push(s[:i]) + } + if strings.HasSuffix(strings.ToUpper(s), " UTC") { + push(strings.TrimSpace(s[:len(s)-4]) + " +0000") + } + if gmt := normalizeGMTOffsetSuffix(s); gmt != "" { + push(gmt) + } + return out +} + +func normalizeGMTOffsetSuffix(s string) string { + trimmed := strings.TrimSpace(s) + upper := strings.ToUpper(trimmed) + idx := strings.LastIndex(upper, " GMT") + if idx <= 0 { + return "" + } + + base := strings.TrimSpace(trimmed[:idx]) + offset := strings.TrimSpace(trimmed[idx+4:]) + if len(offset) < 2 { + return "" + } + sign := offset[0] + if sign != '+' && sign != '-' { + return "" + } + offset = offset[1:] + + hourText := offset + minText := "00" + if p := strings.Index(offset, ":"); p >= 0 { + hourText = offset[:p] + minText = offset[p+1:] + } + if hourText == "" { + return "" + } + + hour, err := strconv.Atoi(hourText) + if err != nil { + return "" + } + mins := 0 + if minText != "" { + mins, err = strconv.Atoi(minText) + if err != nil { + return "" + } + } + return fmt.Sprintf("%s %c%02d%02d", base, sign, hour, mins) +} diff --git a/date_parse_unit_test.go b/date_parse_unit_test.go new file mode 100644 index 0000000..869ff3e --- /dev/null +++ b/date_parse_unit_test.go @@ -0,0 +1,45 @@ +package whois + +import "testing" + +func TestParseDateAutoRFC3339(t *testing.T) { + got := parseDateAuto("2026-03-17T12:34:56+08:00") + if got.IsZero() { + t.Fatal("expected RFC3339 offset date to parse") + } +} + +func TestParseDateAutoRFC3339Nano(t *testing.T) { + got := parseDateAuto("2026-03-17T12:34:56.123456789Z") + if got.IsZero() { + t.Fatal("expected RFC3339Nano date to parse") + } +} + +func TestParseDateAutoInvalid(t *testing.T) { + got := parseDateAuto("invalid-date-value") + if !got.IsZero() { + t.Fatal("expected invalid date to return zero time") + } +} + +func TestParseDateAutoDotDayMonth(t *testing.T) { + got := parseDateAuto("16.2.1999 00:00:00") + if got.IsZero() { + t.Fatal("expected dot day/month date to parse") + } +} + +func TestParseDateAutoWeekdayFormat(t *testing.T) { + got := parseDateAuto("Tue Jan 16 10:31:46 2001") + if got.IsZero() { + t.Fatal("expected weekday date to parse") + } +} + +func TestParseDateAutoGMTOffsetSuffix(t *testing.T) { + got := parseDateAuto("14-07-2009 00:00:00 GMT+1") + if got.IsZero() { + t.Fatal("expected GMT+offset date to parse") + } +} diff --git a/domain_normalize.go b/domain_normalize.go new file mode 100644 index 0000000..35c60c8 --- /dev/null +++ b/domain_normalize.go @@ -0,0 +1,91 @@ +package whois + +import ( + "errors" + "fmt" + "net" + "strconv" + "strings" + + "golang.org/x/net/idna" +) + +type lookupTargetKind int + +const ( + lookupTargetDomain lookupTargetKind = iota + lookupTargetIP + lookupTargetASN +) + +func normalizeQueryDomainInput(domain string) (string, error) { + domain = strings.Trim(strings.TrimSpace(domain), ".") + if domain == "" { + return "", errors.New("whois: domain is empty") + } + if strings.ContainsAny(domain, "/\\") { + return "", fmt.Errorf("whois: invalid domain=%q", domain) + } + + if IsASN(domain) { + if strings.HasPrefix(strings.ToUpper(domain), "AS") { + return strings.ToUpper(domain), nil + } + return "AS" + strings.ToUpper(domain), nil + } + + if ip := net.ParseIP(domain); ip != nil { + return ip.String(), nil + } + + ascii, err := idna.Lookup.ToASCII(domain) + if err != nil { + return "", fmt.Errorf("whois: invalid domain=%q: %w", domain, err) + } + ascii = strings.ToLower(strings.Trim(ascii, ".")) + if ascii == "" { + return "", errors.New("whois: domain is empty") + } + return ascii, nil +} + +func normalizeLookupDomainInput(domain string) (string, error) { + normalized, kind, err := normalizeLookupTargetInput(domain) + if err != nil { + return "", err + } + if kind != lookupTargetDomain { + return "", fmt.Errorf("whois/lookup: invalid domain=%q", domain) + } + return normalized, nil +} + +func normalizeLookupTargetInput(input string) (string, lookupTargetKind, error) { + normalized, err := normalizeQueryDomainInput(input) + if err != nil { + return "", lookupTargetDomain, err + } + if IsASN(normalized) { + return normalized, lookupTargetASN, nil + } + if net.ParseIP(normalized) != nil { + return normalized, lookupTargetIP, nil + } + return normalized, lookupTargetDomain, nil +} + +func normalizeASNNumeric(asn string) (string, error) { + asn = strings.TrimSpace(strings.ToUpper(asn)) + if !IsASN(asn) { + return "", fmt.Errorf("whois/rdap: invalid asn=%q", asn) + } + asn = strings.TrimPrefix(asn, "AS") + asn = strings.TrimSpace(asn) + if asn == "" { + return "", fmt.Errorf("whois/rdap: invalid asn=%q", asn) + } + if _, err := strconv.ParseUint(asn, 10, 32); err != nil { + return "", fmt.Errorf("whois/rdap: invalid asn=%q: %w", asn, err) + } + return asn, nil +} diff --git a/domain_normalize_test.go b/domain_normalize_test.go new file mode 100644 index 0000000..6094450 --- /dev/null +++ b/domain_normalize_test.go @@ -0,0 +1,29 @@ +package whois + +import "testing" + +func TestNormalizeQueryDomainInputIDN(t *testing.T) { + got, err := normalizeQueryDomainInput("\u4f8b\u5b50.\u6d4b\u8bd5") + if err != nil { + t.Fatalf("normalizeQueryDomainInput() error: %v", err) + } + if got != "xn--fsqu00a.xn--0zwm56d" { + t.Fatalf("unexpected idn ascii result: %q", got) + } +} + +func TestNormalizeQueryDomainInputASN(t *testing.T) { + got, err := normalizeQueryDomainInput("13335") + if err != nil { + t.Fatalf("normalizeQueryDomainInput() error: %v", err) + } + if got != "AS13335" { + t.Fatalf("unexpected asn normalization result: %q", got) + } +} + +func TestNormalizeLookupDomainInputRejectASN(t *testing.T) { + if _, err := normalizeLookupDomainInput("AS13335"); err == nil { + t.Fatal("expected normalizeLookupDomainInput to reject asn") + } +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..ffc71de --- /dev/null +++ b/errors.go @@ -0,0 +1,124 @@ +package whois + +import ( + "errors" + "fmt" + "strings" +) + +// ErrorCode represents a typed SDK error category. +type ErrorCode string + +const ( + ErrorCodeOK ErrorCode = "ok" + ErrorCodeNoWhoisServer ErrorCode = "no-whois-server" + ErrorCodeAccessDenied ErrorCode = "access-denied" + ErrorCodeNotFound ErrorCode = "not-found" + ErrorCodeEmptyResponse ErrorCode = "empty-response" + ErrorCodeParseWeak ErrorCode = "parse-weak" + ErrorCodeNetwork ErrorCode = "network" +) + +// WhoisError is the typed error model used by this SDK. +type WhoisError struct { + Code ErrorCode + Domain string + Server string + Reason string + Err error +} + +func (e *WhoisError) Error() string { + if e == nil { + return "whois: unknown error" + } + parts := make([]string, 0, 5) + parts = append(parts, "whois") + if e.Code != "" { + parts = append(parts, string(e.Code)) + } + if strings.TrimSpace(e.Domain) != "" { + parts = append(parts, "domain="+strings.TrimSpace(e.Domain)) + } + if strings.TrimSpace(e.Server) != "" { + parts = append(parts, "server="+strings.TrimSpace(e.Server)) + } + if strings.TrimSpace(e.Reason) != "" { + parts = append(parts, strings.TrimSpace(e.Reason)) + } + if e.Err != nil { + parts = append(parts, e.Err.Error()) + } + return strings.Join(parts, ": ") +} + +func (e *WhoisError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func (e *WhoisError) Is(target error) bool { + t, ok := target.(*WhoisError) + if !ok { + return false + } + return e.Code == t.Code +} + +var ( + ErrNoWhoisServer = &WhoisError{Code: ErrorCodeNoWhoisServer} + ErrAccessDenied = &WhoisError{Code: ErrorCodeAccessDenied} + ErrNotFound = &WhoisError{Code: ErrorCodeNotFound} + ErrEmptyResponse = &WhoisError{Code: ErrorCodeEmptyResponse} + ErrParseWeak = &WhoisError{Code: ErrorCodeParseWeak} +) + +func newWhoisError(code ErrorCode, domain, server, reason string, cause error) error { + return &WhoisError{ + Code: code, + Domain: strings.TrimSpace(domain), + Server: strings.TrimSpace(server), + Reason: strings.TrimSpace(reason), + Err: cause, + } +} + +func reasonErrorFromMeta(domain string, m ResultMeta) error { + switch m.ReasonCode { + case ErrorCodeAccessDenied: + return newWhoisError(ErrorCodeAccessDenied, domain, m.Server, m.Reason, ErrAccessDenied) + case ErrorCodeNotFound: + return newWhoisError(ErrorCodeNotFound, domain, m.Server, m.Reason, ErrNotFound) + case ErrorCodeEmptyResponse: + return newWhoisError(ErrorCodeEmptyResponse, domain, m.Server, m.Reason, ErrEmptyResponse) + case ErrorCodeParseWeak: + return newWhoisError(ErrorCodeParseWeak, domain, m.Server, m.Reason, ErrParseWeak) + default: + return nil + } +} + +// IsCode reports whether err is a typed SDK error with the given code. +func IsCode(err error, code ErrorCode) bool { + if err == nil { + return false + } + var we *WhoisError + if !errors.As(err, &we) { + return false + } + return we.Code == code +} + +func wrapNetworkError(domain, server string, err error, op string) error { + reason := strings.TrimSpace(op) + if reason == "" { + reason = "network error" + } + if err == nil { + err = fmt.Errorf("whois: %s", reason) + } + return newWhoisError(ErrorCodeNetwork, domain, server, reason, err) +} diff --git a/go.mod b/go.mod index 21a3eb4..f8d589a 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,9 @@ module b612.me/sdk/whois go 1.21.2 -require golang.org/x/net v0.28.0 +require ( + b612.me/starnet v0.4.2 + golang.org/x/net v0.28.0 + golang.org/x/sync v0.8.0 + golang.org/x/text v0.17.0 +) diff --git a/go.sum b/go.sum index e890837..a83fe50 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,8 @@ +b612.me/starnet v0.4.2 h1:cTcEoN5RtKYT0fwuvUOTbqyUaaEm3xuYvbFjB+Mx8zo= +b612.me/starnet v0.4.2/go.mod h1:6q+AXhYeXsIiKV+hZZmqAMn8S48QcdonURJyH66rbzI= golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= diff --git a/lookup.go b/lookup.go new file mode 100644 index 0000000..aa12f18 --- /dev/null +++ b/lookup.go @@ -0,0 +1,533 @@ +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 +} diff --git a/lookup_ip_asn_test.go b/lookup_ip_asn_test.go new file mode 100644 index 0000000..259b851 --- /dev/null +++ b/lookup_ip_asn_test.go @@ -0,0 +1,82 @@ +package whois + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestLookupRDAPOnlyIP(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ip/1.1.1.1" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"objectClassName":"ip network","handle":"NET-1-1-1-0-1"}`)) + })) + defer srv.Close() + + rdc, err := NewRDAPClientWithBootstrap(&RDAPBootstrap{ + Version: "1.0", + Services: []RDAPService{ + {TLDs: []string{"com"}, URLs: []string{"https://rdap.example.com/"}}, + }, + }) + if err != nil { + t.Fatalf("NewRDAPClientWithBootstrap() error: %v", err) + } + rdc.SetIPServers(srv.URL) + + c := NewClient() + got, meta, err := c.LookupContext(context.Background(), "1.1.1.1", + WithLookupMode(LookupModeRDAPOnly), + WithLookupRDAPClient(rdc), + ) + if err != nil { + t.Fatalf("LookupContext() error: %v", err) + } + if meta.Source != LookupSourceRDAP || !got.Exists() { + t.Fatalf("unexpected source=%s exists=%v", meta.Source, got.Exists()) + } + if got.Domain() != "1.1.1.1" { + t.Fatalf("unexpected domain: %q", got.Domain()) + } +} + +func TestLookupRDAPOnlyASN(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/autnum/13335" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"objectClassName":"autnum","handle":"AS13335"}`)) + })) + defer srv.Close() + + rdc, err := NewRDAPClientWithBootstrap(&RDAPBootstrap{ + Version: "1.0", + Services: []RDAPService{ + {TLDs: []string{"com"}, URLs: []string{"https://rdap.example.com/"}}, + }, + }) + if err != nil { + t.Fatalf("NewRDAPClientWithBootstrap() error: %v", err) + } + rdc.SetASNServers(srv.URL) + + c := NewClient() + got, meta, err := c.LookupContext(context.Background(), "AS13335", + WithLookupMode(LookupModeRDAPOnly), + WithLookupRDAPClient(rdc), + ) + if err != nil { + t.Fatalf("LookupContext() error: %v", err) + } + if meta.Source != LookupSourceRDAP || !got.Exists() { + t.Fatalf("unexpected source=%s exists=%v", meta.Source, got.Exists()) + } + if got.Domain() != "AS13335" { + t.Fatalf("unexpected domain: %q", got.Domain()) + } +} diff --git a/lookup_ops.go b/lookup_ops.go new file mode 100644 index 0000000..2da5604 --- /dev/null +++ b/lookup_ops.go @@ -0,0 +1,265 @@ +package whois + +import ( + "context" + "fmt" + "net" + "net/url" + "strings" + "time" + + "b612.me/starnet" + netproxy "golang.org/x/net/proxy" +) + +// LookupCommonOps are controls shared by RDAP and WHOIS when specific values are absent. +type LookupCommonOps struct { + Timeout time.Duration + Proxy string +} + +// LookupRDAPOps are RDAP-only controls, powered by starnet request options. +// Proxy here overrides common Proxy for RDAP only. +type LookupRDAPOps struct { + Timeout time.Duration + DialTimeout time.Duration + Proxy string + SkipTLSVerify *bool + CustomIP []string + CustomDNS []string + Hosts map[string]string + LookupFunc func(ctx context.Context, host string) ([]net.IPAddr, error) + Headers map[string]string + RequestOpts []starnet.RequestOpt +} + +// LookupWHOISOps are WHOIS-safe controls. +type LookupWHOISOps struct { + Timeout time.Duration + Proxy string + Dialer netproxy.Dialer +} + +// LookupOps groups common + protocol-specific controls. +type LookupOps struct { + Common LookupCommonOps + RDAP LookupRDAPOps + WHOIS LookupWHOISOps +} + +// WithLookupOps sets all lookup ops. +func WithLookupOps(ops LookupOps) LookupOpt { + return func(o *LookupOptions) { + o.Ops = cloneLookupOps(ops) + } +} + +// WithLookupCommonOps sets shared lookup ops. +func WithLookupCommonOps(ops LookupCommonOps) LookupOpt { + return func(o *LookupOptions) { + o.Ops.Common = ops + } +} + +// WithLookupRDAPOps sets RDAP-only lookup ops. +func WithLookupRDAPOps(ops LookupRDAPOps) LookupOpt { + return func(o *LookupOptions) { + o.Ops.RDAP = cloneLookupRDAPOps(ops) + } +} + +// WithLookupWHOISOps sets WHOIS-only lookup ops. +func WithLookupWHOISOps(ops LookupWHOISOps) LookupOpt { + return func(o *LookupOptions) { + o.Ops.WHOIS = ops + } +} + +// WithLookupCommonTimeout sets shared timeout. +func WithLookupCommonTimeout(timeout time.Duration) LookupOpt { + return func(o *LookupOptions) { + o.Ops.Common.Timeout = timeout + } +} + +// WithLookupProxy sets common proxy for RDAP + WHOIS (WHOIS expects socks5 proxy). +func WithLookupProxy(proxy string) LookupOpt { + return func(o *LookupOptions) { + o.Ops.Common.Proxy = strings.TrimSpace(proxy) + } +} + +// WithLookupRDAPProxy sets RDAP-only proxy, overriding common proxy for RDAP. +func WithLookupRDAPProxy(proxy string) LookupOpt { + return func(o *LookupOptions) { + o.Ops.RDAP.Proxy = strings.TrimSpace(proxy) + } +} + +// WithLookupWHOISDialer sets WHOIS TCP dialer. +func WithLookupWHOISDialer(d netproxy.Dialer) LookupOpt { + return func(o *LookupOptions) { + o.Ops.WHOIS.Dialer = d + } +} + +// WithLookupWHOISProxy sets WHOIS-only proxy, overriding common proxy for WHOIS. +func WithLookupWHOISProxy(proxy string) LookupOpt { + return func(o *LookupOptions) { + o.Ops.WHOIS.Proxy = strings.TrimSpace(proxy) + } +} + +// WithLookupWHOISTimeout sets WHOIS timeout. +func WithLookupWHOISTimeout(timeout time.Duration) LookupOpt { + return func(o *LookupOptions) { + o.Ops.WHOIS.Timeout = timeout + } +} + +func cloneLookupOps(in LookupOps) LookupOps { + out := in + out.RDAP = cloneLookupRDAPOps(in.RDAP) + return out +} + +func cloneLookupRDAPOps(in LookupRDAPOps) LookupRDAPOps { + out := in + out.CustomIP = copyStringSlice(in.CustomIP) + out.CustomDNS = copyStringSlice(in.CustomDNS) + out.Hosts = copyStringMap(in.Hosts) + out.Headers = copyStringMap(in.Headers) + if len(in.RequestOpts) > 0 { + out.RequestOpts = append([]starnet.RequestOpt(nil), in.RequestOpts...) + } + return out +} + +func copyStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func (o LookupOptions) effectiveRDAPRequestOpts() []starnet.RequestOpt { + base := []starnet.RequestOpt{ + starnet.WithHeader("Accept", "application/rdap+json, application/json"), + starnet.WithUserAgent("b612-whois-rdap/1.0"), + starnet.WithAutoFetch(true), + } + + timeout := o.Ops.RDAP.Timeout + if timeout <= 0 { + timeout = o.Ops.Common.Timeout + } + if timeout > 0 { + base = append(base, starnet.WithTimeout(timeout)) + } + if o.Ops.RDAP.DialTimeout > 0 { + base = append(base, starnet.WithDialTimeout(o.Ops.RDAP.DialTimeout)) + } + if p := o.effectiveRDAPProxy(); p != "" { + base = append(base, starnet.WithProxy(p)) + } + if o.Ops.RDAP.SkipTLSVerify != nil { + base = append(base, starnet.WithSkipTLSVerify(*o.Ops.RDAP.SkipTLSVerify)) + } + if len(o.Ops.RDAP.CustomIP) > 0 { + base = append(base, starnet.WithCustomIP(copyStringSlice(o.Ops.RDAP.CustomIP))) + } + if len(o.Ops.RDAP.CustomDNS) > 0 { + base = append(base, starnet.WithCustomDNS(copyStringSlice(o.Ops.RDAP.CustomDNS))) + } + if len(o.Ops.RDAP.Headers) > 0 { + base = append(base, starnet.WithHeaders(copyStringMap(o.Ops.RDAP.Headers))) + } + + lookupFn := o.Ops.RDAP.LookupFunc + if len(o.Ops.RDAP.Hosts) > 0 { + lookupFn = buildHostsLookupFunc(o.Ops.RDAP.Hosts, lookupFn) + } + if lookupFn != nil { + base = append(base, starnet.WithLookupFunc(lookupFn)) + } + + base = append(base, o.RDAPRequestOpts...) + base = append(base, o.Ops.RDAP.RequestOpts...) + return base +} + +func (o LookupOptions) effectiveRDAPProxy() string { + if p := strings.TrimSpace(o.Ops.RDAP.Proxy); p != "" { + return p + } + return strings.TrimSpace(o.Ops.Common.Proxy) +} + +func (o LookupOptions) effectiveWHOISProxy() string { + if p := strings.TrimSpace(o.Ops.WHOIS.Proxy); p != "" { + return p + } + return strings.TrimSpace(o.Ops.Common.Proxy) +} + +func (o LookupOptions) effectiveWHOISDialer(defaultTimeout time.Duration) (netproxy.Dialer, error) { + if o.Ops.WHOIS.Dialer != nil { + return o.Ops.WHOIS.Dialer, nil + } + proxyAddr := o.effectiveWHOISProxy() + if proxyAddr == "" { + return nil, nil + } + if !strings.Contains(proxyAddr, "://") { + proxyAddr = "socks5://" + proxyAddr + } + u, err := url.Parse(proxyAddr) + if err != nil { + return nil, fmt.Errorf("whois proxy parse failed: %w", err) + } + dialer, err := netproxy.FromURL(u, &net.Dialer{Timeout: o.effectiveWHOISTimeout(defaultTimeout)}) + if err != nil { + return nil, fmt.Errorf("whois proxy unsupported: %w", err) + } + return dialer, nil +} + +func (o LookupOptions) effectiveWHOISTimeout(defaultTimeout time.Duration) time.Duration { + if o.Ops.WHOIS.Timeout > 0 { + return o.Ops.WHOIS.Timeout + } + if o.Ops.Common.Timeout > 0 { + return o.Ops.Common.Timeout + } + return defaultTimeout +} + +func buildHostsLookupFunc(hosts map[string]string, fallback func(ctx context.Context, host string) ([]net.IPAddr, error)) func(ctx context.Context, host string) ([]net.IPAddr, error) { + hostMap := make(map[string]net.IP, len(hosts)) + for host, ipStr := range hosts { + h := strings.Trim(strings.ToLower(strings.TrimSpace(host)), ".") + ip := net.ParseIP(strings.TrimSpace(ipStr)) + if h == "" || ip == nil { + continue + } + hostMap[h] = ip + } + if len(hostMap) == 0 { + return fallback + } + + return func(ctx context.Context, host string) ([]net.IPAddr, error) { + h := strings.Trim(strings.ToLower(strings.TrimSpace(host)), ".") + if ip, ok := hostMap[h]; ok { + return []net.IPAddr{{IP: ip}}, nil + } + if fallback != nil { + return fallback(ctx, host) + } + return net.DefaultResolver.LookupIPAddr(ctx, host) + } +} diff --git a/lookup_ops_proxy_test.go b/lookup_ops_proxy_test.go new file mode 100644 index 0000000..5c4b294 --- /dev/null +++ b/lookup_ops_proxy_test.go @@ -0,0 +1,80 @@ +package whois + +import ( + "errors" + "net" + "strings" + "testing" + "time" +) + +type testDialer struct{} + +func (d *testDialer) Dial(_, _ string) (net.Conn, error) { + return nil, errors.New("test dialer") +} + +func TestWithLookupProxySetsCommonProxy(t *testing.T) { + cfg := defaultLookupOptions() + WithLookupProxy(" socks5://127.0.0.1:1080 ")(&cfg) + if got, want := cfg.Ops.Common.Proxy, "socks5://127.0.0.1:1080"; got != want { + t.Fatalf("WithLookupProxy() got %q, want %q", got, want) + } +} + +func TestEffectiveRDAPProxyFallbackAndOverride(t *testing.T) { + cfg := defaultLookupOptions() + cfg.Ops.Common.Proxy = "socks5://127.0.0.1:1080" + if got := cfg.effectiveRDAPProxy(); got != cfg.Ops.Common.Proxy { + t.Fatalf("effectiveRDAPProxy() fallback got %q, want %q", got, cfg.Ops.Common.Proxy) + } + + cfg.Ops.RDAP.Proxy = "http://127.0.0.1:8080" + if got := cfg.effectiveRDAPProxy(); got != cfg.Ops.RDAP.Proxy { + t.Fatalf("effectiveRDAPProxy() override got %q, want %q", got, cfg.Ops.RDAP.Proxy) + } +} + +func TestEffectiveWHOISDialerFromCommonProxy(t *testing.T) { + cfg := defaultLookupOptions() + cfg.Ops.Common.Proxy = "127.0.0.1:1080" + + d, err := cfg.effectiveWHOISDialer(time.Second) + if err != nil { + t.Fatalf("effectiveWHOISDialer() error: %v", err) + } + if d == nil { + t.Fatal("effectiveWHOISDialer() dialer is nil") + } +} + +func TestEffectiveWHOISDialerPrefersExplicitDialer(t *testing.T) { + cfg := defaultLookupOptions() + cfg.Ops.Common.Proxy = "socks5://127.0.0.1:1080" + explicit := &testDialer{} + cfg.Ops.WHOIS.Dialer = explicit + + d, err := cfg.effectiveWHOISDialer(time.Second) + if err != nil { + t.Fatalf("effectiveWHOISDialer() error: %v", err) + } + if d != explicit { + t.Fatal("effectiveWHOISDialer() did not return explicit dialer") + } +} + +func TestEffectiveWHOISDialerRejectsUnsupportedScheme(t *testing.T) { + cfg := defaultLookupOptions() + cfg.Ops.Common.Proxy = "http://127.0.0.1:8080" + + d, err := cfg.effectiveWHOISDialer(time.Second) + if err == nil { + t.Fatal("expected effectiveWHOISDialer() error for unsupported proxy scheme") + } + if d != nil { + t.Fatal("effectiveWHOISDialer() returned unexpected dialer") + } + if !strings.Contains(err.Error(), "whois proxy unsupported") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/lookup_strategy.go b/lookup_strategy.go new file mode 100644 index 0000000..3744cc1 --- /dev/null +++ b/lookup_strategy.go @@ -0,0 +1,89 @@ +package whois + +import "strings" + +// LookupStrategy provides per-TLD mode mapping used when Lookup mode is auto. +type LookupStrategy struct { + DefaultMode LookupMode + TLDModes map[string]LookupMode +} + +func defaultLookupStrategy() LookupStrategy { + return LookupStrategy{ + DefaultMode: LookupModeAutoPreferRDAP, + TLDModes: map[string]LookupMode{}, + } +} + +func cloneLookupStrategy(in LookupStrategy) LookupStrategy { + out := LookupStrategy{ + DefaultMode: in.DefaultMode, + TLDModes: map[string]LookupMode{}, + } + for k, v := range in.TLDModes { + tld := normalizeRDAPTLD(k) + if tld == "" { + continue + } + out.TLDModes[tld] = normalizeLookupMode(v) + } + return out +} + +func (s LookupStrategy) modeForDomain(domain string, fallback LookupMode) LookupMode { + tld := normalizeRDAPTLD(getExtension(domain)) + if tld != "" { + if m, ok := s.TLDModes[tld]; ok { + return normalizeLookupMode(m) + } + } + if strings.TrimSpace(string(s.DefaultMode)) != "" { + return normalizeLookupMode(s.DefaultMode) + } + return normalizeLookupMode(fallback) +} + +func (o LookupOptions) resolveMode(domain string) LookupMode { + mode := normalizeLookupMode(o.Mode) + if mode != LookupModeAutoPreferRDAP { + return mode + } + return o.Strategy.modeForDomain(domain, mode) +} + +// WithLookupStrategy sets full lookup strategy. +func WithLookupStrategy(strategy LookupStrategy) LookupOpt { + return func(o *LookupOptions) { + o.Strategy = cloneLookupStrategy(strategy) + } +} + +// WithLookupTLDStrategy sets one tld-specific lookup mode used under auto mode. +func WithLookupTLDStrategy(tld string, mode LookupMode) LookupOpt { + return func(o *LookupOptions) { + if o.Strategy.TLDModes == nil { + o.Strategy.TLDModes = map[string]LookupMode{} + } + key := normalizeRDAPTLD(tld) + if key == "" { + return + } + o.Strategy.TLDModes[key] = normalizeLookupMode(mode) + } +} + +// WithLookupTLDStrategies sets multiple tld-specific lookup modes used under auto mode. +func WithLookupTLDStrategies(m map[string]LookupMode) LookupOpt { + return func(o *LookupOptions) { + for tld, mode := range m { + WithLookupTLDStrategy(tld, mode)(o) + } + } +} + +// WithLookupStrategyDefaultMode sets default strategy mode used under auto mode. +func WithLookupStrategyDefaultMode(mode LookupMode) LookupOpt { + return func(o *LookupOptions) { + o.Strategy.DefaultMode = normalizeLookupMode(mode) + } +} diff --git a/lookup_test.go b/lookup_test.go new file mode 100644 index 0000000..478a73d --- /dev/null +++ b/lookup_test.go @@ -0,0 +1,459 @@ +package whois + +import ( + "bufio" + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestLookupAutoPreferRDAP(t *testing.T) { + rdapSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rdap/domain/example.com" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/rdap+json") + _, _ = w.Write([]byte(`{ +"objectClassName":"domain", +"ldhName":"example.com", +"status":["active"], +"nameservers":[{"ldhName":"ns1.example.com"}], +"events":[{"eventAction":"registration","eventDate":"2020-01-01T00:00:00Z"}] +}`)) + })) + defer rdapSrv.Close() + + rdc, err := NewRDAPClientWithBootstrap(&RDAPBootstrap{ + Version: "1.0", + Services: []RDAPService{ + {TLDs: []string{"com"}, URLs: []string{rdapSrv.URL + "/rdap/"}}, + }, + }) + if err != nil { + t.Fatalf("NewRDAPClientWithBootstrap() error: %v", err) + } + + c := NewClient() + got, meta, err := c.Lookup("example.com", + WithLookupMode(LookupModeAutoPreferRDAP), + WithLookupRDAPClient(rdc), + ) + if err != nil { + t.Fatalf("Lookup() error: %v", err) + } + if meta.Source != LookupSourceRDAP { + t.Fatalf("unexpected source: %s", meta.Source) + } + if !got.Exists() || got.Domain() != "example.com" { + t.Fatalf("unexpected result: exists=%v domain=%q", got.Exists(), got.Domain()) + } + if len(got.NsServers()) != 1 || got.NsServers()[0] != "ns1.example.com" { + t.Fatalf("unexpected ns list: %#v", got.NsServers()) + } +} + +func TestLookupAutoFallbackToWhois(t *testing.T) { + rdapSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "temporary", http.StatusInternalServerError) + })) + defer rdapSrv.Close() + + rdc, err := NewRDAPClientWithBootstrap(&RDAPBootstrap{ + Version: "1.0", + Services: []RDAPService{ + {TLDs: []string{"com"}, URLs: []string{rdapSrv.URL}}, + }, + }) + if err != nil { + t.Fatalf("NewRDAPClientWithBootstrap() error: %v", err) + } + + whoisAddr, shutdown := startMockWhoisServer(t, strings.Join([]string{ + "Domain Name: EXAMPLE.COM", + "Registrar: TEST-REG", + "Creation Date: 2020-01-01T00:00:00Z", + "Registry Expiry Date: 2030-01-01T00:00:00Z", + "Updated Date: 2024-01-01T00:00:00Z", + "Name Server: NS2.EXAMPLE.COM", + "Status: clientTransferProhibited", + "", + }, "\n")) + defer shutdown() + + c := NewClient() + got, meta, err := c.Lookup("example.com", + WithLookupMode(LookupModeAutoPreferRDAP), + WithLookupRDAPClient(rdc), + WithLookupWhoisOptions(QueryOptions{ + Level: QueryAuto, + OverrideServer: whoisAddr, + }), + ) + if err != nil { + t.Fatalf("Lookup() error: %v", err) + } + if meta.Source != LookupSourceWHOIS { + t.Fatalf("unexpected source: %s", meta.Source) + } + if got.Registrar() != "TEST-REG" { + t.Fatalf("unexpected registrar: %q", got.Registrar()) + } + if !got.HasExpireDate() { + t.Fatal("expected expire date from whois fallback") + } + if len(meta.WarningList) == 0 { + t.Fatal("expected fallback warning") + } +} + +func TestLookupBothPreferRDAPMerge(t *testing.T) { + rdapSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ +"objectClassName":"domain", +"ldhName":"example.com", +"status":["active"], +"nameservers":[{"ldhName":"ns1.example.com"}] +}`)) + })) + defer rdapSrv.Close() + + rdc, err := NewRDAPClientWithBootstrap(&RDAPBootstrap{ + Version: "1.0", + Services: []RDAPService{ + {TLDs: []string{"com"}, URLs: []string{rdapSrv.URL}}, + }, + }) + if err != nil { + t.Fatalf("NewRDAPClientWithBootstrap() error: %v", err) + } + + whoisAddr, shutdown := startMockWhoisServer(t, strings.Join([]string{ + "Domain Name: EXAMPLE.COM", + "Registrar: TEST-REG", + "Name Server: NS2.EXAMPLE.COM", + "Status: clientTransferProhibited", + "", + }, "\n")) + defer shutdown() + + c := NewClient() + got, meta, err := c.LookupContext(context.Background(), "example.com", + WithLookupMode(LookupModeBothPreferRDAP), + WithLookupRDAPClient(rdc), + WithLookupWhoisOptions(QueryOptions{OverrideServer: whoisAddr, Level: QueryAuto}), + ) + if err != nil { + t.Fatalf("LookupContext() error: %v", err) + } + if meta.Source != LookupSourceMerged { + t.Fatalf("unexpected source: %s", meta.Source) + } + if got.Registrar() != "TEST-REG" { + t.Fatalf("expected merged registrar from whois, got %q", got.Registrar()) + } + if len(got.NsServers()) != 2 { + t.Fatalf("expected merged ns servers, got %#v", got.NsServers()) + } +} + +func TestLookupRDAPHostsOps(t *testing.T) { + rdapSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rdap/domain/example.com" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"objectClassName":"domain","ldhName":"example.com"}`)) + })) + defer rdapSrv.Close() + + u, err := url.Parse(rdapSrv.URL) + if err != nil { + t.Fatalf("url.Parse() error: %v", err) + } + fakeHost := "rdap.invalid" + fakeBase := "http://" + fakeHost + ":" + u.Port() + "/rdap/" + + rdc, err := NewRDAPClientWithBootstrap(&RDAPBootstrap{ + Version: "1.0", + Services: []RDAPService{ + {TLDs: []string{"com"}, URLs: []string{fakeBase}}, + }, + }) + if err != nil { + t.Fatalf("NewRDAPClientWithBootstrap() error: %v", err) + } + + c := NewClient() + _, _, err = c.Lookup("example.com", + WithLookupMode(LookupModeRDAPOnly), + WithLookupRDAPClient(rdc), + ) + if err == nil { + t.Fatal("expected rdap-only lookup to fail without hosts override") + } + + got, meta, err := c.Lookup("example.com", + WithLookupMode(LookupModeRDAPOnly), + WithLookupRDAPClient(rdc), + WithLookupRDAPOps(LookupRDAPOps{ + Hosts: map[string]string{ + fakeHost: "127.0.0.1", + }, + }), + ) + if err != nil { + t.Fatalf("Lookup() with rdap hosts ops error: %v", err) + } + if meta.Source != LookupSourceRDAP || !got.Exists() { + t.Fatalf("unexpected result source=%s exists=%v", meta.Source, got.Exists()) + } +} + +func TestLookupWHOISTimeoutOps(t *testing.T) { + whoisAddr, shutdown := startMockWhoisServerWithDelay(t, strings.Join([]string{ + "Domain Name: EXAMPLE.COM", + "Registrar: TEST-REG", + "", + }, "\n"), 300*time.Millisecond) + defer shutdown() + + c := NewClient() + _, _, err := c.Lookup("example.com", + WithLookupMode(LookupModeWHOISOnly), + WithLookupWhoisOptions(QueryOptions{OverrideServer: whoisAddr, Level: QueryAuto}), + WithLookupWHOISTimeout(80*time.Millisecond), + ) + if err == nil { + t.Fatal("expected whois timeout error") + } +} + +func TestLookupWhoisOnlyIgnoresRDAPProxyOps(t *testing.T) { + whoisAddr, shutdown := startMockWhoisServer(t, strings.Join([]string{ + "Domain Name: EXAMPLE.COM", + "Registrar: TEST-REG", + "", + }, "\n")) + defer shutdown() + + c := NewClient() + got, meta, err := c.Lookup("example.com", + WithLookupMode(LookupModeWHOISOnly), + WithLookupWhoisOptions(QueryOptions{OverrideServer: whoisAddr, Level: QueryAuto}), + WithLookupRDAPProxy("http://127.0.0.1:1"), + ) + if err != nil { + t.Fatalf("whois-only lookup should not be affected by rdap proxy ops: %v", err) + } + if !got.Exists() || meta.Source != LookupSourceWHOIS { + t.Fatalf("unexpected whois-only result source=%s exists=%v", meta.Source, got.Exists()) + } +} + +func TestLookupWhoisOnlyInvalidCommonProxyFailsFast(t *testing.T) { + c := NewClient() + _, _, err := c.Lookup("example.com", + WithLookupMode(LookupModeWHOISOnly), + WithLookupProxy("http://127.0.0.1:8080"), + ) + if err == nil { + t.Fatal("expected whois-only lookup to fail on unsupported common proxy") + } + if !strings.Contains(err.Error(), "whois proxy unsupported") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLookupAutoUsesTLDStrategyWhoisOnly(t *testing.T) { + var rdapHit int32 + rdapSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&rdapHit, 1) + _, _ = w.Write([]byte(`{"objectClassName":"domain","ldhName":"example.com"}`)) + })) + defer rdapSrv.Close() + + rdc, err := NewRDAPClientWithBootstrap(&RDAPBootstrap{ + Version: "1.0", + Services: []RDAPService{ + {TLDs: []string{"com"}, URLs: []string{rdapSrv.URL}}, + }, + }) + if err != nil { + t.Fatalf("NewRDAPClientWithBootstrap() error: %v", err) + } + + whoisAddr, shutdown := startMockWhoisServer(t, strings.Join([]string{ + "Domain Name: EXAMPLE.COM", + "Registrar: TEST-REG", + "", + }, "\n")) + defer shutdown() + + c := NewClient() + got, meta, err := c.Lookup("example.com", + WithLookupMode(LookupModeAutoPreferRDAP), + WithLookupTLDStrategy("com", LookupModeWHOISOnly), + WithLookupRDAPClient(rdc), + WithLookupWhoisOptions(QueryOptions{OverrideServer: whoisAddr, Level: QueryAuto}), + ) + if err != nil { + t.Fatalf("Lookup() error: %v", err) + } + if meta.Mode != LookupModeWHOISOnly { + t.Fatalf("unexpected resolved mode: %s", meta.Mode) + } + if meta.Source != LookupSourceWHOIS || !got.Exists() { + t.Fatalf("unexpected result source=%s exists=%v", meta.Source, got.Exists()) + } + if atomic.LoadInt32(&rdapHit) != 0 { + t.Fatalf("rdap should not be called when strategy forces whois-only, hits=%d", atomic.LoadInt32(&rdapHit)) + } +} + +func TestLookupStrategyDefaultMode(t *testing.T) { + whoisAddr, shutdown := startMockWhoisServer(t, strings.Join([]string{ + "Domain Name: EXAMPLE.NET", + "Registrar: TEST-REG", + "", + }, "\n")) + defer shutdown() + + c := NewClient() + got, meta, err := c.Lookup("example.net", + WithLookupMode(LookupModeAutoPreferRDAP), + WithLookupStrategyDefaultMode(LookupModeWHOISOnly), + WithLookupWhoisOptions(QueryOptions{OverrideServer: whoisAddr, Level: QueryAuto}), + ) + if err != nil { + t.Fatalf("Lookup() error: %v", err) + } + if meta.Mode != LookupModeWHOISOnly { + t.Fatalf("unexpected resolved mode: %s", meta.Mode) + } + if meta.Source != LookupSourceWHOIS || !got.Exists() { + t.Fatalf("unexpected source=%s exists=%v", meta.Source, got.Exists()) + } +} + +func TestLookupRDAPBootstrapConvenienceOptions(t *testing.T) { + cacheKey := t.Name() + "-cache" + ClearRDAPBootstrapLayeredCache(cacheKey) + + rdapSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rdap/domain/example.com" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"objectClassName":"domain","ldhName":"example.com"}`)) + })) + defer rdapSrv.Close() + + bootstrapRaw := `{"version":"1.0","services":[[["com"],["` + rdapSrv.URL + `/rdap/"]]]}` + bootstrapSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(bootstrapRaw)) + })) + defer bootstrapSrv.Close() + + c := NewClient() + got, meta, err := c.Lookup("example.com", + WithLookupMode(LookupModeRDAPOnly), + WithLookupRDAPBootstrapRemoteRefresh(true, bootstrapSrv.URL), + WithLookupRDAPBootstrapCache(time.Minute, cacheKey), + ) + if err != nil { + t.Fatalf("Lookup() error: %v", err) + } + if meta.Source != LookupSourceRDAP || !got.Exists() { + t.Fatalf("unexpected source=%s exists=%v", meta.Source, got.Exists()) + } +} + +func TestLookupRDAPBootstrapIgnoreRemoteError(t *testing.T) { + cacheKey := t.Name() + "-cache" + ClearRDAPBootstrapLayeredCache(cacheKey) + + rdapSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rdap/domain/example.com" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"objectClassName":"domain","ldhName":"example.com"}`)) + })) + defer rdapSrv.Close() + + localRaw := `{"version":"1.0","services":[[["com"],["` + rdapSrv.URL + `/rdap/"]]]}` + localPath := filepath.Join(t.TempDir(), "rdap_local.json") + if err := os.WriteFile(localPath, []byte(localRaw), 0644); err != nil { + t.Fatalf("write local bootstrap failed: %v", err) + } + + remoteSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "refresh failed", http.StatusInternalServerError) + })) + defer remoteSrv.Close() + + c := NewClient() + got, meta, err := c.Lookup("example.com", + WithLookupMode(LookupModeRDAPOnly), + WithLookupRDAPBootstrapLocalFiles(localPath), + WithLookupRDAPBootstrapRemoteRefresh(true, remoteSrv.URL), + WithLookupRDAPBootstrapIgnoreRemoteError(true), + WithLookupRDAPBootstrapCache(time.Minute, cacheKey), + ) + if err != nil { + t.Fatalf("Lookup() with ignore remote error failed: %v", err) + } + if meta.Source != LookupSourceRDAP || !got.Exists() { + t.Fatalf("unexpected source=%s exists=%v", meta.Source, got.Exists()) + } +} + +func startMockWhoisServer(t *testing.T, response string) (addr string, shutdown func()) { + return startMockWhoisServerWithDelay(t, response, 0) +} + +func startMockWhoisServerWithDelay(t *testing.T, response string, delay time.Duration) (addr string, shutdown func()) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen mock whois failed: %v", err) + } + done := make(chan struct{}) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-done: + return + default: + return + } + } + go func(c net.Conn) { + defer c.Close() + _ = c.SetDeadline(time.Now().Add(5 * time.Second)) + _, _ = bufio.NewReader(c).ReadString('\n') + if delay > 0 { + time.Sleep(delay) + } + _, _ = io.WriteString(c, response) + }(conn) + } + }() + return ln.Addr().String(), func() { + close(done) + _ = ln.Close() + } +} diff --git a/parse_alias.go b/parse_alias.go new file mode 100644 index 0000000..528e6b2 --- /dev/null +++ b/parse_alias.go @@ -0,0 +1,154 @@ +package whois + +import "strings" + +var notFoundTokens = []string{ + "no object found", + "domain not found", + "no match for", + "no match", + "no data found", + "no entries found", + "no matching record", + "no found", + "query returned 0 objects", + "does not have any data for", + "domain unknown", + "domain has not been registered", + "the domain name is not available", + "domain name is not available", + "reserved name", + "reserved domain name", + "cannot be registered", + "can not be registered", + "the queried object does not exist", +} + +var accessDeniedTokens = []string{ + "this tld has no whois server", + "requests of this client are not permitted", + "restricted to specifically qualified registrants", + "ip address used to perform the query is not authorised", + "ip address used to perform the query is not authorised", +} + +func isNotFoundLine(line string) bool { + return isStrictNotFoundLine(line) || isAccessDeniedLine(line) +} + +func isStrictNotFoundLine(line string) bool { + l := strings.ToLower(strings.TrimSpace(line)) + if l == "" { + return false + } + for _, t := range notFoundTokens { + if strings.Contains(l, t) { + return true + } + } + return false +} + +func isAccessDeniedLine(line string) bool { + l := strings.ToLower(strings.TrimSpace(line)) + if l == "" { + return false + } + for _, t := range accessDeniedTokens { + if strings.Contains(l, t) { + return true + } + } + return false +} + +func rawHasNotFound(raw string) bool { + for _, line := range strings.Split(raw, "\n") { + if isStrictNotFoundLine(line) { + return true + } + } + return false +} + +func rawHasAccessDenied(raw string) bool { + for _, line := range strings.Split(raw, "\n") { + if isAccessDeniedLine(line) { + return true + } + } + return false +} + +func splitKV(line string) (key, val string, ok bool) { + if i := strings.Index(line, ":"); i > 0 { + key = strings.TrimSpace(line[:i]) + if key == "" || strings.Contains(key, "://") { + return "", "", false + } + return key, strings.TrimSpace(line[i+1:]), true + } + return "", "", false +} + +func normalizeKey(k string) string { + k = strings.ToLower(strings.TrimSpace(k)) + if k == "" { + return "" + } + + for strings.Contains(k, "..") { + k = strings.ReplaceAll(k, "..", ".") + } + k = strings.Trim(k, ". ") + + replacer := strings.NewReplacer( + ".", " ", + "\t", " ", + "_", " ", + "-", " ", + "/", " ", + ) + k = replacer.Replace(k) + k = strings.Join(strings.Fields(k), " ") + return k +} + +func canonicalKey(k string) string { + n := normalizeKey(k) + switch n { + case "domain", "domain name", "domainname", "nom de domaine": + return "domain" + case "registry domain id", "domain id", "roid": + return "domain_id" + case "updated date", "last modified", "domain record last updated", "changed", "record last updated on", "last updated": + return "updated_at" + case "creation date", "registration time", "registered", "domain record activated", "created", "record created", "domain created": + return "created_at" + case "registry expiry date", "registrar registration expiration date", "expiration time", "domain expires", "expire", "expires", "record expires on", "renewal date": + return "expired_at" + case "registrar", "sponsoring registrar", "registrar name", "registration service provider", "current registar", "registar created", "registrar handle": + return "registrar" + case "status", "domain status", "registration status", "domain state", "domaintype": + return "status" + case "name server", "name servers", "name server information", "nameserver", "nameservers", "nserver", "primary server", "secondary server": + return "nameserver" + case "dnssec", "ds records": + return "dnssec" + } + + if strings.Contains(n, "modification") && strings.Contains(n, "derni") { + return "updated_at" + } + if strings.Contains(n, "date d expiration") || strings.Contains(n, "date de expiration") { + return "expired_at" + } + if strings.Contains(n, "date de c") && strings.Contains(n, "ation") { + return "created_at" + } + if strings.HasPrefix(n, "name server") || strings.HasPrefix(n, "nameserver") || strings.HasPrefix(n, "name servers in") { + return "nameserver" + } + + return n +} diff --git a/parse_common.go b/parse_common.go new file mode 100644 index 0000000..e1335a3 --- /dev/null +++ b/parse_common.go @@ -0,0 +1,430 @@ +package whois + +import ( + "strings" +) + +func commonParser(domain, data string) (Result, error) { + p := newCommonParsePipeline(domain, data) + p.run() + return p.result, nil +} + +type commonParsePipeline struct { + queryDomain string + result Result + register PersonalInfo + admin PersonalInfo + tech PersonalInfo + statusSet map[string]struct{} + pendingKey string + terminated bool +} + +func newCommonParsePipeline(domain, data string) *commonParsePipeline { + return &commonParsePipeline{ + queryDomain: strings.ToLower(strings.Trim(strings.TrimSpace(domain), ".")), + result: Result{ + domain: strings.TrimSpace(domain), + rawData: data, + }, + statusSet: make(map[string]struct{}), + } +} + +func (p *commonParsePipeline) run() { + for _, rawLine := range splitParseLines(p.result.rawData) { + if p.terminated { + break + } + p.consumeLine(rawLine) + } + p.finalize() +} + +func splitParseLines(raw string) []string { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + return strings.Split(raw, "\n") +} + +func (p *commonParsePipeline) consumeLine(rawLine string) { + line := strings.TrimSpace(rawLine) + if line == "" { + p.onBlankLine() + return + } + + if p.handleTerminalLine(line) { + return + } + if p.handlePendingLine(line) { + return + } + if p.handleKVLine(line) { + return + } + p.handleInlineLine(line) +} + +func (p *commonParsePipeline) onBlankLine() { + if p.pendingKey == "status" || p.pendingKey == "domain" { + p.pendingKey = "" + } +} + +func (p *commonParsePipeline) handleTerminalLine(line string) bool { + if !isNotFoundLine(line) { + return false + } + if p.result.exists && p.result.hasRegisterDate { + return false + } + p.result.exists = false + p.pendingKey = "" + p.terminated = true + return true +} + +func (p *commonParsePipeline) handlePendingLine(line string) bool { + if p.pendingKey == "" { + return false + } + switch p.pendingKey { + case "domain": + if d := extractDomainToken(line); d != "" { + p.result.exists = true + p.result.domain = d + p.pendingKey = "" + return true + } + p.pendingKey = "" + return false + case "nameserver": + if ns, ok := parseNameServerCandidate(line); ok { + p.result.nsServers = appendUnique(p.result.nsServers, ns) + return true + } + p.pendingKey = "" + return false + case "status": + if s := firstToken(line); s != "" { + p.statusSet[s] = struct{}{} + return true + } + p.pendingKey = "" + return false + default: + p.pendingKey = "" + return false + } +} + +func (p *commonParsePipeline) handleKVLine(line string) bool { + key, val, ok := splitKV(line) + if !ok { + return false + } + + canonical := canonicalKey(key) + if val == "" { + switch canonical { + case "domain", "nameserver", "status": + p.pendingKey = canonical + return true + } + } + + if applyCanonicalKV(&p.result, canonical, val, p.statusSet) { + return true + } + applyContactAlias(key, val, &p.register, &p.admin, &p.tech) + return true +} + +func (p *commonParsePipeline) handleInlineLine(line string) { + if d, ok := parseInlineDomain(line); ok { + p.result.exists = true + p.result.domain = d + } +} + +func (p *commonParsePipeline) finalize() { + if p.result.exists && strings.TrimSpace(p.result.domain) == "" { + if d := extractDomainFromRaw(p.result.rawData, p.queryDomain); d != "" { + p.result.domain = d + } + } + if p.result.exists && strings.TrimSpace(p.result.registrar) == "" { + if v := extractDomainManagersName(p.result.rawData); v != "" { + p.result.registrar = v + } + } + for s := range p.statusSet { + p.result.statusRaw = append(p.result.statusRaw, s) + } + p.result.registerInfo = p.register + p.result.adminInfo = p.admin + p.result.techInfo = p.tech +} + +func applyCanonicalKV(res *Result, canonical, val string, statusSet map[string]struct{}) bool { + switch canonical { + case "domain": + res.exists = true + if d := extractDomainToken(val); d != "" { + res.domain = d + } + return true + case "domain_id": + res.domainID = strings.TrimSpace(val) + return true + case "updated_at": + if d := parseDateAuto(val); !d.IsZero() { + res.hasUpdateDate = true + res.updateDate = d + } + return true + case "created_at": + if d := parseDateAuto(val); !d.IsZero() { + res.hasRegisterDate = true + res.registerDate = d + } + return true + case "expired_at": + if d := parseDateAuto(val); !d.IsZero() { + res.hasExpireDate = true + res.expireDate = d + } + return true + case "registrar": + res.registrar = strings.TrimSpace(val) + return true + case "status": + if s := firstToken(val); s != "" { + statusSet[s] = struct{}{} + } + return true + case "nameserver": + if ns, ok := parseNameServerCandidate(val); ok { + res.nsServers = appendUnique(res.nsServers, ns) + } else if v := strings.TrimSpace(val); v != "" { + res.nsServers = appendUnique(res.nsServers, v) + } + return true + case "dnssec": + res.dnssec = strings.TrimSpace(val) + return true + default: + return false + } +} + +func applyContactAlias(key, val string, r, a, t *PersonalInfo) { + k := normalizeKey(key) + switch k { + case "registrant name": + r.Name = val + case "registrant organization": + r.Org = val + case "registrant street": + r.Addr = val + case "registrant city": + r.City = val + case "registrant state province": + r.State = val + case "registrant postal code": + r.Zip = val + case "registrant country": + r.Country = val + case "registrant phone": + r.Phone = val + case "registrant phone ext": + r.PhoneExt = val + case "registrant fax": + r.Fax = val + case "registrant fax ext": + r.FaxExt = val + case "registrant email", "registrant contact email": + r.Email = val + + case "admin name": + a.Name = val + case "admin organization": + a.Org = val + case "admin street": + a.Addr = val + case "admin city": + a.City = val + case "admin state province": + a.State = val + case "admin postal code": + a.Zip = val + case "admin country": + a.Country = val + case "admin phone": + a.Phone = val + case "admin phone ext": + a.PhoneExt = val + case "admin fax": + a.Fax = val + case "admin fax ext": + a.FaxExt = val + case "admin email": + a.Email = val + + case "tech name", "tech contact name": + t.Name = val + case "tech organization", "tech contact organisation", "tech contact organization": + t.Org = val + case "tech street": + t.Addr = val + case "tech city": + t.City = val + case "tech state province": + t.State = val + case "tech postal code": + t.Zip = val + case "tech country": + t.Country = val + case "tech phone": + t.Phone = val + case "tech phone ext": + t.PhoneExt = val + case "tech fax": + t.Fax = val + case "tech fax ext": + t.FaxExt = val + case "tech email", "tech contact email": + t.Email = val + } +} + +func firstToken(s string) string { + fields := strings.Fields(strings.TrimSpace(s)) + if len(fields) == 0 { + return "" + } + return fields[0] +} + +func parseNameServerCandidate(s string) (string, bool) { + d := extractDomainToken(s) + if d == "" { + return "", false + } + if isIPv4Token(d) { + return "", false + } + return d, true +} + +func parseInlineDomain(line string) (string, bool) { + l := strings.ToLower(strings.TrimSpace(line)) + if !strings.HasPrefix(l, "domain ") { + return "", false + } + if strings.HasPrefix(l, "domain name") || strings.HasPrefix(l, "domain status") || strings.HasPrefix(l, "domain information") || strings.HasPrefix(l, "domain managers") { + return "", false + } + d := extractDomainToken(line) + if d == "" { + return "", false + } + return d, true +} + +func extractDomainToken(s string) string { + for _, field := range strings.Fields(strings.TrimSpace(s)) { + token := strings.ToLower(strings.Trim(field, "[]()<>\"'`,;")) + token = strings.Trim(token, ".") + if token == "" { + continue + } + if strings.HasPrefix(token, "http://") || strings.HasPrefix(token, "https://") { + continue + } + if strings.Contains(token, "@") || strings.Contains(token, "/") { + continue + } + if strings.Count(token, ".") < 1 { + continue + } + return token + } + return "" +} + +func isIPv4Token(s string) bool { + parts := strings.Split(s, ".") + if len(parts) != 4 { + return false + } + for _, p := range parts { + if p == "" { + return false + } + for _, ch := range p { + if ch < '0' || ch > '9' { + return false + } + } + } + return true +} + +func appendUnique(list []string, v string) []string { + v = strings.TrimSpace(v) + if v == "" { + return list + } + for _, cur := range list { + if strings.EqualFold(cur, v) { + return list + } + } + return append(list, v) +} + +func extractDomainManagersName(raw string) string { + lines := splitParseLines(raw) + inManagers := false + waitingName := false + + for _, rawLine := range lines { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "%") { + continue + } + + key := normalizeKey(line) + if key == "domain managers" { + inManagers = true + waitingName = false + continue + } + if !inManagers { + continue + } + + if key == "name" { + waitingName = true + continue + } + if waitingName { + return strings.TrimSpace(line) + } + } + return "" +} + +func extractDomainFromRaw(raw, query string) string { + q := strings.ToLower(strings.TrimSpace(query)) + if q == "" { + return "" + } + if strings.Contains(strings.ToLower(raw), q) { + return q + } + return "" +} diff --git a/parse_common_unit_test.go b/parse_common_unit_test.go new file mode 100644 index 0000000..77a9a63 --- /dev/null +++ b/parse_common_unit_test.go @@ -0,0 +1,137 @@ +package whois + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCommonParserDateFlagsOnlyOnParseSuccess(t *testing.T) { + raw := strings.Join([]string{ + "Domain Name: EXAMPLE.COM", + "Creation Date: invalid-date", + "Updated Date: 2026-03-17T12:34:56+08:00", + "Registry Expiry Date: 2027-03-17T12:34:56+08:00", + "Registrar: TEST-REG", + }, "\n") + + got, err := commonParser("example.com", raw) + if err != nil { + t.Fatalf("commonParser returned error: %v", err) + } + + if !got.Exists() { + t.Fatal("expected domain to exist") + } + if got.HasRegisterDate() { + t.Fatal("expected register date flag false when creation date parsing fails") + } + if !got.HasUpdateDate() { + t.Fatal("expected update date flag true") + } + if !got.HasExpireDate() { + t.Fatal("expected expire date flag true") + } +} + +func TestParseCachedBinSamples(t *testing.T) { + tests := []struct { + name string + domain string + file string + wantExists bool + wantDomain string + minNS int + wantRegistrarContains string + }{ + {name: "ee", domain: "nic.ee", file: "nic.ee.txt", wantExists: true, wantDomain: "nic.ee", minNS: 1}, + {name: "fi", domain: "nic.fi", file: "nic.fi.txt", wantExists: true, wantDomain: "nic.fi", minNS: 2}, + {name: "gg", domain: "nic.gg", file: "nic.gg.txt", wantExists: true, wantDomain: "nic.gg", minNS: 2}, + {name: "je", domain: "nic.je", file: "nic.je.txt", wantExists: true, wantDomain: "nic.je", minNS: 2}, + {name: "kg", domain: "nic.kg", file: "nic.kg.txt", wantExists: true, wantDomain: "nic.kg", minNS: 1}, + {name: "kz", domain: "nic.kz", file: "nic.kz.txt", wantExists: true, wantDomain: "nic.kz", minNS: 2}, + {name: "lu", domain: "nic.lu", file: "nic.lu.txt", wantExists: true, wantDomain: "nic.lu", minNS: 2}, + {name: "md", domain: "nic.md", file: "nic.md.txt", wantExists: true, wantDomain: "nic.md", minNS: 2}, + {name: "im", domain: "nic.im", file: "nic.im.txt", wantExists: true, wantDomain: "nic.im", wantRegistrarContains: "im registry"}, + {name: "no", domain: "nic.no", file: "nic.no.txt", wantExists: true, wantDomain: "nic.no", wantRegistrarContains: "reg1-norid"}, + {name: "sn", domain: "nic.sn", file: "nic.sn.txt", wantExists: true, wantDomain: "nic.sn"}, + {name: "tn", domain: "nic.tn", file: "nic.tn.txt", wantExists: true, wantDomain: "nic.tn"}, + {name: "uk", domain: "nic.uk", file: "nic.uk.txt", wantExists: true, wantDomain: "nic.uk", minNS: 4}, + {name: "bn_empty", domain: "nic.bn", file: "nic.bn.txt", wantExists: false}, + {name: "ch_not_found", domain: "nic.ch", file: "nic.ch.txt", wantExists: false}, + {name: "es_access_denied", domain: "nic.es", file: "nic.es.txt", wantExists: false}, + {name: "int_not_found", domain: "nic.int", file: "nic.int.txt", wantExists: false}, + {name: "il_policy_only", domain: "nic.il", file: "nic.il.txt", wantExists: false}, + {name: "kr_restricted", domain: "nic.kr", file: "nic.kr.txt", wantExists: false}, + {name: "pf_not_found", domain: "nic.pf", file: "nic.pf.txt", wantExists: false}, + {name: "tw_reserved", domain: "nic.tw", file: "nic.tw.txt", wantExists: false}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + raw := readBinRaw(t, tc.file) + got, err := parse(tc.domain, raw) + if err != nil { + t.Fatalf("parse returned error: %v", err) + } + + if got.Exists() != tc.wantExists { + t.Fatalf("exists mismatch: got=%v want=%v", got.Exists(), tc.wantExists) + } + if tc.wantExists { + if strings.ToLower(got.Domain()) != tc.wantDomain { + t.Fatalf("domain mismatch: got=%q want=%q", got.Domain(), tc.wantDomain) + } + if tc.minNS > 0 && len(got.NsServers()) < tc.minNS { + t.Fatalf("ns count too small: got=%d want>=%d ns=%v", len(got.NsServers()), tc.minNS, got.NsServers()) + } + if tc.wantRegistrarContains != "" && !strings.Contains(strings.ToLower(got.Registrar()), strings.ToLower(tc.wantRegistrarContains)) { + t.Fatalf("registrar mismatch: got=%q want contains %q", got.Registrar(), tc.wantRegistrarContains) + } + } + }) + } +} + +func readBinRaw(t *testing.T, filename string) string { + t.Helper() + path := filepath.Join("bin", filename) + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} + +func TestCommonParserPipelineWithCRLFSections(t *testing.T) { + raw := strings.Join([]string{ + "Domain:", + " EXAMPLE.COM", + "Name servers:", + "", + " ns1.example.com", + " ns2.example.com", + "Status:", + " active", + "", + }, "\r\n") + + got, err := commonParser("example.com", raw) + if err != nil { + t.Fatalf("commonParser returned error: %v", err) + } + if !got.Exists() { + t.Fatal("expected exists=true") + } + if strings.ToLower(got.Domain()) != "example.com" { + t.Fatalf("unexpected domain: %q", got.Domain()) + } + if len(got.NsServers()) != 2 { + t.Fatalf("unexpected ns count: %d (%v)", len(got.NsServers()), got.NsServers()) + } + if len(got.Status()) != 1 || got.Status()[0] != "active" { + t.Fatalf("unexpected status: %v", got.Status()) + } +} diff --git a/parse_dispatch.go b/parse_dispatch.go new file mode 100644 index 0000000..8ab53e7 --- /dev/null +++ b/parse_dispatch.go @@ -0,0 +1,33 @@ +package whois + +func parse(domain string, result string) (Result, error) { + var out Result + var err error + switch getExtension(domain) { + case "cn": + out, err = dotCNParser(domain, result) + case "jp": + out, err = dotJPParser(domain, result) + case "tw": + out, err = dotTWParser(domain, result) + case "edu": + out, err = dotEduParser(domain, result) + case "int": + out, err = dotIntParser(domain, result) + case "am": + out, err = dotAmParser(domain, result) + case "mk": + out, err = dotMkParser(domain, result) + case "ar": + out, err = dotArParser(domain, result) + default: + out, err = commonParser(domain, result) + } + if err != nil { + return Result{}, err + } + if out.meta.Source == "" { + out.meta = buildResultMeta(out, "whois", out.whoisSer) + } + return out, nil +} diff --git a/parse_test.go b/parse_test.go index f09e7e5..7a3a488 100644 --- a/parse_test.go +++ b/parse_test.go @@ -6,9 +6,17 @@ import ( "testing" ) +func requireIntegration(t *testing.T) { + t.Helper() + if os.Getenv("WHOIS_INTEGRATION") != "1" { + t.Skip("set WHOIS_INTEGRATION=1 to run network whois integration tests") + } +} + func TestWhoisInfo(t *testing.T) { + requireIntegration(t) c := NewClient() - domain := "who.int" + domain := "ra.com" h, err := c.Whois(domain) if err != nil { t.Fatal(err) @@ -58,6 +66,7 @@ func TestWhoisInfo(t *testing.T) { } func TestWhois(t *testing.T) { + requireIntegration(t) os.MkdirAll("./bin", 0755) domainSuffix := []string{"com", "net", "org", "cn", "io", "me", "cc", "top", "xyz", "vip", "club", "site", "win", "bid", "loan", "ek", "kim", "ren", "ltd", "link", "red", "pro", "info", "mobi", "name", "tv", "ws", "asia", @@ -108,7 +117,8 @@ func TestWhois(t *testing.T) { os.WriteFile("./bin/"+domain+".txt", []byte(h.RawData()), 0644) if !h.exists { fmt.Println(idx, h.Domain(), "fail") - t.Fatal(domain, "not exists") + continue + //t.Fatal(domain, "not exists") } fmt.Println(idx, h.Domain(), "ok") fmt.Println(h.ExpireDate(), h.RegisterDate()) @@ -127,3 +137,112 @@ func TestWhois(t *testing.T) { fmt.Println(h.Status()) } } + +func TestWhoisFull(t *testing.T) { + requireIntegration(t) + os.MkdirAll("./bin", 0755) + + domainSuffix := []string{"com", "net", "org", "cn", "io", "me", "cc", "top", "xyz", "vip", "club", "site", "win", "bid", + "loan", "ek", "kim", "ren", "ltd", "link", "red", "pro", "info", "mobi", "name", "tv", "ws", "asia", + "biz", "gov", "edu", "mil", "int", "aero", "coop", "museum", "jobs", "travel", "xxx", + "ac", "ad", "ae", "af", "ag", "ai", "al", "am", "an", "ao", "aq", "ar", "as", "at", "au", "aw", "az", + "ba", "bb", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bm", "bn", "bo", "br", "bs", "bt", "bv", "bw", "by", "bz", + "ca", "cc", "cd", "cf", "cg", "ch", "ci", "ck", "cl", "cm", "cn", "co", "cr", "cu", "cv", "cx", "cy", "cz", + "de", "dj", "dk", "dm", "do", "dz", + "ec", "ee", "eg", "eh", "er", "es", "et", "eu", + "fi", "fj", "fk", "fm", "fo", "fr", + "ga", "gb", "gd", "ge", "gf", "gg", "gh", "gi", "gl", "gm", "gn", "gp", "gq", "gr", "gs", "gt", "gu", "gw", "gy", + "hk", "hm", "hn", "hr", "ht", "hu", + "id", "ie", "il", "im", "in", "io", "iq", "ir", "is", "it", + "je", "jm", "jo", "jp", + "ke", "kg", "kh", "ki", "km", "kn", "kp", "kr", "kw", "ky", "kz", + "la", "lb", "lc", "li", "lk", "lr", "ls", "lt", "lu", "lv", "ly", + "ma", "mc", "md", "mg", "mh", "mk", "ml", "mm", "mn", "mo", "mp", "mq", "mr", "ms", "mt", "mu", "mv", "mw", "mx", "my", "mz", + "na", "nc", "ne", "nf", "ng", "ni", "nl", "no", "np", "nr", "nu", "nz", + "om", + "pa", "pe", "pf", "pg", "ph", "pk", "pl", "pm", "pn", "pr", "ps", "pt", "pw", "py", + "qa", + "re", "ro", "ru", "rw", + "sa", "sb", "sc", "sd", "se", "sg", "sh", "si", "sj", "sk", "sl", "sm", "sn", "so", "sr", "st", "sv", "sy", "sz", + "tc", "td", "tf", "tg", "th", "tj", "tk", "tl", "tm", "tn", "to", "tp", "tr", "tt", "tv", "tw", "tz", + "ua", "ug", "uk", "us", "uy", "uz", + "va", "vc", "ve", "vg", "vi", "vn", "vu", + "wf", "ws", + "ye", "yt", "yu", + "za", "zm", "zw", + } + + prefix := "nic." + c := NewClient() + + var failedTLDs []string + + for idx, suffix := range domainSuffix { + + domain := prefix + suffix + if suffix == "cn" { + domain = "cnnic.cn" + } + if suffix == "int" { + domain = "who.int" + } + + h, _, err := c.Lookup(domain, WithLookupMode(LookupModeWHOISOnly), WithLookupProxy("socks5://127.0.0.1:29992")) + if err != nil { + // 网络/查询失败:记录失败,不退出 + fmt.Printf("[FAIL][NET] idx=%d tld=%s domain=%s err=%v\n", idx, suffix, domain, err) + failedTLDs = append(failedTLDs, suffix+"(net)") + continue + } + + // 解析失败判定(你可以按需要再加规则) + parseFailed := false + var failReason string + + if !h.Exists() { + parseFailed = true + failReason = "exists=false" + } else if h.Domain() == "" { + parseFailed = true + failReason = "domain empty" + } else { + // 可选严格校验 + if h.HasRegisterDate() && h.RegisterDate().IsZero() { + parseFailed = true + failReason = "register date zero" + } + if h.HasExpireDate() && h.ExpireDate().IsZero() { + parseFailed = true + failReason = "expire date zero" + } + } + + if parseFailed { + // 仅失败时写 bin + _ = os.WriteFile("./bin/"+domain+".txt", []byte(h.RawData()), 0644) + fmt.Printf("[FAIL][PARSE] idx=%d tld=%s domain=%s reason=%s -> raw saved\n", idx, suffix, domain, failReason) + failedTLDs = append(failedTLDs, suffix+"(parse)") + continue + } + + // 成功提示 + fmt.Printf("[OK] idx=%d tld=%s domain=%s register=%v expire=%v ns=%d\n", + idx, suffix, h.Domain(), h.RegisterDate(), h.ExpireDate(), len(h.NsServers())) + } + + // 最后汇总打印失败 tld + fmt.Println("====================================") + if len(failedTLDs) == 0 { + fmt.Println("[SUMMARY] all passed") + } else { + fmt.Printf("[SUMMARY] failed tlds (%d): %v\n", len(failedTLDs), failedTLDs) + } +} + +func TestParseDomain(t *testing.T) { + data, err := os.ReadFile("./bin/nic.me.txt") + if err != nil { + t.Fatal(err) + } + fmt.Println(parse("nic.me", string(data))) +} diff --git a/parse.go b/parse_tld.go similarity index 54% rename from parse.go rename to parse_tld.go index c50c97e..aa44a43 100644 --- a/parse.go +++ b/parse_tld.go @@ -5,284 +5,7 @@ import ( "time" ) -func parse(domain string, result string) (Result, error) { - ext := getExtension(domain) - var data Result - var err error - switch ext { - case "cn": - data, err = dotCNParser(domain, result) - case "jp": - data, err = dotJPParser(domain, result) - case "tw": - data, err = dotTWParser(domain, result) - case "name": - data, err = dotNameParser(domain, result) - default: - data, err = commonParser(domain, result) - case "edu": - data, err = dotEduParser(domain, result) - case "int": - data, err = dotIntParser(domain, result) - case "ae": - data, err = dotAeParser(domain, result) - case "ai": - data, err = commonParserWithDate(domain, result, false, false, false) - case "am": - data, err = dotAmParser(domain, result) - } - return data, err -} - -func commonParser(domain, data string) (Result, error) { - return commonParserWithDate(domain, data, true, true, true) -} - -func commonParserWithDate(domain, data string, hasCreate bool, hasExpire bool, hasUpdate bool) (Result, error) { - var res = Result{ - domain: domain, - rawData: data, - } - var r, a, t PersonalInfo - - split := strings.Split(data, "\n") - statusMap := make(map[string]struct{}) - for _, line := range split { - line = strings.TrimSpace(line) - if !res.exists { - for _, token := range []string{"No Object Found", "Domain not found.", "No match for", "No match", "No Data Found", "No entries found", "No match for domain", "No matching record", "No Found", "No Object"} { - if strings.HasPrefix(line, token) { - res.exists = false - return res, nil - } - } - } - if strings.HasPrefix(line, "Domain Name:") { - res.exists = true - res.hasUpdateDate = hasUpdate - res.hasRegisterDate = hasCreate - res.hasExpireDate = hasExpire - res.nsServers = []string{} - res.domain = strings.TrimSpace(strings.TrimPrefix(line, "Domain Name:")) - } - if strings.HasPrefix(line, "Registry Domain ID:") { - res.domainID = strings.TrimSpace(strings.TrimPrefix(line, "Registry Domain ID:")) - } - if strings.HasPrefix(line, "Updated Date:") { - tmpDate := parseDate(strings.TrimSpace(strings.TrimPrefix(line, "Updated Date:"))) - if !tmpDate.IsZero() { - res.updateDate = tmpDate - } - } - if strings.HasPrefix(line, "Creation Date:") { - tmpDate := parseDate(strings.TrimSpace(strings.TrimPrefix(line, "Creation Date:"))) - if !tmpDate.IsZero() { - res.registerDate = tmpDate - } - } - if strings.HasPrefix(line, "Registry Expiry Date:") { - tmpDate := parseDate(strings.TrimSpace(strings.TrimPrefix(line, "Registry Expiry Date:"))) - if !tmpDate.IsZero() { - res.expireDate = tmpDate - } - } - if strings.HasPrefix(line, "Registrar Registration Expiration Date:") { - tmpDate := parseDate(strings.TrimSpace(strings.TrimPrefix(line, "Registrar Registration Expiration Date:"))) - if !tmpDate.IsZero() { - res.expireDate = tmpDate - } - } - if strings.HasPrefix(line, "Registrar:") { - res.registar = strings.TrimSpace(strings.TrimPrefix(line, "Registrar:")) - } - if strings.HasPrefix(line, "Status:") { - statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), " ")[0]] = struct{}{} - } - if strings.HasPrefix(line, "Domain Status:") { - if strings.Contains(line, "No Object Found") { - res.exists = false - return res, nil - } - statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Domain Status:")), " ")[0]] = struct{}{} - } - if strings.HasPrefix(line, "Name Server:") { - res.nsServers = append(res.nsServers, strings.TrimSpace(strings.TrimPrefix(line, "Name Server:"))) - } - if strings.HasPrefix(line, "DNSSEC:") { - res.dnssec = strings.TrimSpace(strings.TrimPrefix(line, "DNSSEC:")) - } - if strings.HasPrefix(line, "Registrant Name:") { - r.Name = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Name:")) - } - if strings.HasPrefix(line, "Registrant Organization:") { - r.Org = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Organization:")) - } - if strings.HasPrefix(line, "Registrant Street:") { - r.Addr = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Street:")) - } - if strings.HasPrefix(line, "Registrant City:") { - r.City = strings.TrimSpace(strings.TrimPrefix(line, "Registrant City:")) - } - if strings.HasPrefix(line, "Registrant State/Province:") { - r.State = strings.TrimSpace(strings.TrimPrefix(line, "Registrant State/Province:")) - } - if strings.HasPrefix(line, "Registrant Postal Code:") { - r.Zip = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Postal Code:")) - } - if strings.HasPrefix(line, "Registrant Country:") { - r.Country = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Country:")) - } - if strings.HasPrefix(line, "Registrant Phone:") { - r.Phone = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Phone:")) - } - if strings.HasPrefix(line, "Registrant Phone Ext:") { - r.PhoneExt = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Phone Ext:")) - } - if strings.HasPrefix(line, "Registrant Fax:") { - r.Fax = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Fax:")) - } - if strings.HasPrefix(line, "Registrant Fax Ext:") { - r.FaxExt = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Fax Ext:")) - } - if strings.HasPrefix(line, "Registrant Email:") { - r.Email = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Email:")) - } - if strings.HasPrefix(line, "Admin Name:") { - a.Name = strings.TrimSpace(strings.TrimPrefix(line, "Admin Name:")) - } - if strings.HasPrefix(line, "Admin Organization:") { - a.Org = strings.TrimSpace(strings.TrimPrefix(line, "Admin Organization:")) - } - if strings.HasPrefix(line, "Admin Street:") { - a.Addr = strings.TrimSpace(strings.TrimPrefix(line, "Admin Street:")) - } - if strings.HasPrefix(line, "Admin City:") { - a.City = strings.TrimSpace(strings.TrimPrefix(line, "Admin City:")) - } - if strings.HasPrefix(line, "Admin State/Province:") { - a.State = strings.TrimSpace(strings.TrimPrefix(line, "Admin State/Province:")) - } - if strings.HasPrefix(line, "Admin Postal Code:") { - a.Zip = strings.TrimSpace(strings.TrimPrefix(line, "Admin Postal Code:")) - } - if strings.HasPrefix(line, "Admin Country:") { - a.Country = strings.TrimSpace(strings.TrimPrefix(line, "Admin Country:")) - } - if strings.HasPrefix(line, "Admin Phone:") { - a.Phone = strings.TrimSpace(strings.TrimPrefix(line, "Admin Phone:")) - } - if strings.HasPrefix(line, "Admin Phone Ext:") { - a.PhoneExt = strings.TrimSpace(strings.TrimPrefix(line, "Admin Phone Ext:")) - } - if strings.HasPrefix(line, "Admin Fax:") { - a.Fax = strings.TrimSpace(strings.TrimPrefix(line, "Admin Fax:")) - } - if strings.HasPrefix(line, "Admin Fax Ext:") { - a.FaxExt = strings.TrimSpace(strings.TrimPrefix(line, "Admin Fax Ext:")) - } - if strings.HasPrefix(line, "Admin Email:") { - a.Email = strings.TrimSpace(strings.TrimPrefix(line, "Admin Email:")) - } - if strings.HasPrefix(line, "Tech Name:") { - t.Name = strings.TrimSpace(strings.TrimPrefix(line, "Tech Name:")) - } - if strings.HasPrefix(line, "Tech Organization:") { - t.Org = strings.TrimSpace(strings.TrimPrefix(line, "Tech Organization:")) - } - if strings.HasPrefix(line, "Tech Street:") { - t.Addr = strings.TrimSpace(strings.TrimPrefix(line, "Tech Street:")) - } - if strings.HasPrefix(line, "Tech City:") { - t.City = strings.TrimSpace(strings.TrimPrefix(line, "Tech City:")) - } - if strings.HasPrefix(line, "Tech State/Province:") { - t.State = strings.TrimSpace(strings.TrimPrefix(line, "Tech State/Province:")) - } - if strings.HasPrefix(line, "Tech Postal Code:") { - t.Zip = strings.TrimSpace(strings.TrimPrefix(line, "Tech Postal Code:")) - } - if strings.HasPrefix(line, "Tech Country:") { - t.Country = strings.TrimSpace(strings.TrimPrefix(line, "Tech Country:")) - } - if strings.HasPrefix(line, "Tech Phone:") { - t.Phone = strings.TrimSpace(strings.TrimPrefix(line, "Tech Phone:")) - } - if strings.HasPrefix(line, "Tech Phone Ext:") { - t.PhoneExt = strings.TrimSpace(strings.TrimPrefix(line, "Tech Phone Ext:")) - } - if strings.HasPrefix(line, "Tech Fax:") { - t.Fax = strings.TrimSpace(strings.TrimPrefix(line, "Tech Fax:")) - } - if strings.HasPrefix(line, "Tech Fax Ext:") { - t.FaxExt = strings.TrimSpace(strings.TrimPrefix(line, "Tech Fax Ext:")) - } - if strings.HasPrefix(line, "Tech Email:") { - t.Email = strings.TrimSpace(strings.TrimPrefix(line, "Tech Email:")) - } - } - for status := range statusMap { - res.statusRaw = append(res.statusRaw, status) - } - res.registerInfo = r - res.adminInfo = a - res.techInfo = t - return res, nil -} - -func dotNameParser(domain, data string) (Result, error) { - var res = Result{ - domain: domain, - rawData: data, - } - var r, a, t PersonalInfo - - split := strings.Split(data, "\n") - statusMap := make(map[string]struct{}) - for _, line := range split { - line = strings.TrimSpace(line) - if !res.exists { - for _, token := range []string{"No match for"} { - if strings.HasPrefix(line, token) { - res.exists = false - return res, nil - } - } - } - if strings.HasPrefix(line, "Domain Name:") { - res.exists = true - res.hasUpdateDate = false - res.hasRegisterDate = false - res.hasExpireDate = false - res.nsServers = []string{} - res.domain = strings.TrimSpace(strings.TrimPrefix(line, "Domain Name:")) - } - if strings.HasPrefix(line, "Registry Domain ID:") { - res.domainID = strings.TrimSpace(strings.TrimPrefix(line, "Registry Domain ID:")) - } - - if strings.HasPrefix(line, "Registrar:") { - res.registar = strings.TrimSpace(strings.TrimPrefix(line, "Registrar:")) - } - if strings.HasPrefix(line, "Status:") { - statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), " ")[0]] = struct{}{} - } - if strings.HasPrefix(line, "Domain Status:") { - if strings.Contains(line, "No Object Found") { - res.exists = false - return res, nil - } - statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Domain Status:")), " ")[0]] = struct{}{} - } - } - for status := range statusMap { - res.statusRaw = append(res.statusRaw, status) - } - res.registerInfo = r - res.adminInfo = a - res.techInfo = t - return res, nil - -} +// ===== Specialized parsers kept from old parse.go ===== func dotCNParser(domain, data string) (Result, error) { var res = Result{ @@ -290,14 +13,17 @@ func dotCNParser(domain, data string) (Result, error) { rawData: data, } var r PersonalInfo - if strings.HasPrefix("No matching record.", strings.TrimSpace(data)) { + + trim := strings.TrimSpace(data) + if strings.HasPrefix(trim, "No matching record.") { res.exists = false return res, nil } - if strings.HasPrefix(strings.TrimSpace(data), "the Domain Name you apply can not be registered online") { + if strings.HasPrefix(trim, "the Domain Name you apply can not be registered online") { res.exists = false return res, nil } + res.hasUpdateDate = false res.hasRegisterDate = true res.hasExpireDate = true @@ -320,7 +46,7 @@ func dotCNParser(domain, data string) (Result, error) { res.expireDate = parseCNDate(strings.TrimSpace(strings.TrimPrefix(line, "Expiration Time:"))) } if strings.HasPrefix(line, "Sponsoring Registrar:") { - res.registar = strings.TrimSpace(strings.TrimPrefix(line, "Sponsoring Registrar:")) + res.registrar = strings.TrimSpace(strings.TrimPrefix(line, "Sponsoring Registrar:")) } if strings.HasPrefix(line, "Status:") { statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), " ")[0]] = struct{}{} @@ -402,6 +128,7 @@ func dotJPParser(domain, data string) (Result, error) { } if strings.HasPrefix(line, "[住所]") { r.Addr = strings.TrimSpace(strings.TrimPrefix(line, "[住所]")) + startAddress = true continue } if strings.HasPrefix(line, "[Postal Address]") { @@ -435,7 +162,7 @@ func dotTWParser(domain, data string) (Result, error) { var r, a, t, p PersonalInfo for idx, line := range split { line = strings.TrimSpace(line) - if strings.HasPrefix(line, "No Found") || strings.HasPrefix(line, "網域名稱不合規定") { + if strings.HasPrefix(line, "No Found") || strings.HasPrefix(line, "網域名稱不合規定") || strings.Contains(strings.ToLower(line), "reserved name") { res.exists = false return res, nil } @@ -449,7 +176,7 @@ func dotTWParser(domain, data string) (Result, error) { } if strings.HasPrefix(line, "Registration Service Provider:") { startNs = false - res.registar = strings.TrimSpace(strings.TrimPrefix(line, "Registration Service Provider:")) + res.registrar = strings.TrimSpace(strings.TrimPrefix(line, "Registration Service Provider:")) } if strings.HasPrefix(line, "Record created on") { res.registerDate = parseCNDate(strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "Record created on"), "(UTC+8)"))) @@ -470,6 +197,18 @@ func dotTWParser(domain, data string) (Result, error) { p = PersonalInfo{} continue } + if strings.HasPrefix(line, "Administrative Contact:") { + currentStatus = 2 + startIdx = idx + p = PersonalInfo{} + continue + } + if strings.HasPrefix(line, "Technical Contact:") { + currentStatus = 3 + startIdx = idx + p = PersonalInfo{} + continue + } if currentStatus > 0 { if line == "(Redacted for privacy)" { switch currentStatus { @@ -491,7 +230,7 @@ func dotTWParser(domain, data string) (Result, error) { p.Country = line } } - if startNs { + if startNs && line != "" { res.nsServers = append(res.nsServers, line) } } @@ -559,7 +298,7 @@ func dotEduParser(domain, data string) (Result, error) { startNs = true continue } - if startNs { + if startNs && line != "" { res.nsServers = append(res.nsServers, line) } if currentStatus > 0 { @@ -683,72 +422,6 @@ func dotIntParser(domain, data string) (Result, error) { return res, nil } -func dotAeParser(domain, data string) (Result, error) { - var res = Result{ - domain: domain, - rawData: data, - } - var r, a, t PersonalInfo - - split := strings.Split(data, "\n") - statusMap := make(map[string]struct{}) - for _, line := range split { - line = strings.TrimSpace(line) - if !res.exists { - for _, token := range []string{"No Data Found"} { - if strings.HasPrefix(line, token) { - res.exists = false - return res, nil - } - } - } - if strings.HasPrefix(line, "Domain Name:") { - res.exists = true - res.hasUpdateDate = false - res.hasRegisterDate = false - res.hasExpireDate = false - res.nsServers = []string{} - res.domain = strings.TrimSpace(strings.TrimPrefix(line, "Domain Name:")) - } - if strings.HasPrefix(line, "Registrar Name:") { - res.registar = strings.TrimSpace(strings.TrimPrefix(line, "Registrar Name:")) - } - if strings.HasPrefix(line, "Status:") { - statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), " ")[0]] = struct{}{} - } - if strings.HasPrefix(line, "Name Server:") { - res.nsServers = append(res.nsServers, strings.TrimSpace(strings.TrimPrefix(line, "Name Server:"))) - } - - if strings.HasPrefix(line, "Registrant Contact Name:") { - r.Name = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Contact Name:")) - } - if strings.HasPrefix(line, "Registrant Contact Organisation::") { - r.Org = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Contact Organisation:")) - } - if strings.HasPrefix(line, "Registrant Contact Email:") { - r.Email = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Contact Email:")) - } - - if strings.HasPrefix(line, "Tech Contact Name:") { - t.Name = strings.TrimSpace(strings.TrimPrefix(line, "Tech Contact Name:")) - } - if strings.HasPrefix(line, "Tech Contact Organisation::") { - t.Org = strings.TrimSpace(strings.TrimPrefix(line, "Tech Contact Organisation:")) - } - if strings.HasPrefix(line, "Tech Contact Email:") { - t.Email = strings.TrimSpace(strings.TrimPrefix(line, "Tech Contact Email:")) - } - } - for status := range statusMap { - res.statusRaw = append(res.statusRaw, status) - } - res.registerInfo = r - res.adminInfo = a - res.techInfo = t - return res, nil -} - func dotAmParser(domain, data string) (Result, error) { var res = Result{ domain: domain, @@ -784,21 +457,24 @@ func dotAmParser(domain, data string) (Result, error) { res.updateDate = parseYMDDate(strings.TrimSpace(strings.TrimPrefix(line, "Last modified:"))) } if strings.HasPrefix(line, "Registrar:") { - res.registar = strings.TrimSpace(strings.TrimPrefix(line, "Registrar:")) + res.registrar = strings.TrimSpace(strings.TrimPrefix(line, "Registrar:")) } if strings.HasPrefix(line, "Registrant:") { currentStatus = 1 p = PersonalInfo{} + tmpSlice = []string{} continue } if strings.HasPrefix(line, "Administrative contact:") { currentStatus = 2 p = PersonalInfo{} + tmpSlice = []string{} continue } if strings.HasPrefix(line, "Technical contact:") { currentStatus = 3 p = PersonalInfo{} + tmpSlice = []string{} continue } if strings.HasPrefix(line, "DNS servers (") { @@ -809,11 +485,14 @@ func dotAmParser(domain, data string) (Result, error) { tmp := strings.Split(line, "-") for idx, ns := range tmp { ns = strings.TrimSpace(ns) - if idx == 0 { - res.nsServers = append(res.nsServers, ns) + if ns == "" { continue } - res.nsIps = append(res.nsIps, ns) + if idx == 0 { + res.nsServers = append(res.nsServers, ns) + } else { + res.nsIps = append(res.nsIps, ns) + } } } if len(line) == 0 { @@ -824,30 +503,28 @@ func dotAmParser(domain, data string) (Result, error) { if len(line) == 0 { switch currentStatus { case 1: - p.Addr = strings.Join(tmpSlice, "\n") - tmpSlice = []string{} + p.Addr = strings.TrimSpace(strings.Join(tmpSlice, "\n")) r = p case 2: if len(tmpSlice) > 2 { - p.Addr = strings.Join(tmpSlice[:len(tmpSlice)-2], "\n") - p.Phone = tmpSlice[len(tmpSlice)-1] - p.Email = tmpSlice[len(tmpSlice)-2] + p.Addr = strings.TrimSpace(strings.Join(tmpSlice[:len(tmpSlice)-2], "\n")) + p.Email = strings.TrimSpace(tmpSlice[len(tmpSlice)-2]) + p.Phone = strings.TrimSpace(tmpSlice[len(tmpSlice)-1]) } else { - p.Addr = strings.Join(tmpSlice, "\n") + p.Addr = strings.TrimSpace(strings.Join(tmpSlice, "\n")) } - tmpSlice = []string{} a = p case 3: if len(tmpSlice) > 2 { - p.Addr = strings.Join(tmpSlice[:len(tmpSlice)-2], "\n") - p.Phone = tmpSlice[len(tmpSlice)-1] - p.Email = tmpSlice[len(tmpSlice)-2] + p.Addr = strings.TrimSpace(strings.Join(tmpSlice[:len(tmpSlice)-2], "\n")) + p.Email = strings.TrimSpace(tmpSlice[len(tmpSlice)-2]) + p.Phone = strings.TrimSpace(tmpSlice[len(tmpSlice)-1]) } else { - p.Addr = strings.Join(tmpSlice, "\n") + p.Addr = strings.TrimSpace(strings.Join(tmpSlice, "\n")) } - tmpSlice = []string{} t = p } + tmpSlice = []string{} currentStatus = 0 } } @@ -861,43 +538,212 @@ func dotAmParser(domain, data string) (Result, error) { return res, nil } -func parseDate(date string) time.Time { - t, err := time.Parse("2006-01-02T15:04:05Z", date) - if err == nil { - t = t.In(time.Local) - return t +func dotMkParser(domain, data string) (Result, error) { + var res = Result{ + domain: domain, + rawData: data, } - t, err = time.Parse("2006-01-02T15:04:05-0700", date) - t = t.In(time.Local) - return t + var r, a, t PersonalInfo + + split := strings.Split(data, "\n") + cid := 0 + for _, line := range split { + line = strings.TrimSpace(line) + if !res.exists { + for _, token := range []string{"%ERROR:101: no entries found"} { + if strings.HasPrefix(line, token) { + res.exists = false + return res, nil + } + } + } + if strings.HasPrefix(line, "domain:") { + res.exists = true + res.hasRegisterDate = true + res.hasExpireDate = true + res.nsServers = []string{} + res.domain = strings.TrimSpace(strings.TrimPrefix(line, "domain:")) + } + if strings.HasPrefix(line, "registered:") { + res.registerDate = parseMkDate(strings.TrimSpace(strings.TrimPrefix(line, "registered:")), 2) + } + if strings.HasPrefix(line, "expire:") { + res.expireDate = parseMkDate(strings.TrimSpace(strings.TrimPrefix(line, "expire:")), 2) + } + if strings.HasPrefix(line, "changed:") { + if res.updateDate.IsZero() { + res.updateDate = parseMkDate(strings.TrimSpace(strings.TrimPrefix(line, "changed:")), 2) + res.hasUpdateDate = true + } + } + if strings.HasPrefix(line, "registrar:") { + res.registrar = strings.TrimSpace(strings.TrimPrefix(line, "registrar:")) + } + + if strings.HasPrefix(line, "contact:") { + cid++ + continue + } + if strings.HasPrefix(line, "org:") { + switch cid { + case 1: + r.Org = strings.TrimSpace(strings.TrimPrefix(line, "org:")) + case 2: + a.Org = strings.TrimSpace(strings.TrimPrefix(line, "org:")) + } + continue + } + if strings.HasPrefix(line, "name:") { + switch cid { + case 1: + r.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:")) + case 2: + a.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:")) + } + continue + } + if strings.HasPrefix(line, "address:") { + switch cid { + case 1: + r.Addr += strings.TrimSpace(strings.TrimPrefix(line, "address:")) + "\n" + case 2: + a.Addr += strings.TrimSpace(strings.TrimPrefix(line, "address:")) + "\n" + } + continue + } + if strings.HasPrefix(line, "phone:") { + switch cid { + case 1: + r.Phone = strings.TrimSpace(strings.TrimPrefix(line, "phone:")) + case 2: + a.Phone = strings.TrimSpace(strings.TrimPrefix(line, "phone:")) + } + continue + } + if strings.HasPrefix(line, "e-mail:") { + switch cid { + case 1: + r.Email = strings.TrimSpace(strings.TrimPrefix(line, "e-mail:")) + case 2: + a.Email = strings.TrimSpace(strings.TrimPrefix(line, "e-mail:")) + } + continue + } + if strings.HasPrefix(line, "nserver:") { + res.nsServers = append(res.nsServers, strings.TrimSpace(strings.TrimPrefix(line, "nserver:"))) + } + } + r.Addr = strings.TrimSpace(r.Addr) + a.Addr = strings.TrimSpace(a.Addr) + res.registerInfo = r + res.adminInfo = a + res.techInfo = t + return res, nil } +func dotArParser(domain, data string) (Result, error) { + var res = Result{ + domain: domain, + rawData: data, + } + var r, a, t PersonalInfo + + split := strings.Split(data, "\n") + for _, line := range split { + line = strings.TrimSpace(line) + if !res.exists { + for _, token := range []string{"El dominio no se encuentra registrado en NIC Argentina"} { + if strings.HasPrefix(line, token) { + res.exists = false + return res, nil + } + } + } + if strings.HasPrefix(line, "domain:") { + res.exists = true + res.hasRegisterDate = true + res.hasExpireDate = true + res.nsServers = []string{} + res.domain = strings.TrimSpace(strings.TrimPrefix(line, "domain:")) + } + if strings.HasPrefix(line, "registered:") { + res.registerDate = parseYMDHMSDate(strings.TrimSpace(strings.TrimPrefix(line, "registered:")), -3) + } + if strings.HasPrefix(line, "expire:") { + res.expireDate = parseYMDHMSDate(strings.TrimSpace(strings.TrimPrefix(line, "expire:")), -3) + } + if strings.HasPrefix(line, "changed:") { + if res.updateDate.IsZero() { + res.updateDate = parseYMDHMSDate(strings.TrimSpace(strings.TrimPrefix(line, "changed:")), -3) + res.hasUpdateDate = true + } + } + if strings.HasPrefix(line, "registrar:") { + res.registrar = strings.TrimSpace(strings.TrimPrefix(line, "registrar:")) + } + + if strings.HasPrefix(line, "name:") { + r.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:")) + } + + if strings.HasPrefix(line, "nserver:") { + tmp := strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "nserver:")), " ") + for idx, ns := range tmp { + if idx == 0 { + res.nsServers = append(res.nsServers, ns) + continue + } + clean := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(ns, "(", ""), ")", "")) + if clean != "" { + res.nsIps = append(res.nsIps, clean) + } + } + } + } + r.Addr = strings.TrimSpace(r.Addr) + a.Addr = strings.TrimSpace(a.Addr) + res.registerInfo = r + res.adminInfo = a + res.techInfo = t + return res, nil +} + +// ===== Date helpers kept for specialized parsers ===== + func parseCNDate(date string) time.Time { t, _ := time.ParseInLocation("2006-01-02 15:04:05", date, time.FixedZone("CST", 8*3600)) - t = t.In(time.Local) - return t + return t.In(time.Local) } func parseJPDate(date string, onlyMD bool) time.Time { if onlyMD { t, _ := time.ParseInLocation("2006/01/02", date, time.FixedZone("JST", 9*3600)) - t = t.In(time.Local) - return t + return t.In(time.Local) } t, _ := time.ParseInLocation("2006/01/02 15:04:05 (JST)", date, time.FixedZone("JST", 9*3600)) - t = t.In(time.Local) - return t + return t.In(time.Local) } func parseEduDate(date string) time.Time { - //example 20-Dec-1996 t, _ := time.Parse("02-Jan-2006", date) - t = t.In(time.Local) - return t + return t.In(time.Local) } func parseYMDDate(date string) time.Time { t, _ := time.Parse("2006-01-02", date) - t = t.In(time.Local) - return t + return t.In(time.Local) +} + +func parseYMDHMSDate(date string, tz int) time.Time { + t, _ := time.ParseInLocation("2006-01-02 15:04:05", date, time.FixedZone("myzone", tz*3600)) + return t.In(time.Local) +} + +func parseMkDate(date string, tz int) time.Time { + t, err := time.ParseInLocation("02.01.2006 15:04:05", date, time.FixedZone("myzone", tz*3600)) + if err == nil { + return t.In(time.Local) + } + t, _ = time.Parse("02.01.2006", date) + return t.In(time.Local) } diff --git a/rdap_bootstrap.go b/rdap_bootstrap.go new file mode 100644 index 0000000..ed915ef --- /dev/null +++ b/rdap_bootstrap.go @@ -0,0 +1,697 @@ +package whois + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "b612.me/starnet" + "golang.org/x/sync/singleflight" +) + +const ( + // DefaultRDAPBootstrapURL is the IANA DNS RDAP bootstrap URL. + DefaultRDAPBootstrapURL = "https://data.iana.org/rdap/dns.json" +) + +var ( + //go:embed rdap_dns.json + embeddedRDAPDNSJSON []byte + + embeddedBootstrapOnce sync.Once + embeddedBootstrapVal *RDAPBootstrap + embeddedBootstrapErr error + + layeredBootstrapCache sync.Map + layeredBootstrapSF singleflight.Group + remoteBootstrapState sync.Map +) + +type layeredBootstrapCacheEntry struct { + Bootstrap *RDAPBootstrap + ExpireAt time.Time +} + +type RDAPBootstrapValidators struct { + ETag string + LastModified string +} + +type rdapBootstrapRemoteStateEntry struct { + Bootstrap *RDAPBootstrap + Validators RDAPBootstrapValidators + UpdatedAt time.Time +} + +// RDAPBootstrap represents parsed RDAP bootstrap data. +type RDAPBootstrap struct { + Version string `json:"version"` + Publication string `json:"publication,omitempty"` + Description string `json:"description,omitempty"` + Services []RDAPService `json:"services"` +} + +// RDAPService maps TLD labels to one or more RDAP base URLs. +type RDAPService struct { + TLDs []string `json:"tlds"` + URLs []string `json:"urls"` +} + +// RDAPBootstrapLoadOptions defines layered bootstrap loading behavior. +// Merge order is: embedded -> local files -> optional remote refresh. +type RDAPBootstrapLoadOptions struct { + LocalFiles []string + RefreshRemote bool + RemoteURL string + RemoteOpts []starnet.RequestOpt + IgnoreRemoteError bool + AllowStaleOnError bool + CacheTTL time.Duration + CacheKey string +} + +// Clone returns a shallow-safe copy (slice fields are copied). +func (o RDAPBootstrapLoadOptions) Clone() RDAPBootstrapLoadOptions { + out := RDAPBootstrapLoadOptions{ + LocalFiles: copyStringSlice(o.LocalFiles), + RefreshRemote: o.RefreshRemote, + RemoteURL: strings.TrimSpace(o.RemoteURL), + IgnoreRemoteError: o.IgnoreRemoteError, + AllowStaleOnError: o.AllowStaleOnError, + CacheTTL: o.CacheTTL, + CacheKey: strings.TrimSpace(o.CacheKey), + } + if len(o.RemoteOpts) > 0 { + out.RemoteOpts = append([]starnet.RequestOpt(nil), o.RemoteOpts...) + } + return out +} + +func (o RDAPBootstrapLoadOptions) cacheKey() string { + if key := strings.TrimSpace(o.CacheKey); key != "" { + return key + } + parts := make([]string, 0, len(o.LocalFiles)) + for _, f := range o.LocalFiles { + f = strings.TrimSpace(f) + if f != "" { + parts = append(parts, f) + } + } + remoteURL := strings.TrimSpace(o.RemoteURL) + if remoteURL == "" { + remoteURL = DefaultRDAPBootstrapURL + } + return fmt.Sprintf("rdap-layered|local=%s|refresh=%t|remote=%s|ignore_remote_err=%t", strings.Join(parts, ","), o.RefreshRemote, remoteURL, o.IgnoreRemoteError) +} + +type rdapBootstrapWire struct { + Version string `json:"version"` + Publication string `json:"publication"` + Description string `json:"description"` + Services [][][]string `json:"services"` +} + +// ParseRDAPBootstrap parses IANA DNS RDAP bootstrap JSON data. +func ParseRDAPBootstrap(data []byte) (*RDAPBootstrap, error) { + if len(bytes.TrimSpace(data)) == 0 { + return nil, errors.New("whois/rdap: bootstrap json is empty") + } + + var wire rdapBootstrapWire + if err := json.Unmarshal(data, &wire); err != nil { + return nil, fmt.Errorf("whois/rdap: parse bootstrap json failed: %w", err) + } + + out := &RDAPBootstrap{ + Version: strings.TrimSpace(wire.Version), + Publication: strings.TrimSpace(wire.Publication), + Description: strings.TrimSpace(wire.Description), + Services: make([]RDAPService, 0, len(wire.Services)), + } + + for _, item := range wire.Services { + if len(item) < 2 { + continue + } + tlds := uniqueTLDs(item[0]) + urls := uniqueURLs(item[1]) + if len(tlds) == 0 || len(urls) == 0 { + continue + } + out.Services = append(out.Services, RDAPService{ + TLDs: tlds, + URLs: urls, + }) + } + + if len(out.Services) == 0 { + return nil, errors.New("whois/rdap: bootstrap has no valid services") + } + return out, nil +} + +// LoadRDAPBootstrapFromFile loads and parses bootstrap from local file. +func LoadRDAPBootstrapFromFile(path string) (*RDAPBootstrap, error) { + path = strings.TrimSpace(path) + if path == "" { + return nil, errors.New("whois/rdap: bootstrap file path is empty") + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("whois/rdap: read bootstrap file failed: %w", err) + } + return ParseRDAPBootstrap(data) +} + +// Clone returns a deep copy. +func (b *RDAPBootstrap) Clone() *RDAPBootstrap { + if b == nil { + return nil + } + out := &RDAPBootstrap{ + Version: b.Version, + Publication: b.Publication, + Description: b.Description, + Services: make([]RDAPService, 0, len(b.Services)), + } + for _, svc := range b.Services { + out.Services = append(out.Services, RDAPService{ + TLDs: copyStringSlice(svc.TLDs), + URLs: copyStringSlice(svc.URLs), + }) + } + return out +} + +// ServerMap builds a tld->rdap base urls map. +func (b *RDAPBootstrap) ServerMap() map[string][]string { + out := make(map[string][]string) + if b == nil { + return out + } + for _, svc := range b.Services { + for _, tld := range svc.TLDs { + key := normalizeRDAPTLD(tld) + if key == "" { + continue + } + out[key] = appendUniqueStrings(out[key], svc.URLs...) + } + } + return out +} + +// URLsForTLD returns rdap URLs for a tld. +func (b *RDAPBootstrap) URLsForTLD(tld string) []string { + m := b.ServerMap() + return copyStringSlice(m[normalizeRDAPTLD(tld)]) +} + +// MergeRDAPBootstraps merges multiple bootstraps. +// Later bootstrap entries override earlier urls for the same tld. +func MergeRDAPBootstraps(bootstraps ...*RDAPBootstrap) *RDAPBootstrap { + tldOrder := make([]string, 0, 512) + tldSeen := make(map[string]struct{}, 512) + tldURLs := make(map[string][]string, 512) + + out := &RDAPBootstrap{ + Version: "1.0", + } + + for _, b := range bootstraps { + if b == nil { + continue + } + if strings.TrimSpace(b.Version) != "" { + out.Version = strings.TrimSpace(b.Version) + } + if strings.TrimSpace(b.Publication) != "" { + out.Publication = strings.TrimSpace(b.Publication) + } + if strings.TrimSpace(b.Description) != "" { + out.Description = strings.TrimSpace(b.Description) + } + for _, svc := range b.Services { + urls := uniqueURLs(svc.URLs) + if len(urls) == 0 { + continue + } + for _, tld := range uniqueTLDs(svc.TLDs) { + if tld == "" { + continue + } + if _, ok := tldSeen[tld]; !ok { + tldSeen[tld] = struct{}{} + tldOrder = append(tldOrder, tld) + } + // Overlay behavior: latest layer wins for same tld. + tldURLs[tld] = copyStringSlice(urls) + } + } + } + + for _, tld := range tldOrder { + urls := uniqueURLs(tldURLs[tld]) + if len(urls) == 0 { + continue + } + out.Services = append(out.Services, RDAPService{ + TLDs: []string{tld}, + URLs: urls, + }) + } + return out +} + +// EmbeddedRDAPBootstrapJSON returns a copy of embedded bootstrap json bytes. +func EmbeddedRDAPBootstrapJSON() []byte { + out := make([]byte, len(embeddedRDAPDNSJSON)) + copy(out, embeddedRDAPDNSJSON) + return out +} + +// LoadEmbeddedRDAPBootstrap parses and returns embedded bootstrap data. +func LoadEmbeddedRDAPBootstrap() (*RDAPBootstrap, error) { + embeddedBootstrapOnce.Do(func() { + embeddedBootstrapVal, embeddedBootstrapErr = ParseRDAPBootstrap(embeddedRDAPDNSJSON) + }) + if embeddedBootstrapErr != nil { + return nil, embeddedBootstrapErr + } + return embeddedBootstrapVal.Clone(), nil +} + +// LoadRDAPBootstrapLayered loads bootstrap with layered merge: +// embedded -> local files -> optional remote refresh. +func LoadRDAPBootstrapLayered(ctx context.Context, opt RDAPBootstrapLoadOptions) (*RDAPBootstrap, error) { + base, err := LoadEmbeddedRDAPBootstrap() + if err != nil { + return nil, err + } + layers := []*RDAPBootstrap{base} + + for _, path := range opt.LocalFiles { + path = strings.TrimSpace(path) + if path == "" { + continue + } + b, err := LoadRDAPBootstrapFromFile(path) + if err != nil { + return nil, err + } + layers = append(layers, b) + } + + if opt.RefreshRemote { + url := strings.TrimSpace(opt.RemoteURL) + if url == "" { + url = DefaultRDAPBootstrapURL + } + state, _ := loadRDAPBootstrapRemoteState(url) + _, remote, validators, notModified, err := FetchRDAPBootstrapFromURLConditional(ctx, url, state.Validators, opt.RemoteOpts...) + if err != nil { + if !opt.IgnoreRemoteError { + return nil, err + } + } else { + if notModified { + if state.Bootstrap != nil { + layers = append(layers, state.Bootstrap.Clone()) + } + } else if remote != nil { + layers = append(layers, remote) + storeRDAPBootstrapRemoteState(url, rdapBootstrapRemoteStateEntry{ + Bootstrap: remote.Clone(), + Validators: validators, + UpdatedAt: time.Now(), + }) + } + } + } + + out := MergeRDAPBootstraps(layers...) + if out == nil || len(out.Services) == 0 { + return nil, errors.New("whois/rdap: layered bootstrap has no valid services") + } + return out, nil +} + +// LoadRDAPBootstrapLayeredCached loads layered bootstrap with optional cache. +// When CacheTTL <= 0, it falls back to direct layered load. +func LoadRDAPBootstrapLayeredCached(ctx context.Context, opt RDAPBootstrapLoadOptions) (*RDAPBootstrap, error) { + if opt.CacheTTL <= 0 { + return LoadRDAPBootstrapLayered(ctx, opt) + } + key := opt.cacheKey() + now := time.Now() + if cached, ok := loadLayeredBootstrapCacheFresh(key, now); ok { + return cached, nil + } + stale, hasStale := loadLayeredBootstrapCacheAny(key) + + v, err, _ := layeredBootstrapSF.Do(key, func() (interface{}, error) { + now := time.Now() + if cached, ok := loadLayeredBootstrapCacheFresh(key, now); ok { + return cached, nil + } + loaded, err := LoadRDAPBootstrapLayered(ctx, opt) + if err != nil { + return nil, err + } + layeredBootstrapCache.Store(key, layeredBootstrapCacheEntry{ + Bootstrap: loaded.Clone(), + ExpireAt: now.Add(opt.CacheTTL), + }) + return loaded.Clone(), nil + }) + if err != nil { + if opt.AllowStaleOnError && hasStale && stale != nil { + return stale.Clone(), nil + } + return nil, err + } + boot, ok := v.(*RDAPBootstrap) + if !ok || boot == nil { + return nil, errors.New("whois/rdap: invalid layered bootstrap cache result") + } + return boot.Clone(), nil +} + +// ClearRDAPBootstrapLayeredCache clears layered bootstrap cache. +// When keys are empty, all cache entries are removed. +func ClearRDAPBootstrapLayeredCache(keys ...string) { + if len(keys) == 0 { + layeredBootstrapCache.Range(func(k, _ interface{}) bool { + layeredBootstrapCache.Delete(k) + return true + }) + return + } + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + layeredBootstrapCache.Delete(key) + } +} + +func loadLayeredBootstrapCacheAny(key string) (*RDAPBootstrap, bool) { + v, ok := layeredBootstrapCache.Load(key) + if !ok { + return nil, false + } + entry, ok := v.(layeredBootstrapCacheEntry) + if !ok || entry.Bootstrap == nil { + layeredBootstrapCache.Delete(key) + return nil, false + } + return entry.Bootstrap.Clone(), true +} + +func loadLayeredBootstrapCacheFresh(key string, now time.Time) (*RDAPBootstrap, bool) { + v, ok := layeredBootstrapCache.Load(key) + if !ok { + return nil, false + } + entry, ok := v.(layeredBootstrapCacheEntry) + if !ok || entry.Bootstrap == nil { + layeredBootstrapCache.Delete(key) + return nil, false + } + if !entry.ExpireAt.After(now) { + return nil, false + } + return entry.Bootstrap.Clone(), true +} + +// FetchRDAPBootstrapFromURL fetches and parses bootstrap json from url. +func FetchRDAPBootstrapFromURL(ctx context.Context, bootstrapURL string, opts ...starnet.RequestOpt) ([]byte, *RDAPBootstrap, error) { + raw, bootstrap, _, _, err := FetchRDAPBootstrapFromURLConditional(ctx, bootstrapURL, RDAPBootstrapValidators{}, opts...) + return raw, bootstrap, err +} + +// FetchRDAPBootstrapFromURLConditional fetches bootstrap with conditional validators. +// When server returns 304, notModified is true and bootstrap is nil. +func FetchRDAPBootstrapFromURLConditional(ctx context.Context, bootstrapURL string, validators RDAPBootstrapValidators, opts ...starnet.RequestOpt) ([]byte, *RDAPBootstrap, RDAPBootstrapValidators, bool, error) { + bootstrapURL = strings.TrimSpace(bootstrapURL) + if bootstrapURL == "" { + return nil, nil, RDAPBootstrapValidators{}, false, errors.New("whois/rdap: bootstrap url is empty") + } + + reqOpts := []starnet.RequestOpt{ + starnet.WithHeader("Accept", "application/json"), + starnet.WithUserAgent("b612-whois-rdap-bootstrap/1.0"), + starnet.WithTimeout(20 * time.Second), + starnet.WithAutoFetch(true), + } + if etag := strings.TrimSpace(validators.ETag); etag != "" { + reqOpts = append(reqOpts, starnet.WithHeader("If-None-Match", etag)) + } + if lm := strings.TrimSpace(validators.LastModified); lm != "" { + reqOpts = append(reqOpts, starnet.WithHeader("If-Modified-Since", lm)) + } + reqOpts = append(reqOpts, opts...) + + resp, err := starnet.NewSimpleRequestWithContext(ctx, bootstrapURL, http.MethodGet, reqOpts...).Do() + if err != nil { + return nil, nil, RDAPBootstrapValidators{}, false, fmt.Errorf("whois/rdap: fetch bootstrap failed: %w", err) + } + defer resp.Close() + outValidators := mergeBootstrapValidators(validators, resp.Header.Get("ETag"), resp.Header.Get("Last-Modified")) + + if resp.StatusCode == http.StatusNotModified { + return nil, nil, outValidators, true, nil + } + + body, err := resp.Body().Bytes() + if err != nil { + return nil, nil, RDAPBootstrapValidators{}, false, fmt.Errorf("whois/rdap: read bootstrap body failed: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, nil, RDAPBootstrapValidators{}, false, fmt.Errorf("whois/rdap: fetch bootstrap status=%d", resp.StatusCode) + } + + bootstrap, err := ParseRDAPBootstrap(body) + if err != nil { + return nil, nil, RDAPBootstrapValidators{}, false, err + } + return body, bootstrap, outValidators, false, nil +} + +// FetchLatestRDAPBootstrap fetches and parses bootstrap from IANA default url. +func FetchLatestRDAPBootstrap(ctx context.Context, opts ...starnet.RequestOpt) ([]byte, *RDAPBootstrap, error) { + return FetchRDAPBootstrapFromURL(ctx, DefaultRDAPBootstrapURL, opts...) +} + +// UpdateRDAPBootstrapFile updates bootstrap json file at dstPath from default IANA source. +func UpdateRDAPBootstrapFile(ctx context.Context, dstPath string, opts ...starnet.RequestOpt) (*RDAPBootstrap, error) { + return UpdateRDAPBootstrapFileFromURL(ctx, DefaultRDAPBootstrapURL, dstPath, opts...) +} + +// UpdateRDAPBootstrapFileFromURL updates bootstrap json file at dstPath from bootstrapURL. +func UpdateRDAPBootstrapFileFromURL(ctx context.Context, bootstrapURL, dstPath string, opts ...starnet.RequestOpt) (*RDAPBootstrap, error) { + dstPath = strings.TrimSpace(dstPath) + if dstPath == "" { + return nil, errors.New("whois/rdap: dst path is empty") + } + + raw, bootstrap, err := FetchRDAPBootstrapFromURL(ctx, bootstrapURL, opts...) + if err != nil { + return nil, err + } + if err := writeFileAtomic(dstPath, raw, 0644); err != nil { + return nil, fmt.Errorf("whois/rdap: write bootstrap file failed: %w", err) + } + return bootstrap, nil +} + +func writeFileAtomic(dstPath string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(dstPath) + if dir == "" { + dir = "." + } + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + tmpFile, err := os.CreateTemp(dir, "."+filepath.Base(dstPath)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmpFile.Write(data); err != nil { + _ = tmpFile.Close() + return err + } + if err := tmpFile.Chmod(perm); err != nil { + _ = tmpFile.Close() + return err + } + if err := tmpFile.Close(); err != nil { + return err + } + + if err := os.Rename(tmpPath, dstPath); err != nil { + _ = os.Remove(dstPath) + if err2 := os.Rename(tmpPath, dstPath); err2 != nil { + return err2 + } + } + cleanup = false + return nil +} + +func uniqueTLDs(in []string) []string { + out := make([]string, 0, len(in)) + seen := make(map[string]struct{}, len(in)) + for _, v := range in { + k := normalizeRDAPTLD(v) + if k == "" { + continue + } + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, k) + } + return out +} + +func uniqueURLs(in []string) []string { + out := make([]string, 0, len(in)) + seen := make(map[string]struct{}, len(in)) + for _, v := range in { + k := strings.TrimSpace(v) + if k == "" { + continue + } + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, k) + } + return out +} + +func normalizeRDAPTLD(tld string) string { + return strings.Trim(strings.ToLower(strings.TrimSpace(tld)), ".") +} + +func appendUniqueStrings(base []string, values ...string) []string { + if len(values) == 0 { + return base + } + seen := make(map[string]struct{}, len(base)+len(values)) + out := make([]string, 0, len(base)+len(values)) + for _, v := range base { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +func copyStringSlice(in []string) []string { + if len(in) == 0 { + return nil + } + out := make([]string, len(in)) + copy(out, in) + return out +} + +func mergeBootstrapValidators(base RDAPBootstrapValidators, etag, lastModified string) RDAPBootstrapValidators { + out := RDAPBootstrapValidators{ + ETag: strings.TrimSpace(base.ETag), + LastModified: strings.TrimSpace(base.LastModified), + } + if v := strings.TrimSpace(etag); v != "" { + out.ETag = v + } + if v := strings.TrimSpace(lastModified); v != "" { + out.LastModified = v + } + return out +} + +func normalizeBootstrapRemoteURL(url string) string { + url = strings.TrimSpace(url) + if url == "" { + return DefaultRDAPBootstrapURL + } + return url +} + +func loadRDAPBootstrapRemoteState(url string) (rdapBootstrapRemoteStateEntry, bool) { + key := normalizeBootstrapRemoteURL(url) + v, ok := remoteBootstrapState.Load(key) + if !ok { + return rdapBootstrapRemoteStateEntry{}, false + } + entry, ok := v.(rdapBootstrapRemoteStateEntry) + if !ok { + remoteBootstrapState.Delete(key) + return rdapBootstrapRemoteStateEntry{}, false + } + if entry.Bootstrap != nil { + entry.Bootstrap = entry.Bootstrap.Clone() + } + return entry, true +} + +func storeRDAPBootstrapRemoteState(url string, entry rdapBootstrapRemoteStateEntry) { + key := normalizeBootstrapRemoteURL(url) + if entry.Bootstrap != nil { + entry.Bootstrap = entry.Bootstrap.Clone() + } + remoteBootstrapState.Store(key, entry) +} + +// ClearRDAPBootstrapRemoteState clears remembered remote bootstrap validators/state. +// When urls are empty, all state is removed. +func ClearRDAPBootstrapRemoteState(urls ...string) { + if len(urls) == 0 { + remoteBootstrapState.Range(func(k, _ interface{}) bool { + remoteBootstrapState.Delete(k) + return true + }) + return + } + for _, u := range urls { + key := normalizeBootstrapRemoteURL(u) + remoteBootstrapState.Delete(key) + } +} diff --git a/rdap_bootstrap_conditional_test.go b/rdap_bootstrap_conditional_test.go new file mode 100644 index 0000000..a660400 --- /dev/null +++ b/rdap_bootstrap_conditional_test.go @@ -0,0 +1,62 @@ +package whois + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func TestLoadRDAPBootstrapLayeredConditionalRefresh(t *testing.T) { + ClearRDAPBootstrapRemoteState() + + var hit int32 + const etag = `"v1"` + var secondIfNoneMatch atomic.Value + + remoteRaw := `{"version":"1.0","services":[[["condtest"],["https://rdap.condtest.example/"]]]}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&hit, 1) + if n == 2 { + secondIfNoneMatch.Store(r.Header.Get("If-None-Match")) + } + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", etag) + _, _ = w.Write([]byte(remoteRaw)) + })) + defer srv.Close() + + opt := RDAPBootstrapLoadOptions{ + RefreshRemote: true, + RemoteURL: srv.URL, + } + + boot1, err := LoadRDAPBootstrapLayered(context.Background(), opt) + if err != nil { + t.Fatalf("first LoadRDAPBootstrapLayered() error: %v", err) + } + if got := boot1.URLsForTLD("condtest"); len(got) != 1 || got[0] != "https://rdap.condtest.example/" { + t.Fatalf("unexpected first load mapping: %#v", got) + } + + boot2, err := LoadRDAPBootstrapLayered(context.Background(), opt) + if err != nil { + t.Fatalf("second LoadRDAPBootstrapLayered() error: %v", err) + } + if got := boot2.URLsForTLD("condtest"); len(got) != 1 || got[0] != "https://rdap.condtest.example/" { + t.Fatalf("unexpected second load mapping: %#v", got) + } + + if got := atomic.LoadInt32(&hit); got != 2 { + t.Fatalf("expected 2 remote calls, got=%d", got) + } + v, _ := secondIfNoneMatch.Load().(string) + if v != etag { + t.Fatalf("expected conditional If-None-Match=%q, got=%q", etag, v) + } +} diff --git a/rdap_bootstrap_test.go b/rdap_bootstrap_test.go new file mode 100644 index 0000000..1384963 --- /dev/null +++ b/rdap_bootstrap_test.go @@ -0,0 +1,379 @@ +package whois + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestParseRDAPBootstrap(t *testing.T) { + raw := `{ + "version":"1.0", + "publication":"2026-03-12T20:00:01Z", + "services":[ + [["com","net"],["https://rdap.example.com/"]], + [["org"],["https://rdap.example.org/","https://rdap.example.org/"]] + ] +}` + boot, err := ParseRDAPBootstrap([]byte(raw)) + if err != nil { + t.Fatalf("ParseRDAPBootstrap() error: %v", err) + } + if boot.Version != "1.0" { + t.Fatalf("unexpected version: %q", boot.Version) + } + if len(boot.Services) != 2 { + t.Fatalf("unexpected service count: %d", len(boot.Services)) + } + m := boot.ServerMap() + if len(m["com"]) != 1 || m["com"][0] != "https://rdap.example.com/" { + t.Fatalf("unexpected com mapping: %#v", m["com"]) + } + if len(m["org"]) != 1 { + t.Fatalf("unexpected org mapping dedupe: %#v", m["org"]) + } +} + +func TestLoadEmbeddedRDAPBootstrap(t *testing.T) { + boot, err := LoadEmbeddedRDAPBootstrap() + if err != nil { + t.Fatalf("LoadEmbeddedRDAPBootstrap() error: %v", err) + } + if len(boot.Services) == 0 { + t.Fatal("embedded bootstrap has zero services") + } + if urls := boot.URLsForTLD("com"); len(urls) == 0 { + t.Fatal("embedded bootstrap missing com rdap mapping") + } +} + +func TestFetchRDAPBootstrapFromURL(t *testing.T) { + raw := `{"version":"1.0","services":[[["io"],["https://rdap.example.io/"]]]}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(raw)) + })) + defer srv.Close() + + gotRaw, boot, err := FetchRDAPBootstrapFromURL(context.Background(), srv.URL) + if err != nil { + t.Fatalf("FetchRDAPBootstrapFromURL() error: %v", err) + } + if !strings.Contains(string(gotRaw), `"version":"1.0"`) { + t.Fatalf("unexpected bootstrap raw: %s", string(gotRaw)) + } + if urls := boot.URLsForTLD("io"); len(urls) != 1 || urls[0] != "https://rdap.example.io/" { + t.Fatalf("unexpected io mapping: %#v", urls) + } +} + +func TestUpdateRDAPBootstrapFileFromURL(t *testing.T) { + raw := `{"version":"1.0","services":[[["dev"],["https://rdap.example.dev/"]]]}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(raw)) + })) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "rdap_dns.json") + boot, err := UpdateRDAPBootstrapFileFromURL(context.Background(), srv.URL, dst) + if err != nil { + t.Fatalf("UpdateRDAPBootstrapFileFromURL() error: %v", err) + } + b, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if !strings.Contains(string(b), `"version":"1.0"`) { + t.Fatalf("unexpected updated file content: %s", string(b)) + } + if urls := boot.URLsForTLD("dev"); len(urls) == 0 { + t.Fatalf("unexpected dev mapping: %#v", urls) + } +} + +func TestMergeRDAPBootstrapsOverride(t *testing.T) { + base := &RDAPBootstrap{ + Version: "1.0", + Services: []RDAPService{ + {TLDs: []string{"com"}, URLs: []string{"https://base.example/rdap/"}}, + {TLDs: []string{"org"}, URLs: []string{"https://base.example.org/rdap/"}}, + }, + } + overlay := &RDAPBootstrap{ + Version: "1.1", + Services: []RDAPService{ + {TLDs: []string{"com"}, URLs: []string{"https://overlay.example/rdap/"}}, + {TLDs: []string{"dev"}, URLs: []string{"https://overlay.example.dev/rdap/"}}, + }, + } + + merged := MergeRDAPBootstraps(base, overlay) + if merged == nil { + t.Fatal("merged bootstrap is nil") + } + if merged.Version != "1.1" { + t.Fatalf("unexpected merged version: %q", merged.Version) + } + if got := merged.URLsForTLD("com"); len(got) != 1 || got[0] != "https://overlay.example/rdap/" { + t.Fatalf("unexpected com mapping after override: %#v", got) + } + if got := merged.URLsForTLD("org"); len(got) != 1 || got[0] != "https://base.example.org/rdap/" { + t.Fatalf("unexpected org mapping after merge: %#v", got) + } + if got := merged.URLsForTLD("dev"); len(got) != 1 { + t.Fatalf("unexpected dev mapping after merge: %#v", got) + } +} + +func TestLoadRDAPBootstrapLayered(t *testing.T) { + localRaw := `{"version":"1.0","services":[[["dev"],["https://local.example.dev/rdap/"]],[["com"],["https://local.example.com/rdap/"]]]}` + localPath := filepath.Join(t.TempDir(), "rdap_local.json") + if err := os.WriteFile(localPath, []byte(localRaw), 0644); err != nil { + t.Fatalf("write local bootstrap failed: %v", err) + } + + remoteRaw := `{"version":"2.0","services":[[["com"],["https://remote.example.com/rdap/"]],[["net"],["https://remote.example.net/rdap/"]]]}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(remoteRaw)) + })) + defer srv.Close() + + boot, err := LoadRDAPBootstrapLayered(context.Background(), RDAPBootstrapLoadOptions{ + LocalFiles: []string{localPath}, + RefreshRemote: true, + RemoteURL: srv.URL, + }) + if err != nil { + t.Fatalf("LoadRDAPBootstrapLayered() error: %v", err) + } + if boot.Version != "2.0" { + t.Fatalf("unexpected layered version: %q", boot.Version) + } + if got := boot.URLsForTLD("dev"); len(got) != 1 || got[0] != "https://local.example.dev/rdap/" { + t.Fatalf("unexpected dev mapping from local layer: %#v", got) + } + if got := boot.URLsForTLD("com"); len(got) != 1 || got[0] != "https://remote.example.com/rdap/" { + t.Fatalf("unexpected com mapping from remote override: %#v", got) + } + if got := boot.URLsForTLD("net"); len(got) != 1 || got[0] != "https://remote.example.net/rdap/" { + t.Fatalf("unexpected net mapping from remote layer: %#v", got) + } +} + +func TestLoadRDAPBootstrapLayeredCachedTTL(t *testing.T) { + cacheKey := t.Name() + "-ttl" + ClearRDAPBootstrapLayeredCache(cacheKey) + + var hit int32 + remoteRaw := `{"version":"2.0","services":[[["com"],["https://remote.example.com/rdap/"]]]}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hit, 1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(remoteRaw)) + })) + defer srv.Close() + + opt := RDAPBootstrapLoadOptions{ + RefreshRemote: true, + RemoteURL: srv.URL, + CacheTTL: 80 * time.Millisecond, + CacheKey: cacheKey, + } + if _, err := LoadRDAPBootstrapLayeredCached(context.Background(), opt); err != nil { + t.Fatalf("first cached load failed: %v", err) + } + if _, err := LoadRDAPBootstrapLayeredCached(context.Background(), opt); err != nil { + t.Fatalf("second cached load failed: %v", err) + } + if got := atomic.LoadInt32(&hit); got != 1 { + t.Fatalf("expected one remote fetch within ttl, got=%d", got) + } + + time.Sleep(120 * time.Millisecond) + if _, err := LoadRDAPBootstrapLayeredCached(context.Background(), opt); err != nil { + t.Fatalf("third cached load after ttl failed: %v", err) + } + if got := atomic.LoadInt32(&hit); got != 2 { + t.Fatalf("expected cache refresh after ttl expiry, got=%d", got) + } +} + +func TestLoadRDAPBootstrapLayeredCachedSingleflight(t *testing.T) { + cacheKey := t.Name() + "-sf" + ClearRDAPBootstrapLayeredCache(cacheKey) + + var hit int32 + remoteRaw := `{"version":"2.0","services":[[["net"],["https://remote.example.net/rdap/"]]]}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hit, 1) + time.Sleep(100 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(remoteRaw)) + })) + defer srv.Close() + + opt := RDAPBootstrapLoadOptions{ + RefreshRemote: true, + RemoteURL: srv.URL, + CacheTTL: time.Minute, + CacheKey: cacheKey, + } + + const n = 8 + var wg sync.WaitGroup + errCh := make(chan error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + boot, err := LoadRDAPBootstrapLayeredCached(context.Background(), opt) + if err != nil { + errCh <- err + return + } + if got := boot.URLsForTLD("net"); len(got) != 1 || got[0] != "https://remote.example.net/rdap/" { + errCh <- &testErr{msg: "unexpected cached bootstrap content"} + } + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + t.Fatalf("concurrent cached load failed: %v", err) + } + } + if got := atomic.LoadInt32(&hit); got != 1 { + t.Fatalf("expected one remote fetch with singleflight, got=%d", got) + } +} + +func TestLoadRDAPBootstrapLayeredIgnoreRemoteError(t *testing.T) { + localRaw := `{"version":"1.0","services":[[["dev"],["https://local.example.dev/rdap/"]]]}` + localPath := filepath.Join(t.TempDir(), "rdap_local.json") + if err := os.WriteFile(localPath, []byte(localRaw), 0644); err != nil { + t.Fatalf("write local bootstrap failed: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := LoadRDAPBootstrapLayered(context.Background(), RDAPBootstrapLoadOptions{ + LocalFiles: []string{localPath}, + RefreshRemote: true, + RemoteURL: srv.URL, + IgnoreRemoteError: false, + }) + if err == nil { + t.Fatal("expected strict remote refresh to fail") + } + + boot, err := LoadRDAPBootstrapLayered(context.Background(), RDAPBootstrapLoadOptions{ + LocalFiles: []string{localPath}, + RefreshRemote: true, + RemoteURL: srv.URL, + IgnoreRemoteError: true, + }) + if err != nil { + t.Fatalf("ignore remote error load failed: %v", err) + } + if got := boot.URLsForTLD("dev"); len(got) != 1 || got[0] != "https://local.example.dev/rdap/" { + t.Fatalf("unexpected local fallback mapping: %#v", got) + } +} + +func TestLoadRDAPBootstrapLayeredCachedAllowStaleOnError(t *testing.T) { + cacheKey := t.Name() + "-stale" + ClearRDAPBootstrapLayeredCache(cacheKey) + + var fail atomic.Bool + var hit int32 + remoteRaw := `{"version":"2.0","services":[[["com"],["https://remote.example.com/rdap/"]]]}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hit, 1) + if fail.Load() { + http.Error(w, "refresh failed", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(remoteRaw)) + })) + defer srv.Close() + + baseOpt := RDAPBootstrapLoadOptions{ + RefreshRemote: true, + RemoteURL: srv.URL, + CacheTTL: 70 * time.Millisecond, + CacheKey: cacheKey, + } + if _, err := LoadRDAPBootstrapLayeredCached(context.Background(), baseOpt); err != nil { + t.Fatalf("initial cached load failed: %v", err) + } + fail.Store(true) + time.Sleep(110 * time.Millisecond) + + _, err := LoadRDAPBootstrapLayeredCached(context.Background(), baseOpt) + if err == nil { + t.Fatal("expected refresh failure when stale fallback disabled") + } + + staleOpt := baseOpt + staleOpt.AllowStaleOnError = true + boot, err := LoadRDAPBootstrapLayeredCached(context.Background(), staleOpt) + if err != nil { + t.Fatalf("stale fallback load failed: %v", err) + } + if got := boot.URLsForTLD("com"); len(got) != 1 || got[0] != "https://remote.example.com/rdap/" { + t.Fatalf("unexpected stale fallback mapping: %#v", got) + } + if got := atomic.LoadInt32(&hit); got < 2 { + t.Fatalf("expected at least one failed refresh attempt, hit=%d", got) + } +} + +func TestUpdateRDAPBootstrapFileFromURLCreateParentDir(t *testing.T) { + raw := `{"version":"1.0","services":[[["dev"],["https://rdap.example.dev/"]]]}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(raw)) + })) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "nested", "rdap", "rdap_dns.json") + boot, err := UpdateRDAPBootstrapFileFromURL(context.Background(), srv.URL, dst) + if err != nil { + t.Fatalf("UpdateRDAPBootstrapFileFromURL() error: %v", err) + } + content, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if !strings.Contains(string(content), `"version":"1.0"`) { + t.Fatalf("unexpected updated file content: %s", string(content)) + } + if got := boot.URLsForTLD("dev"); len(got) != 1 || got[0] != "https://rdap.example.dev/" { + t.Fatalf("unexpected updated bootstrap mapping: %#v", got) + } +} + +type testErr struct { + msg string +} + +func (e *testErr) Error() string { + if e == nil { + return "" + } + return e.msg +} diff --git a/rdap_client.go b/rdap_client.go new file mode 100644 index 0000000..cecd84e --- /dev/null +++ b/rdap_client.go @@ -0,0 +1,525 @@ +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)" +} diff --git a/rdap_client_retry_test.go b/rdap_client_retry_test.go new file mode 100644 index 0000000..7f64c65 --- /dev/null +++ b/rdap_client_retry_test.go @@ -0,0 +1,94 @@ +package whois + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestRDAPClientRetryOn5xx(t *testing.T) { + var hit int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&hit, 1) + if n == 1 { + http.Error(w, "temporary", http.StatusInternalServerError) + return + } + _, _ = w.Write([]byte(`{"objectClassName":"domain","ldhName":"example.com"}`)) + })) + defer srv.Close() + + c, err := NewRDAPClientWithBootstrap(&RDAPBootstrap{ + Version: "1.0", + Services: []RDAPService{ + {TLDs: []string{"com"}, URLs: []string{srv.URL}}, + }, + }) + if err != nil { + t.Fatalf("NewRDAPClientWithBootstrap() error: %v", err) + } + c.SetRetryPolicy(RDAPRetryPolicy{ + MaxAttempts: 2, + BaseDelay: time.Millisecond, + MaxDelay: 5 * time.Millisecond, + RetryOn429: true, + RetryOn5xx: true, + RetryOnNetwork: true, + }) + + resp, err := c.QueryDomain(context.Background(), "example.com") + if err != nil { + t.Fatalf("QueryDomain() error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + if got := atomic.LoadInt32(&hit); got != 2 { + t.Fatalf("expected retry hit=2, got=%d", got) + } +} + +func TestRDAPClientQueryIPAndASN(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ip/1.1.1.1": + _, _ = w.Write([]byte(`{"objectClassName":"ip network","handle":"NET-1-1-1-0-1"}`)) + case "/autnum/13335": + _, _ = w.Write([]byte(`{"objectClassName":"autnum","handle":"AS13335"}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + c, err := NewRDAPClientWithBootstrap(&RDAPBootstrap{ + Version: "1.0", + Services: []RDAPService{ + {TLDs: []string{"com"}, URLs: []string{"https://rdap.example.com/"}}, + }, + }) + if err != nil { + t.Fatalf("NewRDAPClientWithBootstrap() error: %v", err) + } + c.SetIPServers(srv.URL) + c.SetASNServers(srv.URL) + + ipResp, err := c.QueryIP(context.Background(), "1.1.1.1") + if err != nil { + t.Fatalf("QueryIP() error: %v", err) + } + if ipResp.StatusCode != http.StatusOK || ipResp.Domain != "1.1.1.1" { + t.Fatalf("unexpected ip response: status=%d domain=%q", ipResp.StatusCode, ipResp.Domain) + } + + asnResp, err := c.QueryASN(context.Background(), "AS13335") + if err != nil { + t.Fatalf("QueryASN() error: %v", err) + } + if asnResp.StatusCode != http.StatusOK || asnResp.Domain != "AS13335" { + t.Fatalf("unexpected asn response: status=%d domain=%q", asnResp.StatusCode, asnResp.Domain) + } +} diff --git a/rdap_client_test.go b/rdap_client_test.go new file mode 100644 index 0000000..aae6ecf --- /dev/null +++ b/rdap_client_test.go @@ -0,0 +1,151 @@ +package whois + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestRDAPClientQueryDomain(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rdap/domain/example.com" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/rdap+json") + _, _ = w.Write([]byte(`{"objectClassName":"domain","ldhName":"example.com"}`)) + })) + defer srv.Close() + + boot := &RDAPBootstrap{ + Version: "1.0", + Services: []RDAPService{ + { + TLDs: []string{"com"}, + URLs: []string{srv.URL + "/rdap/"}, + }, + }, + } + c, err := NewRDAPClientWithBootstrap(boot) + if err != nil { + t.Fatalf("NewRDAPClientWithBootstrap() error: %v", err) + } + + resp, err := c.QueryDomain(context.Background(), "example.com") + if err != nil { + t.Fatalf("QueryDomain() error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + if !strings.Contains(string(resp.Body), `"ldhName":"example.com"`) { + t.Fatalf("unexpected body: %s", string(resp.Body)) + } +} + +func TestRDAPClientQueryDomainJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/domain/example.org" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"ldhName":"example.org","status":["active"]}`)) + })) + defer srv.Close() + + c, err := NewRDAPClientWithBootstrap(&RDAPBootstrap{ + Version: "1.0", + Services: []RDAPService{ + {TLDs: []string{"org"}, URLs: []string{srv.URL}}, + }, + }) + if err != nil { + t.Fatalf("NewRDAPClientWithBootstrap() error: %v", err) + } + + var out map[string]any + _, err = c.QueryDomainJSON(context.Background(), "example.org", &out) + if err != nil { + t.Fatalf("QueryDomainJSON() error: %v", err) + } + if out["ldhName"] != "example.org" { + t.Fatalf("unexpected json field: %#v", out) + } +} + +func TestNewRDAPClientWithLayeredBootstrap(t *testing.T) { + rdapSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rdap/domain/example.dev" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"objectClassName":"domain","ldhName":"example.dev"}`)) + })) + defer rdapSrv.Close() + + localRaw := `{"version":"1.0","services":[[["dev"],["` + rdapSrv.URL + `/rdap/"]]]}` + localPath := filepath.Join(t.TempDir(), "rdap_local.json") + if err := os.WriteFile(localPath, []byte(localRaw), 0644); err != nil { + t.Fatalf("write local bootstrap failed: %v", err) + } + + c, err := NewRDAPClientWithLayeredBootstrap(context.Background(), RDAPBootstrapLoadOptions{ + LocalFiles: []string{localPath}, + }) + if err != nil { + t.Fatalf("NewRDAPClientWithLayeredBootstrap() error: %v", err) + } + + resp, err := c.QueryDomain(context.Background(), "example.dev") + if err != nil { + t.Fatalf("QueryDomain() error: %v", err) + } + if resp.StatusCode != http.StatusOK || !strings.Contains(string(resp.Body), `"example.dev"`) { + t.Fatalf("unexpected response: status=%d body=%s", resp.StatusCode, string(resp.Body)) + } +} + +func TestNewRDAPClientWithLayeredBootstrapCacheTTL(t *testing.T) { + cacheKey := t.Name() + "-cache" + ClearRDAPBootstrapLayeredCache(cacheKey) + + var bootstrapHit int32 + bootstrapRaw := `{"version":"2.0","services":[[["dev"],["https://rdap.example.dev/rdap/"]]]}` + bootstrapSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&bootstrapHit, 1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(bootstrapRaw)) + })) + defer bootstrapSrv.Close() + + loadOpt := RDAPBootstrapLoadOptions{ + RefreshRemote: true, + RemoteURL: bootstrapSrv.URL, + CacheTTL: time.Minute, + CacheKey: cacheKey, + } + + c1, err := NewRDAPClientWithLayeredBootstrap(context.Background(), loadOpt) + if err != nil { + t.Fatalf("first NewRDAPClientWithLayeredBootstrap() error: %v", err) + } + if c1 == nil { + t.Fatal("first client should not be nil") + } + c2, err := NewRDAPClientWithLayeredBootstrap(context.Background(), loadOpt) + if err != nil { + t.Fatalf("second NewRDAPClientWithLayeredBootstrap() error: %v", err) + } + if c2 == nil { + t.Fatal("second client should not be nil") + } + if got := atomic.LoadInt32(&bootstrapHit); got != 1 { + t.Fatalf("expected cached bootstrap to fetch remote once, got=%d", got) + } +} diff --git a/rdap_dns.json b/rdap_dns.json new file mode 100644 index 0000000..4757536 --- /dev/null +++ b/rdap_dns.json @@ -0,0 +1,5412 @@ +{ + "description": "RDAP bootstrap file for Domain Name System registrations", + "publication": "2026-03-12T20:00:01Z", + "services": [ + [ + [ + "kg" + ], + [ + "http://rdap.cctld.kg/" + ] + ], + [ + [ + "mg" + ], + [ + "http://rdap.nic.mg/" + ] + ], + [ + [ + "ng" + ], + [ + "http://rdap.nic.net.ng/" + ] + ], + [ + [ + "xn--kpry57d" + ], + [ + "https://ccrdap.twnic.tw/taiwan/" + ] + ], + [ + [ + "tw" + ], + [ + "https://ccrdap.twnic.tw/tw/" + ] + ], + [ + [ + "na" + ], + [ + "https://keetmans.omadhina.co.na/" + ] + ], + [ + [ + "samsung", + "xn--cg4bki" + ], + [ + "https://nic.samsung/rdap/" + ] + ], + [ + [ + "ads", + "android", + "app", + "boo", + "cal", + "channel", + "chrome", + "dad", + "day", + "dclk", + "dev", + "docs", + "drive", + "eat", + "esq", + "fly", + "foo", + "gbiz", + "gle", + "gmail", + "goog", + "google", + "guge", + "hangout", + "here", + "how", + "ing", + "map", + "meet", + "meme", + "mov", + "new", + "nexus", + "page", + "phd", + "play", + "prod", + "prof", + "rsvp", + "search", + "soy", + "xn--flw351e", + "xn--q9jyb4c", + "xn--qcka1pmc", + "youtube", + "zip" + ], + [ + "https://pubapi.registry.google/rdap/" + ] + ], + [ + [ + "blog" + ], + [ + "https://rdap.blog.fury.ca/rdap/" + ] + ], + [ + [ + "ca" + ], + [ + "https://rdap.ca.fury.ca/rdap/" + ] + ], + [ + [ + "au" + ], + [ + "https://rdap.cctld.au/rdap/" + ] + ], + [ + [ + "uz" + ], + [ + "https://rdap.cctld.uz/" + ] + ], + [ + [ + "allfinanz" + ], + [ + "https://rdap.centralnic.com/allfinanz/" + ] + ], + [ + [ + "art" + ], + [ + "https://rdap.centralnic.com/art/" + ] + ], + [ + [ + "audio" + ], + [ + "https://rdap.centralnic.com/audio/" + ] + ], + [ + [ + "auto" + ], + [ + "https://rdap.centralnic.com/auto/" + ] + ], + [ + [ + "autos" + ], + [ + "https://rdap.centralnic.com/autos/" + ] + ], + [ + [ + "baby" + ], + [ + "https://rdap.centralnic.com/baby/" + ] + ], + [ + [ + "beauty" + ], + [ + "https://rdap.centralnic.com/beauty/" + ] + ], + [ + [ + "best" + ], + [ + "https://rdap.centralnic.com/best/" + ] + ], + [ + [ + "bmw" + ], + [ + "https://rdap.centralnic.com/bmw/" + ] + ], + [ + [ + "boats" + ], + [ + "https://rdap.centralnic.com/boats/" + ] + ], + [ + [ + "bond" + ], + [ + "https://rdap.centralnic.com/bond/" + ] + ], + [ + [ + "box" + ], + [ + "https://rdap.centralnic.com/box/" + ] + ], + [ + [ + "build" + ], + [ + "https://rdap.centralnic.com/build/" + ] + ], + [ + [ + "cam" + ], + [ + "https://rdap.centralnic.com/cam/" + ] + ], + [ + [ + "car" + ], + [ + "https://rdap.centralnic.com/car/" + ] + ], + [ + [ + "cars" + ], + [ + "https://rdap.centralnic.com/cars/" + ] + ], + [ + [ + "case" + ], + [ + "https://rdap.centralnic.com/case/" + ] + ], + [ + [ + "ceo" + ], + [ + "https://rdap.centralnic.com/ceo/" + ] + ], + [ + [ + "cfd" + ], + [ + "https://rdap.centralnic.com/cfd/" + ] + ], + [ + [ + "christmas" + ], + [ + "https://rdap.centralnic.com/christmas/" + ] + ], + [ + [ + "college" + ], + [ + "https://rdap.centralnic.com/college/" + ] + ], + [ + [ + "cyou" + ], + [ + "https://rdap.centralnic.com/cyou/" + ] + ], + [ + [ + "dealer" + ], + [ + "https://rdap.centralnic.com/dealer/" + ] + ], + [ + [ + "deloitte" + ], + [ + "https://rdap.centralnic.com/deloitte/" + ] + ], + [ + [ + "dhl" + ], + [ + "https://rdap.centralnic.com/dhl/" + ] + ], + [ + [ + "diet" + ], + [ + "https://rdap.centralnic.com/diet/" + ] + ], + [ + [ + "dvag" + ], + [ + "https://rdap.centralnic.com/dvag/" + ] + ], + [ + [ + "fans" + ], + [ + "https://rdap.centralnic.com/fans/" + ] + ], + [ + [ + "flowers" + ], + [ + "https://rdap.centralnic.com/flowers/" + ] + ], + [ + [ + "fm" + ], + [ + "https://rdap.centralnic.com/fm/" + ] + ], + [ + [ + "fo" + ], + [ + "https://rdap.centralnic.com/fo/" + ] + ], + [ + [ + "fresenius" + ], + [ + "https://rdap.centralnic.com/fresenius/" + ] + ], + [ + [ + "frl" + ], + [ + "https://rdap.centralnic.com/frl/" + ] + ], + [ + [ + "fun" + ], + [ + "https://rdap.centralnic.com/fun/" + ] + ], + [ + [ + "game" + ], + [ + "https://rdap.centralnic.com/game/" + ] + ], + [ + [ + "gd" + ], + [ + "https://rdap.centralnic.com/gd/" + ] + ], + [ + [ + "gent" + ], + [ + "https://rdap.centralnic.com/gent/" + ] + ], + [ + [ + "guitars" + ], + [ + "https://rdap.centralnic.com/guitars/" + ] + ], + [ + [ + "hair" + ], + [ + "https://rdap.centralnic.com/hair/" + ] + ], + [ + [ + "help" + ], + [ + "https://rdap.centralnic.com/help/" + ] + ], + [ + [ + "homes" + ], + [ + "https://rdap.centralnic.com/homes/" + ] + ], + [ + [ + "host" + ], + [ + "https://rdap.centralnic.com/host/" + ] + ], + [ + [ + "hosting" + ], + [ + "https://rdap.centralnic.com/hosting/" + ] + ], + [ + [ + "icu" + ], + [ + "https://rdap.centralnic.com/icu/" + ] + ], + [ + [ + "inc" + ], + [ + "https://rdap.centralnic.com/inc/" + ] + ], + [ + [ + "kfh" + ], + [ + "https://rdap.centralnic.com/kfh/" + ] + ], + [ + [ + "kpn" + ], + [ + "https://rdap.centralnic.com/kpn/" + ] + ], + [ + [ + "kred" + ], + [ + "https://rdap.centralnic.com/kred/" + ] + ], + [ + [ + "lat" + ], + [ + "https://rdap.centralnic.com/lat/" + ] + ], + [ + [ + "lidl" + ], + [ + "https://rdap.centralnic.com/lidl/" + ] + ], + [ + [ + "llp" + ], + [ + "https://rdap.centralnic.com/llp/" + ] + ], + [ + [ + "lol" + ], + [ + "https://rdap.centralnic.com/lol/" + ] + ], + [ + [ + "london" + ], + [ + "https://rdap.centralnic.com/london/" + ] + ], + [ + [ + "lpl" + ], + [ + "https://rdap.centralnic.com/lpl/" + ] + ], + [ + [ + "lplfinancial" + ], + [ + "https://rdap.centralnic.com/lplfinancial/" + ] + ], + [ + [ + "luxury" + ], + [ + "https://rdap.centralnic.com/luxury/" + ] + ], + [ + [ + "makeup" + ], + [ + "https://rdap.centralnic.com/makeup/" + ] + ], + [ + [ + "mini" + ], + [ + "https://rdap.centralnic.com/mini/" + ] + ], + [ + [ + "mom" + ], + [ + "https://rdap.centralnic.com/mom/" + ] + ], + [ + [ + "monster" + ], + [ + "https://rdap.centralnic.com/monster/" + ] + ], + [ + [ + "motorcycles" + ], + [ + "https://rdap.centralnic.com/motorcycles/" + ] + ], + [ + [ + "nokia" + ], + [ + "https://rdap.centralnic.com/nokia/" + ] + ], + [ + [ + "online" + ], + [ + "https://rdap.centralnic.com/online/" + ] + ], + [ + [ + "ooo" + ], + [ + "https://rdap.centralnic.com/ooo/" + ] + ], + [ + [ + "pics" + ], + [ + "https://rdap.centralnic.com/pics/" + ] + ], + [ + [ + "pohl" + ], + [ + "https://rdap.centralnic.com/pohl/" + ] + ], + [ + [ + "press" + ], + [ + "https://rdap.centralnic.com/press/" + ] + ], + [ + [ + "protection" + ], + [ + "https://rdap.centralnic.com/protection/" + ] + ], + [ + [ + "pw" + ], + [ + "https://rdap.centralnic.com/pw/" + ] + ], + [ + [ + "qpon" + ], + [ + "https://rdap.centralnic.com/qpon/" + ] + ], + [ + [ + "quest" + ], + [ + "https://rdap.centralnic.com/quest/" + ] + ], + [ + [ + "reit" + ], + [ + "https://rdap.centralnic.com/reit/" + ] + ], + [ + [ + "rent" + ], + [ + "https://rdap.centralnic.com/rent/" + ] + ], + [ + [ + "ruhr" + ], + [ + "https://rdap.centralnic.com/ruhr/" + ] + ], + [ + [ + "saarland" + ], + [ + "https://rdap.centralnic.com/saarland/" + ] + ], + [ + [ + "sbs" + ], + [ + "https://rdap.centralnic.com/sbs/" + ] + ], + [ + [ + "schwarz" + ], + [ + "https://rdap.centralnic.com/schwarz/" + ] + ], + [ + [ + "security" + ], + [ + "https://rdap.centralnic.com/security/" + ] + ], + [ + [ + "sfr" + ], + [ + "https://rdap.centralnic.com/sfr/" + ] + ], + [ + [ + "site" + ], + [ + "https://rdap.centralnic.com/site/" + ] + ], + [ + [ + "skin" + ], + [ + "https://rdap.centralnic.com/skin/" + ] + ], + [ + [ + "smart" + ], + [ + "https://rdap.centralnic.com/smart/" + ] + ], + [ + [ + "space" + ], + [ + "https://rdap.centralnic.com/space/" + ] + ], + [ + [ + "stc" + ], + [ + "https://rdap.centralnic.com/stc/" + ] + ], + [ + [ + "stcgroup" + ], + [ + "https://rdap.centralnic.com/stcgroup/" + ] + ], + [ + [ + "storage" + ], + [ + "https://rdap.centralnic.com/storage/" + ] + ], + [ + [ + "store" + ], + [ + "https://rdap.centralnic.com/store/" + ] + ], + [ + [ + "tech" + ], + [ + "https://rdap.centralnic.com/tech/" + ] + ], + [ + [ + "theatre" + ], + [ + "https://rdap.centralnic.com/theatre/" + ] + ], + [ + [ + "tickets" + ], + [ + "https://rdap.centralnic.com/tickets/" + ] + ], + [ + [ + "tui" + ], + [ + "https://rdap.centralnic.com/tui/" + ] + ], + [ + [ + "uno" + ], + [ + "https://rdap.centralnic.com/uno/" + ] + ], + [ + [ + "vg" + ], + [ + "https://rdap.centralnic.com/vg/" + ] + ], + [ + [ + "viva" + ], + [ + "https://rdap.centralnic.com/viva/" + ] + ], + [ + [ + "website" + ], + [ + "https://rdap.centralnic.com/website/" + ] + ], + [ + [ + "wme" + ], + [ + "https://rdap.centralnic.com/wme/" + ] + ], + [ + [ + "xn--4gbrim" + ], + [ + "https://rdap.centralnic.com/xn--4gbrim/" + ] + ], + [ + [ + "xn--ngbe9e0a" + ], + [ + "https://rdap.centralnic.com/xn--ngbe9e0a/" + ] + ], + [ + [ + "xn--vermgensberater-ctb" + ], + [ + "https://rdap.centralnic.com/xn--vermgensberater-ctb/" + ] + ], + [ + [ + "xn--vermgensberatung-pwb" + ], + [ + "https://rdap.centralnic.com/xn--vermgensberatung-pwb/" + ] + ], + [ + [ + "xyz" + ], + [ + "https://rdap.centralnic.com/xyz/" + ] + ], + [ + [ + "yachts" + ], + [ + "https://rdap.centralnic.com/yachts/" + ] + ], + [ + [ + "zuerich" + ], + [ + "https://rdap.centralnic.com/zuerich/" + ] + ], + [ + [ + "jnj" + ], + [ + "https://rdap.centralnicregistry.com/jnj/" + ] + ], + [ + [ + "xn--55qw42g", + "xn--zfr164b" + ], + [ + "https://rdap.conac.cn/" + ] + ], + [ + [ + "crown" + ], + [ + "https://rdap.crown.fury.ca/rdap/" + ] + ], + [ + [ + "pl" + ], + [ + "https://rdap.dns.pl/" + ] + ], + [ + [ + "eco" + ], + [ + "https://rdap.eco.fury.ca/rdap/" + ] + ], + [ + [ + "fi" + ], + [ + "https://rdap.fi/rdap/rdap/" + ] + ], + [ + [ + "moscow", + "xn--80adxhks" + ], + [ + "https://rdap.flexireg.net/" + ] + ], + [ + [ + "bridgestone", + "brother", + "canon", + "datsun", + "dnp", + "epson", + "firestone", + "fujitsu", + "ggee", + "gmo", + "goldpoint", + "hisamitsu", + "hitachi", + "honda", + "hyundai", + "infiniti", + "jcb", + "kddi", + "kia", + "komatsu", + "kyoto", + "lexus", + "lotte", + "mitsubishi", + "nagoya", + "nec", + "nhk", + "nico", + "nissan", + "okinawa", + "otsuka", + "panasonic", + "playstation", + "ricoh", + "ryukyu", + "sharp", + "shop", + "softbank", + "sony", + "suzuki", + "tokyo", + "toray", + "toshiba", + "toyota", + "yodobashi", + "yokohama" + ], + [ + "https://rdap.gmoregistry.net/rdap/" + ] + ], + [ + [ + "bom", + "final", + "globo", + "rio", + "uol" + ], + [ + "https://rdap.gtlds.nic.br/" + ] + ], + [ + [ + "ua" + ], + [ + "https://rdap.hostmaster.ua/" + ] + ], + [ + [ + "int" + ], + [ + "https://rdap.iana.org/" + ] + ], + [ + [ + "abb", + "abbott", + "abc", + "academy", + "accenture", + "accountants", + "actor", + "aeg", + "aero", + "agakhan", + "agency", + "ai", + "airbus", + "airforce", + "akdn", + "alibaba", + "alipay", + "allstate", + "aol", + "apartments", + "archi", + "army", + "arte", + "asda", + "asia", + "associates", + "attorney", + "auction", + "audi", + "band", + "barclaycard", + "barclays", + "barefoot", + "bargains", + "bbt", + "bcg", + "beats", + "bestbuy", + "bet", + "bike", + "bingo", + "bio", + "black", + "bloomberg", + "blue", + "bm", + "bms", + "bnpparibas", + "boehringer", + "bofa", + "bosch", + "boutique", + "bradesco", + "broker", + "builders", + "business", + "cab", + "cafe", + "camera", + "camp", + "capital", + "cards", + "care", + "careers", + "cash", + "casino", + "catering", + "center", + "cern", + "cfa", + "chanel", + "chat", + "cheap", + "church", + "cipriani", + "citadel", + "city", + "claims", + "cleaning", + "clinic", + "clinique", + "clothing", + "clubmed", + "coach", + "codes", + "coffee", + "community", + "company", + "computer", + "condos", + "construction", + "consulting", + "contact", + "contractors", + "cool", + "coupon", + "coupons", + "credit", + "creditcard", + "crs", + "cruise", + "cruises", + "dance", + "dating", + "deals", + "degree", + "delivery", + "delta", + "democrat", + "dental", + "dentist", + "diamonds", + "digital", + "direct", + "directory", + "discount", + "discover", + "doctor", + "dog", + "domains", + "edeka", + "education", + "email", + "emerck", + "energy", + "engineer", + "engineering", + "enterprises", + "equipment", + "ericsson", + "estate", + "events", + "exchange", + "expert", + "exposed", + "express", + "extraspace", + "fage", + "fail", + "family", + "fan", + "farm", + "fedex", + "ferrari", + "fidelity", + "fido", + "finance", + "financial", + "fish", + "fitness", + "flights", + "florist", + "football", + "forex", + "forsale", + "frogans", + "fund", + "furniture", + "futbol", + "fyi", + "gallery", + "gallo", + "gallup", + "games", + "genting", + "gifts", + "glass", + "global", + "gmbh", + "gold", + "golf", + "goodyear", + "graphics", + "gratis", + "green", + "gripe", + "group", + "guide", + "guru", + "haus", + "hdfc", + "hdfcbank", + "healthcare", + "helsinki", + "hermes", + "hkt", + "hockey", + "holdings", + "holiday", + "homedepot", + "hospital", + "house", + "hughes", + "ice", + "imamat", + "immo", + "immobilien", + "industries", + "info", + "institute", + "insure", + "international", + "investments", + "irish", + "ismaili", + "ist", + "istanbul", + "itv", + "jaguar", + "java", + "jeep", + "jetzt", + "jewelry", + "jio", + "jll", + "juegos", + "juniper", + "kaufen", + "kerryhotels", + "kerryproperties", + "kids", + "kim", + "kitchen", + "kosher", + "kuokgroup", + "lamborghini", + "lamer", + "land", + "landrover", + "lasalle", + "lawyer", + "lds", + "lease", + "lefrak", + "legal", + "lego", + "lgbt", + "life", + "lighting", + "limited", + "limo", + "live", + "llc", + "loans", + "lotto", + "ltd", + "ltda", + "lundbeck", + "maif", + "maison", + "management", + "market", + "marketing", + "markets", + "marriott", + "mba", + "mckinsey", + "media", + "memorial", + "mit", + "mobi", + "moda", + "money", + "mormon", + "mortgage", + "movie", + "mu", + "nab", + "navy", + "network", + "news", + "next", + "nextdirect", + "nikon", + "ninja", + "nissay", + "nowtv", + "nra", + "obi", + "onl", + "oracle", + "orange", + "organic", + "origins", + "partners", + "parts", + "pccw", + "pet", + "photography", + "photos", + "pictet", + "pictures", + "pink", + "pizza", + "place", + "plumbing", + "plus", + "pnc", + "poker", + "post", + "pro", + "productions", + "progressive", + "promo", + "properties", + "pub", + "pwc", + "recipes", + "red", + "redumbrella", + "rehab", + "reise", + "reisen", + "reliance", + "rentals", + "repair", + "report", + "republican", + "restaurant", + "reviews", + "rexroth", + "rich", + "richardli", + "ril", + "rip", + "rocks", + "rogers", + "run", + "rwe", + "sale", + "salon", + "sanofi", + "sarl", + "saxo", + "sbi", + "scholarships", + "school", + "schule", + "sener", + "services", + "sew", + "shangrila", + "shiksha", + "shoes", + "shopping", + "show", + "sina", + "singles", + "ski", + "soccer", + "social", + "software", + "solar", + "solutions", + "song", + "spa", + "srl", + "stada", + "star", + "statebank", + "stockholm", + "studio", + "style", + "supplies", + "supply", + "support", + "surgery", + "systems", + "taobao", + "tatamotors", + "tax", + "taxi", + "team", + "technology", + "temasek", + "tennis", + "thd", + "theater", + "tiaa", + "tienda", + "tips", + "tires", + "tmall", + "today", + "tools", + "tours", + "town", + "toys", + "trading", + "training", + "travel", + "travelers", + "travelersinsurance", + "trv", + "tvs", + "ubank", + "ubs", + "university", + "ups", + "vacations", + "vanguard", + "vegas", + "ventures", + "vet", + "viajes", + "video", + "vig", + "viking", + "villas", + "vin", + "visa", + "vision", + "volvo", + "vote", + "voto", + "voyage", + "watch", + "watches", + "weber", + "weibo", + "weir", + "wine", + "works", + "world", + "wtf", + "xin", + "xn--1ck2e1b", + "xn--5su34j936bgsg", + "xn--5tzm5g", + "xn--6frz82g", + "xn--9krt00a", + "xn--b4w605ferd", + "xn--bck1b9a5dre4c", + "xn--cck2b3b", + "xn--czrs0t", + "xn--eckvdtc9d", + "xn--fct429k", + "xn--fjq720a", + "xn--fzys8d69uvgm", + "xn--gckr3f0f", + "xn--gk3at1e", + "xn--jvr189m", + "xn--rovu88b", + "xn--unup4y", + "xn--vhquv", + "xn--w4r85el8fhu5dnra", + "xn--w4rs40l", + "yahoo", + "zara", + "zero", + "zone" + ], + [ + "https://rdap.identitydigital.services/rdap/" + ] + ], + [ + [ + "is" + ], + [ + "https://rdap.isnic.is/rdap/" + ] + ], + [ + [ + "ke" + ], + [ + "https://rdap.kenic.or.ke/" + ] + ], + [ + [ + "kiwi" + ], + [ + "https://rdap.kiwi.fury.ca/rdap/" + ] + ], + [ + [ + "lb" + ], + [ + "https://rdap.lbdr.org.lb/" + ] + ], + [ + [ + "mls" + ], + [ + "https://rdap.mls.fury.ca/rdap/" + ] + ], + [ + [ + "blockbuster", + "data", + "dish", + "dot", + "dtv", + "dvr", + "latino", + "mobile", + "ollo", + "ott", + "phone", + "sling" + ], + [ + "https://rdap.mobile-registry.com/rdap/" + ] + ], + [ + [ + "aaa" + ], + [ + "https://rdap.nic.aaa/" + ] + ], + [ + [ + "aarp" + ], + [ + "https://rdap.nic.aarp/" + ] + ], + [ + [ + "able" + ], + [ + "https://rdap.nic.able/" + ] + ], + [ + [ + "abogado" + ], + [ + "https://rdap.nic.abogado/" + ] + ], + [ + [ + "abudhabi" + ], + [ + "https://rdap.nic.abudhabi/" + ] + ], + [ + [ + "accountant" + ], + [ + "https://rdap.nic.accountant/" + ] + ], + [ + [ + "aco" + ], + [ + "https://rdap.nic.aco/" + ] + ], + [ + [ + "ad" + ], + [ + "https://rdap.nic.ad/" + ] + ], + [ + [ + "adult" + ], + [ + "https://rdap.nic.adult/" + ] + ], + [ + [ + "aetna" + ], + [ + "https://rdap.nic.aetna/" + ] + ], + [ + [ + "afl" + ], + [ + "https://rdap.nic.afl/" + ] + ], + [ + [ + "africa" + ], + [ + "https://rdap.nic.africa/rdap/" + ] + ], + [ + [ + "aig" + ], + [ + "https://rdap.nic.aig/" + ] + ], + [ + [ + "airtel" + ], + [ + "https://rdap.nic.airtel/" + ] + ], + [ + [ + "ally" + ], + [ + "https://rdap.nic.ally/" + ] + ], + [ + [ + "alsace" + ], + [ + "https://rdap.nic.alsace/" + ] + ], + [ + [ + "alstom" + ], + [ + "https://rdap.nic.alstom/" + ] + ], + [ + [ + "americanexpress" + ], + [ + "https://rdap.nic.americanexpress/" + ] + ], + [ + [ + "americanfamily" + ], + [ + "https://rdap.nic.americanfamily/" + ] + ], + [ + [ + "amex" + ], + [ + "https://rdap.nic.amex/" + ] + ], + [ + [ + "amfam" + ], + [ + "https://rdap.nic.amfam/" + ] + ], + [ + [ + "amica" + ], + [ + "https://rdap.nic.amica/" + ] + ], + [ + [ + "amsterdam" + ], + [ + "https://rdap.nic.amsterdam/" + ] + ], + [ + [ + "analytics" + ], + [ + "https://rdap.nic.analytics/" + ] + ], + [ + [ + "anz" + ], + [ + "https://rdap.nic.anz/" + ] + ], + [ + [ + "apple" + ], + [ + "https://rdap.nic.apple/" + ] + ], + [ + [ + "aquarelle" + ], + [ + "https://rdap.nic.aquarelle/" + ] + ], + [ + [ + "ar" + ], + [ + "https://rdap.nic.ar/" + ] + ], + [ + [ + "arab" + ], + [ + "https://rdap.nic.arab/" + ] + ], + [ + [ + "aramco" + ], + [ + "https://rdap.nic.aramco/" + ] + ], + [ + [ + "as" + ], + [ + "https://rdap.nic.as/" + ] + ], + [ + [ + "athleta" + ], + [ + "https://rdap.nic.athleta/" + ] + ], + [ + [ + "auspost" + ], + [ + "https://rdap.nic.auspost/" + ] + ], + [ + [ + "axa" + ], + [ + "https://rdap.nic.axa/" + ] + ], + [ + [ + "banamex" + ], + [ + "https://rdap.nic.banamex/" + ] + ], + [ + [ + "bank" + ], + [ + "https://rdap.nic.bank/" + ] + ], + [ + [ + "barcelona" + ], + [ + "https://rdap.nic.barcelona/" + ] + ], + [ + [ + "baseball" + ], + [ + "https://rdap.nic.baseball/" + ] + ], + [ + [ + "basketball" + ], + [ + "https://rdap.nic.basketball/" + ] + ], + [ + [ + "bauhaus" + ], + [ + "https://rdap.nic.bauhaus/" + ] + ], + [ + [ + "bayern" + ], + [ + "https://rdap.nic.bayern/" + ] + ], + [ + [ + "bcn" + ], + [ + "https://rdap.nic.bcn/" + ] + ], + [ + [ + "beer" + ], + [ + "https://rdap.nic.beer/" + ] + ], + [ + [ + "berlin" + ], + [ + "https://rdap.nic.berlin/v1/" + ] + ], + [ + [ + "bharti" + ], + [ + "https://rdap.nic.bharti/" + ] + ], + [ + [ + "bible" + ], + [ + "https://rdap.nic.bible/" + ] + ], + [ + [ + "bid" + ], + [ + "https://rdap.nic.bid/" + ] + ], + [ + [ + "biz" + ], + [ + "https://rdap.nic.biz/" + ] + ], + [ + [ + "blackfriday" + ], + [ + "https://rdap.nic.blackfriday/" + ] + ], + [ + [ + "booking" + ], + [ + "https://rdap.nic.booking/" + ] + ], + [ + [ + "bostik" + ], + [ + "https://rdap.nic.bostik/" + ] + ], + [ + [ + "boston" + ], + [ + "https://rdap.nic.boston/" + ] + ], + [ + [ + "brussels" + ], + [ + "https://rdap.nic.brussels/" + ] + ], + [ + [ + "buzz" + ], + [ + "https://rdap.nic.buzz/" + ] + ], + [ + [ + "bzh" + ], + [ + "https://rdap.nic.bzh/" + ] + ], + [ + [ + "calvinklein" + ], + [ + "https://rdap.nic.calvinklein/" + ] + ], + [ + [ + "capetown" + ], + [ + "https://rdap.nic.capetown/rdap/" + ] + ], + [ + [ + "capitalone" + ], + [ + "https://rdap.nic.capitalone/" + ] + ], + [ + [ + "caravan" + ], + [ + "https://rdap.nic.caravan/" + ] + ], + [ + [ + "casa" + ], + [ + "https://rdap.nic.casa/" + ] + ], + [ + [ + "cat" + ], + [ + "https://rdap.nic.cat/" + ] + ], + [ + [ + "catholic" + ], + [ + "https://rdap.nic.catholic/" + ] + ], + [ + [ + "cba" + ], + [ + "https://rdap.nic.cba/" + ] + ], + [ + [ + "cbn" + ], + [ + "https://rdap.nic.cbn/" + ] + ], + [ + [ + "cbre" + ], + [ + "https://rdap.nic.cbre/" + ] + ], + [ + [ + "chase" + ], + [ + "https://rdap.nic.chase/" + ] + ], + [ + [ + "chintai" + ], + [ + "https://rdap.nic.chintai/" + ] + ], + [ + [ + "cisco" + ], + [ + "https://rdap.nic.cisco/" + ] + ], + [ + [ + "citi" + ], + [ + "https://rdap.nic.citi/" + ] + ], + [ + [ + "club" + ], + [ + "https://rdap.nic.club/" + ] + ], + [ + [ + "cm" + ], + [ + "https://rdap.nic.cm/" + ] + ], + [ + [ + "commbank" + ], + [ + "https://rdap.nic.commbank/" + ] + ], + [ + [ + "compare" + ], + [ + "https://rdap.nic.compare/" + ] + ], + [ + [ + "cooking" + ], + [ + "https://rdap.nic.cooking/" + ] + ], + [ + [ + "corsica" + ], + [ + "https://rdap.nic.corsica/" + ] + ], + [ + [ + "courses" + ], + [ + "https://rdap.nic.courses/" + ] + ], + [ + [ + "cpa" + ], + [ + "https://rdap.nic.cpa/" + ] + ], + [ + [ + "cr" + ], + [ + "https://rdap.nic.cr/" + ] + ], + [ + [ + "cricket" + ], + [ + "https://rdap.nic.cricket/" + ] + ], + [ + [ + "cuisinella" + ], + [ + "https://rdap.nic.cuisinella/" + ] + ], + [ + [ + "cv" + ], + [ + "https://rdap.nic.cv/" + ] + ], + [ + [ + "cx" + ], + [ + "https://rdap.nic.cx/" + ] + ], + [ + [ + "cz" + ], + [ + "https://rdap.nic.cz/" + ] + ], + [ + [ + "date" + ], + [ + "https://rdap.nic.date/" + ] + ], + [ + [ + "dds" + ], + [ + "https://rdap.nic.dds/" + ] + ], + [ + [ + "dell" + ], + [ + "https://rdap.nic.dell/" + ] + ], + [ + [ + "design" + ], + [ + "https://rdap.nic.design/" + ] + ], + [ + [ + "download" + ], + [ + "https://rdap.nic.download/" + ] + ], + [ + [ + "dubai" + ], + [ + "https://rdap.nic.dubai/" + ] + ], + [ + [ + "dupont" + ], + [ + "https://rdap.nic.dupont/" + ] + ], + [ + [ + "durban" + ], + [ + "https://rdap.nic.durban/rdap/" + ] + ], + [ + [ + "earth" + ], + [ + "https://rdap.nic.earth/" + ] + ], + [ + [ + "erni" + ], + [ + "https://rdap.nic.erni/" + ] + ], + [ + [ + "eurovision" + ], + [ + "https://rdap.nic.eurovision/" + ] + ], + [ + [ + "eus" + ], + [ + "https://rdap.nic.eus/" + ] + ], + [ + [ + "faith" + ], + [ + "https://rdap.nic.faith/" + ] + ], + [ + [ + "farmers" + ], + [ + "https://rdap.nic.farmers/" + ] + ], + [ + [ + "fashion" + ], + [ + "https://rdap.nic.fashion/" + ] + ], + [ + [ + "ferrero" + ], + [ + "https://rdap.nic.ferrero/" + ] + ], + [ + [ + "film" + ], + [ + "https://rdap.nic.film/" + ] + ], + [ + [ + "firmdale" + ], + [ + "https://rdap.nic.firmdale/" + ] + ], + [ + [ + "fishing" + ], + [ + "https://rdap.nic.fishing/" + ] + ], + [ + [ + "fit" + ], + [ + "https://rdap.nic.fit/" + ] + ], + [ + [ + "flickr" + ], + [ + "https://rdap.nic.flickr/" + ] + ], + [ + [ + "flir" + ], + [ + "https://rdap.nic.flir/" + ] + ], + [ + [ + "ford" + ], + [ + "https://rdap.nic.ford/" + ] + ], + [ + [ + "fox" + ], + [ + "https://rdap.nic.fox/" + ] + ], + [ + [ + "fr" + ], + [ + "https://rdap.nic.fr/" + ] + ], + [ + [ + "frontier" + ], + [ + "https://rdap.nic.frontier/" + ] + ], + [ + [ + "ftr" + ], + [ + "https://rdap.nic.ftr/" + ] + ], + [ + [ + "gal" + ], + [ + "https://rdap.nic.gal/" + ] + ], + [ + [ + "gap" + ], + [ + "https://rdap.nic.gap/" + ] + ], + [ + [ + "garden" + ], + [ + "https://rdap.nic.garden/" + ] + ], + [ + [ + "gay" + ], + [ + "https://rdap.nic.gay/" + ] + ], + [ + [ + "gdn" + ], + [ + "https://rdap.nic.gdn/" + ] + ], + [ + [ + "gea" + ], + [ + "https://rdap.nic.gea/" + ] + ], + [ + [ + "george" + ], + [ + "https://rdap.nic.george/" + ] + ], + [ + [ + "gmx" + ], + [ + "https://rdap.nic.gmx/" + ] + ], + [ + [ + "godaddy" + ], + [ + "https://rdap.nic.godaddy/" + ] + ], + [ + [ + "gov" + ], + [ + "https://rdap.nic.gov/rdap/" + ] + ], + [ + [ + "grainger" + ], + [ + "https://rdap.nic.grainger/" + ] + ], + [ + [ + "grocery" + ], + [ + "https://rdap.nic.grocery/" + ] + ], + [ + [ + "gs" + ], + [ + "https://rdap.nic.gs/" + ] + ], + [ + [ + "hamburg" + ], + [ + "https://rdap.nic.hamburg/v1/" + ] + ], + [ + [ + "hbo" + ], + [ + "https://rdap.nic.hbo/" + ] + ], + [ + [ + "health" + ], + [ + "https://rdap.nic.health/" + ] + ], + [ + [ + "hn" + ], + [ + "https://rdap.nic.hn/" + ] + ], + [ + [ + "homegoods" + ], + [ + "https://rdap.nic.homegoods/" + ] + ], + [ + [ + "homesense" + ], + [ + "https://rdap.nic.homesense/" + ] + ], + [ + [ + "horse" + ], + [ + "https://rdap.nic.horse/" + ] + ], + [ + [ + "hotels" + ], + [ + "https://rdap.nic.hotels/" + ] + ], + [ + [ + "hsbc" + ], + [ + "https://rdap.nic.hsbc/" + ] + ], + [ + [ + "ht" + ], + [ + "https://rdap.nic.ht/" + ] + ], + [ + [ + "hyatt" + ], + [ + "https://rdap.nic.hyatt/" + ] + ], + [ + [ + "ibm" + ], + [ + "https://rdap.nic.ibm/" + ] + ], + [ + [ + "ifm" + ], + [ + "https://rdap.nic.ifm/" + ] + ], + [ + [ + "ikano" + ], + [ + "https://rdap.nic.ikano/v1/" + ] + ], + [ + [ + "ink" + ], + [ + "https://rdap.nic.ink/" + ] + ], + [ + [ + "insurance" + ], + [ + "https://rdap.nic.insurance/" + ] + ], + [ + [ + "intuit" + ], + [ + "https://rdap.nic.intuit/" + ] + ], + [ + [ + "ipiranga" + ], + [ + "https://rdap.nic.ipiranga/" + ] + ], + [ + [ + "itau" + ], + [ + "https://rdap.nic.itau/" + ] + ], + [ + [ + "jmp" + ], + [ + "https://rdap.nic.jmp/" + ] + ], + [ + [ + "joburg" + ], + [ + "https://rdap.nic.joburg/rdap/" + ] + ], + [ + [ + "jpmorgan" + ], + [ + "https://rdap.nic.jpmorgan/" + ] + ], + [ + [ + "jprs" + ], + [ + "https://rdap.nic.jprs/rdap/" + ] + ], + [ + [ + "kpmg" + ], + [ + "https://rdap.nic.kpmg/" + ] + ], + [ + [ + "krd" + ], + [ + "https://rdap.nic.krd/" + ] + ], + [ + [ + "lacaixa" + ], + [ + "https://rdap.nic.lacaixa/" + ] + ], + [ + [ + "lanxess" + ], + [ + "https://rdap.nic.lanxess/" + ] + ], + [ + [ + "latrobe" + ], + [ + "https://rdap.nic.latrobe/" + ] + ], + [ + [ + "law" + ], + [ + "https://rdap.nic.law/" + ] + ], + [ + [ + "leclerc" + ], + [ + "https://rdap.nic.leclerc/" + ] + ], + [ + [ + "lifeinsurance" + ], + [ + "https://rdap.nic.lifeinsurance/" + ] + ], + [ + [ + "lilly" + ], + [ + "https://rdap.nic.lilly/" + ] + ], + [ + [ + "lincoln" + ], + [ + "https://rdap.nic.lincoln/" + ] + ], + [ + [ + "loan" + ], + [ + "https://rdap.nic.loan/" + ] + ], + [ + [ + "locker" + ], + [ + "https://rdap.nic.locker/rdap/" + ] + ], + [ + [ + "luxe" + ], + [ + "https://rdap.nic.luxe/" + ] + ], + [ + [ + "ly" + ], + [ + "https://rdap.nic.ly/" + ] + ], + [ + [ + "madrid" + ], + [ + "https://rdap.nic.madrid/" + ] + ], + [ + [ + "man" + ], + [ + "https://rdap.nic.man/" + ] + ], + [ + [ + "mango" + ], + [ + "https://rdap.nic.mango/" + ] + ], + [ + [ + "marshalls" + ], + [ + "https://rdap.nic.marshalls/" + ] + ], + [ + [ + "mattel" + ], + [ + "https://rdap.nic.mattel/" + ] + ], + [ + [ + "melbourne" + ], + [ + "https://rdap.nic.melbourne/" + ] + ], + [ + [ + "men" + ], + [ + "https://rdap.nic.men/" + ] + ], + [ + [ + "menu" + ], + [ + "https://rdap.nic.menu/" + ] + ], + [ + [ + "merckmsd" + ], + [ + "https://rdap.nic.merckmsd/" + ] + ], + [ + [ + "miami" + ], + [ + "https://rdap.nic.miami/" + ] + ], + [ + [ + "mint" + ], + [ + "https://rdap.nic.mint/" + ] + ], + [ + [ + "ml" + ], + [ + "https://rdap.nic.ml/" + ] + ], + [ + [ + "mlb" + ], + [ + "https://rdap.nic.mlb/" + ] + ], + [ + [ + "mma" + ], + [ + "https://rdap.nic.mma/" + ] + ], + [ + [ + "moe" + ], + [ + "https://rdap.nic.moe/" + ] + ], + [ + [ + "monash" + ], + [ + "https://rdap.nic.monash/" + ] + ], + [ + [ + "moto" + ], + [ + "https://rdap.nic.moto/" + ] + ], + [ + [ + "ms" + ], + [ + "https://rdap.nic.ms/" + ] + ], + [ + [ + "msd" + ], + [ + "https://rdap.nic.msd/" + ] + ], + [ + [ + "museum" + ], + [ + "https://rdap.nic.museum/" + ] + ], + [ + [ + "nba" + ], + [ + "https://rdap.nic.nba/" + ] + ], + [ + [ + "netbank" + ], + [ + "https://rdap.nic.netbank/" + ] + ], + [ + [ + "netflix" + ], + [ + "https://rdap.nic.netflix/" + ] + ], + [ + [ + "neustar" + ], + [ + "https://rdap.nic.neustar/" + ] + ], + [ + [ + "nf" + ], + [ + "https://rdap.nic.nf/" + ] + ], + [ + [ + "nfl" + ], + [ + "https://rdap.nic.nfl/" + ] + ], + [ + [ + "nike" + ], + [ + "https://rdap.nic.nike/" + ] + ], + [ + [ + "norton" + ], + [ + "https://rdap.nic.norton/" + ] + ], + [ + [ + "nrw" + ], + [ + "https://rdap.nic.nrw/" + ] + ], + [ + [ + "ntt" + ], + [ + "https://rdap.nic.ntt/rdap/" + ] + ], + [ + [ + "nyc" + ], + [ + "https://rdap.nic.nyc/" + ] + ], + [ + [ + "olayan" + ], + [ + "https://rdap.nic.olayan/" + ] + ], + [ + [ + "olayangroup" + ], + [ + "https://rdap.nic.olayangroup/" + ] + ], + [ + [ + "one" + ], + [ + "https://rdap.nic.one/" + ] + ], + [ + [ + "open" + ], + [ + "https://rdap.nic.open/" + ] + ], + [ + [ + "osaka" + ], + [ + "https://rdap.nic.osaka/" + ] + ], + [ + [ + "ovh" + ], + [ + "https://rdap.nic.ovh/" + ] + ], + [ + [ + "paris" + ], + [ + "https://rdap.nic.paris/" + ] + ], + [ + [ + "party" + ], + [ + "https://rdap.nic.party/" + ] + ], + [ + [ + "pfizer" + ], + [ + "https://rdap.nic.pfizer/" + ] + ], + [ + [ + "pg" + ], + [ + "https://rdap.nic.pg/" + ] + ], + [ + [ + "philips" + ], + [ + "https://rdap.nic.philips/" + ] + ], + [ + [ + "photo" + ], + [ + "https://rdap.nic.photo/" + ] + ], + [ + [ + "physio" + ], + [ + "https://rdap.nic.physio/" + ] + ], + [ + [ + "ping" + ], + [ + "https://rdap.nic.ping/" + ] + ], + [ + [ + "pm" + ], + [ + "https://rdap.nic.pm/" + ] + ], + [ + [ + "politie" + ], + [ + "https://rdap.nic.politie/" + ] + ], + [ + [ + "porn" + ], + [ + "https://rdap.nic.porn/" + ] + ], + [ + [ + "praxi" + ], + [ + "https://rdap.nic.praxi/" + ] + ], + [ + [ + "pru" + ], + [ + "https://rdap.nic.pru/" + ] + ], + [ + [ + "prudential" + ], + [ + "https://rdap.nic.prudential/" + ] + ], + [ + [ + "quebec" + ], + [ + "https://rdap.nic.quebec/" + ] + ], + [ + [ + "racing" + ], + [ + "https://rdap.nic.racing/" + ] + ], + [ + [ + "radio" + ], + [ + "https://rdap.nic.radio/" + ] + ], + [ + [ + "re" + ], + [ + "https://rdap.nic.re/" + ] + ], + [ + [ + "review" + ], + [ + "https://rdap.nic.review/" + ] + ], + [ + [ + "rodeo" + ], + [ + "https://rdap.nic.rodeo/" + ] + ], + [ + [ + "rugby" + ], + [ + "https://rdap.nic.rugby/" + ] + ], + [ + [ + "safety" + ], + [ + "https://rdap.nic.safety/" + ] + ], + [ + [ + "sakura" + ], + [ + "https://rdap.nic.sakura/rdap/" + ] + ], + [ + [ + "samsclub" + ], + [ + "https://rdap.nic.samsclub/" + ] + ], + [ + [ + "sandvik" + ], + [ + "https://rdap.nic.sandvik/" + ] + ], + [ + [ + "sandvikcoromant" + ], + [ + "https://rdap.nic.sandvikcoromant/" + ] + ], + [ + [ + "sap" + ], + [ + "https://rdap.nic.sap/" + ] + ], + [ + [ + "sas" + ], + [ + "https://rdap.nic.sas/" + ] + ], + [ + [ + "scb" + ], + [ + "https://rdap.nic.scb/" + ] + ], + [ + [ + "schaeffler" + ], + [ + "https://rdap.nic.schaeffler/" + ] + ], + [ + [ + "schmidt" + ], + [ + "https://rdap.nic.schmidt/" + ] + ], + [ + [ + "science" + ], + [ + "https://rdap.nic.science/" + ] + ], + [ + [ + "scot" + ], + [ + "https://rdap.nic.scot/" + ] + ], + [ + [ + "sd" + ], + [ + "https://rdap.nic.sd/" + ] + ], + [ + [ + "seat" + ], + [ + "https://rdap.nic.seat/" + ] + ], + [ + [ + "seek" + ], + [ + "https://rdap.nic.seek/" + ] + ], + [ + [ + "select" + ], + [ + "https://rdap.nic.select/" + ] + ], + [ + [ + "seven" + ], + [ + "https://rdap.nic.seven/" + ] + ], + [ + [ + "sex" + ], + [ + "https://rdap.nic.sex/" + ] + ], + [ + [ + "sn" + ], + [ + "https://rdap.nic.sn/whois43/" + ] + ], + [ + [ + "sncf" + ], + [ + "https://rdap.nic.sncf/" + ] + ], + [ + [ + "sport" + ], + [ + "https://rdap.nic.sport/" + ] + ], + [ + [ + "ss" + ], + [ + "https://rdap.nic.ss/" + ] + ], + [ + [ + "staples" + ], + [ + "https://rdap.nic.staples/" + ] + ], + [ + [ + "statefarm" + ], + [ + "https://rdap.nic.statefarm/" + ] + ], + [ + [ + "stream" + ], + [ + "https://rdap.nic.stream/" + ] + ], + [ + [ + "study" + ], + [ + "https://rdap.nic.study/" + ] + ], + [ + [ + "sucks" + ], + [ + "https://rdap.nic.sucks/" + ] + ], + [ + [ + "surf" + ], + [ + "https://rdap.nic.surf/" + ] + ], + [ + [ + "swiss" + ], + [ + "https://rdap.nic.swiss/" + ] + ], + [ + [ + "sydney" + ], + [ + "https://rdap.nic.sydney/" + ] + ], + [ + [ + "tab" + ], + [ + "https://rdap.nic.tab/" + ] + ], + [ + [ + "taipei" + ], + [ + "https://rdap.nic.taipei/" + ] + ], + [ + [ + "target" + ], + [ + "https://rdap.nic.target/" + ] + ], + [ + [ + "tattoo" + ], + [ + "https://rdap.nic.tattoo/" + ] + ], + [ + [ + "tdk" + ], + [ + "https://rdap.nic.tdk/" + ] + ], + [ + [ + "tel" + ], + [ + "https://rdap.nic.tel/" + ] + ], + [ + [ + "teva" + ], + [ + "https://rdap.nic.teva/" + ] + ], + [ + [ + "tf" + ], + [ + "https://rdap.nic.tf/" + ] + ], + [ + [ + "tjmaxx" + ], + [ + "https://rdap.nic.tjmaxx/" + ] + ], + [ + [ + "tjx" + ], + [ + "https://rdap.nic.tjx/" + ] + ], + [ + [ + "tkmaxx" + ], + [ + "https://rdap.nic.tkmaxx/" + ] + ], + [ + [ + "total" + ], + [ + "https://rdap.nic.total/" + ] + ], + [ + [ + "trade" + ], + [ + "https://rdap.nic.trade/" + ] + ], + [ + [ + "tube" + ], + [ + "https://rdap.nic.tube/" + ] + ], + [ + [ + "tv" + ], + [ + "https://rdap.nic.tv/" + ] + ], + [ + [ + "versicherung" + ], + [ + "https://rdap.nic.versicherung/v1/" + ] + ], + [ + [ + "vi" + ], + [ + "https://rdap.nic.vi/" + ] + ], + [ + [ + "vip" + ], + [ + "https://rdap.nic.vip/" + ] + ], + [ + [ + "vivo" + ], + [ + "https://rdap.nic.vivo/" + ] + ], + [ + [ + "vlaanderen" + ], + [ + "https://rdap.nic.vlaanderen/" + ] + ], + [ + [ + "vodka" + ], + [ + "https://rdap.nic.vodka/" + ] + ], + [ + [ + "voting" + ], + [ + "https://rdap.nic.voting/" + ] + ], + [ + [ + "walmart" + ], + [ + "https://rdap.nic.walmart/" + ] + ], + [ + [ + "walter" + ], + [ + "https://rdap.nic.walter/" + ] + ], + [ + [ + "weather" + ], + [ + "https://rdap.nic.weather/" + ] + ], + [ + [ + "weatherchannel" + ], + [ + "https://rdap.nic.weatherchannel/" + ] + ], + [ + [ + "webcam" + ], + [ + "https://rdap.nic.webcam/" + ] + ], + [ + [ + "wedding" + ], + [ + "https://rdap.nic.wedding/" + ] + ], + [ + [ + "wf" + ], + [ + "https://rdap.nic.wf/" + ] + ], + [ + [ + "whoswho" + ], + [ + "https://rdap.nic.whoswho/" + ] + ], + [ + [ + "wiki" + ], + [ + "https://rdap.nic.wiki/" + ] + ], + [ + [ + "williamhill" + ], + [ + "https://rdap.nic.williamhill/" + ] + ], + [ + [ + "win" + ], + [ + "https://rdap.nic.win/" + ] + ], + [ + [ + "winners" + ], + [ + "https://rdap.nic.winners/" + ] + ], + [ + [ + "woodside" + ], + [ + "https://rdap.nic.woodside/" + ] + ], + [ + [ + "work" + ], + [ + "https://rdap.nic.work/" + ] + ], + [ + [ + "wtc" + ], + [ + "https://rdap.nic.wtc/" + ] + ], + [ + [ + "xerox" + ], + [ + "https://rdap.nic.xerox/" + ] + ], + [ + [ + "xn--80aqecdr1a" + ], + [ + "https://rdap.nic.xn--80aqecdr1a/" + ] + ], + [ + [ + "xn--80asehdb" + ], + [ + "https://rdap.nic.xn--80asehdb/" + ] + ], + [ + [ + "xn--80aswg" + ], + [ + "https://rdap.nic.xn--80aswg/" + ] + ], + [ + [ + "xn--g2xx48c" + ], + [ + "https://rdap.nic.xn--g2xx48c/" + ] + ], + [ + [ + "xn--kcrx77d1x4a" + ], + [ + "https://rdap.nic.xn--kcrx77d1x4a/" + ] + ], + [ + [ + "xn--mgba3a3ejt" + ], + [ + "https://rdap.nic.xn--mgba3a3ejt/" + ] + ], + [ + [ + "xn--mgba7c0bbn0a" + ], + [ + "https://rdap.nic.xn--mgba7c0bbn0a/" + ] + ], + [ + [ + "xn--mgbab2bd" + ], + [ + "https://rdap.nic.xn--mgbab2bd/" + ] + ], + [ + [ + "xn--mgbca7dzdo" + ], + [ + "https://rdap.nic.xn--mgbca7dzdo/" + ] + ], + [ + [ + "xn--mgbi4ecexp" + ], + [ + "https://rdap.nic.xn--mgbi4ecexp/" + ] + ], + [ + [ + "xn--ngbc5azd" + ], + [ + "https://rdap.nic.xn--ngbc5azd/" + ] + ], + [ + [ + "xn--ngbrx" + ], + [ + "https://rdap.nic.xn--ngbrx/" + ] + ], + [ + [ + "xn--p1acf" + ], + [ + "https://rdap.nic.xn--p1acf/" + ] + ], + [ + [ + "xn--tiq49xqyj" + ], + [ + "https://rdap.nic.xn--tiq49xqyj/" + ] + ], + [ + [ + "xxx" + ], + [ + "https://rdap.nic.xxx/" + ] + ], + [ + [ + "yandex" + ], + [ + "https://rdap.nic.yandex/rdap/" + ] + ], + [ + [ + "yoga" + ], + [ + "https://rdap.nic.yoga/" + ] + ], + [ + [ + "yt" + ], + [ + "https://rdap.nic.yt/" + ] + ], + [ + [ + "zm" + ], + [ + "https://rdap.nic.zm/" + ] + ], + [ + [ + "in" + ], + [ + "https://rdap.nixiregistry.in/rdap/" + ] + ], + [ + [ + "abbvie" + ], + [ + "https://rdap.nominet.uk/abbvie/" + ] + ], + [ + [ + "amazon" + ], + [ + "https://rdap.nominet.uk/amazon/" + ] + ], + [ + [ + "audible" + ], + [ + "https://rdap.nominet.uk/audible/" + ] + ], + [ + [ + "author" + ], + [ + "https://rdap.nominet.uk/author/" + ] + ], + [ + [ + "aws" + ], + [ + "https://rdap.nominet.uk/aws/" + ] + ], + [ + [ + "azure" + ], + [ + "https://rdap.nominet.uk/azure/" + ] + ], + [ + [ + "bbc" + ], + [ + "https://rdap.nominet.uk/bbc/" + ] + ], + [ + [ + "bbva" + ], + [ + "https://rdap.nominet.uk/bbva/" + ] + ], + [ + [ + "bing" + ], + [ + "https://rdap.nominet.uk/bing/" + ] + ], + [ + [ + "book" + ], + [ + "https://rdap.nominet.uk/book/" + ] + ], + [ + [ + "bot" + ], + [ + "https://rdap.nominet.uk/bot/" + ] + ], + [ + [ + "broadway" + ], + [ + "https://rdap.nominet.uk/broadway/" + ] + ], + [ + [ + "buy" + ], + [ + "https://rdap.nominet.uk/buy/" + ] + ], + [ + [ + "call" + ], + [ + "https://rdap.nominet.uk/call/" + ] + ], + [ + [ + "career" + ], + [ + "https://rdap.nominet.uk/career/" + ] + ], + [ + [ + "circle" + ], + [ + "https://rdap.nominet.uk/circle/" + ] + ], + [ + [ + "cymru" + ], + [ + "https://rdap.nominet.uk/cymru/" + ] + ], + [ + [ + "deal" + ], + [ + "https://rdap.nominet.uk/deal/" + ] + ], + [ + [ + "desi" + ], + [ + "https://rdap.nominet.uk/desi/" + ] + ], + [ + [ + "fairwinds" + ], + [ + "https://rdap.nominet.uk/fairwinds/" + ] + ], + [ + [ + "fast" + ], + [ + "https://rdap.nominet.uk/fast/" + ] + ], + [ + [ + "fire" + ], + [ + "https://rdap.nominet.uk/fire/" + ] + ], + [ + [ + "free" + ], + [ + "https://rdap.nominet.uk/free/" + ] + ], + [ + [ + "gop" + ], + [ + "https://rdap.nominet.uk/gop/" + ] + ], + [ + [ + "got" + ], + [ + "https://rdap.nominet.uk/got/" + ] + ], + [ + [ + "gucci" + ], + [ + "https://rdap.nominet.uk/gucci/" + ] + ], + [ + [ + "hot" + ], + [ + "https://rdap.nominet.uk/hot/" + ] + ], + [ + [ + "hotmail" + ], + [ + "https://rdap.nominet.uk/hotmail/" + ] + ], + [ + [ + "ieee" + ], + [ + "https://rdap.nominet.uk/ieee/" + ] + ], + [ + [ + "imdb" + ], + [ + "https://rdap.nominet.uk/imdb/" + ] + ], + [ + [ + "jobs" + ], + [ + "https://rdap.nominet.uk/jobs/" + ] + ], + [ + [ + "jot" + ], + [ + "https://rdap.nominet.uk/jot/" + ] + ], + [ + [ + "joy" + ], + [ + "https://rdap.nominet.uk/joy/" + ] + ], + [ + [ + "kindle" + ], + [ + "https://rdap.nominet.uk/kindle/" + ] + ], + [ + [ + "like" + ], + [ + "https://rdap.nominet.uk/like/" + ] + ], + [ + [ + "locus" + ], + [ + "https://rdap.nominet.uk/locus/" + ] + ], + [ + [ + "med" + ], + [ + "https://rdap.nominet.uk/med/" + ] + ], + [ + [ + "microsoft" + ], + [ + "https://rdap.nominet.uk/microsoft/" + ] + ], + [ + [ + "moi" + ], + [ + "https://rdap.nominet.uk/moi/" + ] + ], + [ + [ + "mtn" + ], + [ + "https://rdap.nominet.uk/mtn/" + ] + ], + [ + [ + "now" + ], + [ + "https://rdap.nominet.uk/now/" + ] + ], + [ + [ + "nowruz" + ], + [ + "https://rdap.nominet.uk/nowruz/" + ] + ], + [ + [ + "office" + ], + [ + "https://rdap.nominet.uk/office/" + ] + ], + [ + [ + "omega" + ], + [ + "https://rdap.nominet.uk/omega/" + ] + ], + [ + [ + "pars" + ], + [ + "https://rdap.nominet.uk/pars/" + ] + ], + [ + [ + "pay" + ], + [ + "https://rdap.nominet.uk/pay/" + ] + ], + [ + [ + "pharmacy" + ], + [ + "https://rdap.nominet.uk/pharmacy/" + ] + ], + [ + [ + "pin" + ], + [ + "https://rdap.nominet.uk/pin/" + ] + ], + [ + [ + "pioneer" + ], + [ + "https://rdap.nominet.uk/pioneer/" + ] + ], + [ + [ + "pn" + ], + [ + "https://rdap.nominet.uk/pn/" + ] + ], + [ + [ + "prime" + ], + [ + "https://rdap.nominet.uk/prime/" + ] + ], + [ + [ + "read" + ], + [ + "https://rdap.nominet.uk/read/" + ] + ], + [ + [ + "realestate" + ], + [ + "https://rdap.nominet.uk/realestate/" + ] + ], + [ + [ + "realtor" + ], + [ + "https://rdap.nominet.uk/realtor/" + ] + ], + [ + [ + "room" + ], + [ + "https://rdap.nominet.uk/room/" + ] + ], + [ + [ + "safe" + ], + [ + "https://rdap.nominet.uk/safe/" + ] + ], + [ + [ + "save" + ], + [ + "https://rdap.nominet.uk/save/" + ] + ], + [ + [ + "secure" + ], + [ + "https://rdap.nominet.uk/secure/" + ] + ], + [ + [ + "shell" + ], + [ + "https://rdap.nominet.uk/shell/" + ] + ], + [ + [ + "shia" + ], + [ + "https://rdap.nominet.uk/shia/" + ] + ], + [ + [ + "silk" + ], + [ + "https://rdap.nominet.uk/silk/" + ] + ], + [ + [ + "sky" + ], + [ + "https://rdap.nominet.uk/sky/" + ] + ], + [ + [ + "skype" + ], + [ + "https://rdap.nominet.uk/skype/" + ] + ], + [ + [ + "smile" + ], + [ + "https://rdap.nominet.uk/smile/" + ] + ], + [ + [ + "spot" + ], + [ + "https://rdap.nominet.uk/spot/" + ] + ], + [ + [ + "swatch" + ], + [ + "https://rdap.nominet.uk/swatch/" + ] + ], + [ + [ + "talk" + ], + [ + "https://rdap.nominet.uk/talk/" + ] + ], + [ + [ + "tci" + ], + [ + "https://rdap.nominet.uk/tci/" + ] + ], + [ + [ + "tunes" + ], + [ + "https://rdap.nominet.uk/tunes/" + ] + ], + [ + [ + "tushu" + ], + [ + "https://rdap.nominet.uk/tushu/" + ] + ], + [ + [ + "uk" + ], + [ + "https://rdap.nominet.uk/uk/" + ] + ], + [ + [ + "virgin" + ], + [ + "https://rdap.nominet.uk/virgin/" + ] + ], + [ + [ + "wales" + ], + [ + "https://rdap.nominet.uk/wales/" + ] + ], + [ + [ + "wanggou" + ], + [ + "https://rdap.nominet.uk/wanggou/" + ] + ], + [ + [ + "wed" + ], + [ + "https://rdap.nominet.uk/wed/" + ] + ], + [ + [ + "windows" + ], + [ + "https://rdap.nominet.uk/windows/" + ] + ], + [ + [ + "wow" + ], + [ + "https://rdap.nominet.uk/wow/" + ] + ], + [ + [ + "xbox" + ], + [ + "https://rdap.nominet.uk/xbox/" + ] + ], + [ + [ + "xn--cckwcxetd" + ], + [ + "https://rdap.nominet.uk/xn--cckwcxetd/" + ] + ], + [ + [ + "xn--jlq480n2rg" + ], + [ + "https://rdap.nominet.uk/xn--jlq480n2rg/" + ] + ], + [ + [ + "xn--mgbt3dhd" + ], + [ + "https://rdap.nominet.uk/xn--mgbt3dhd/" + ] + ], + [ + [ + "yamaxun" + ], + [ + "https://rdap.nominet.uk/yamaxun/" + ] + ], + [ + [ + "you" + ], + [ + "https://rdap.nominet.uk/you/" + ] + ], + [ + [ + "zappos" + ], + [ + "https://rdap.nominet.uk/zappos/" + ] + ], + [ + [ + "no" + ], + [ + "https://rdap.norid.no/" + ] + ], + [ + [ + "id" + ], + [ + "https://rdap.pandi.id/rdap/" + ] + ], + [ + [ + "charity", + "foundation", + "gives", + "giving", + "ngo", + "ong", + "org", + "xn--c1avg", + "xn--i1b6b1a6a2e", + "xn--nqv7f", + "xn--nqv7fs00ema" + ], + [ + "https://rdap.publicinterestregistry.org/rdap/" + ] + ], + [ + [ + "si" + ], + [ + "https://rdap.register.si/" + ] + ], + [ + [ + "br" + ], + [ + "https://rdap.registro.br/" + ] + ], + [ + [ + "bar", + "rest" + ], + [ + "https://rdap.registry.bar/rdap/" + ] + ], + [ + [ + "feedback", + "forum", + "observer", + "pid", + "realty" + ], + [ + "https://rdap.registry.click/rdap/" + ] + ], + [ + [ + "cloud" + ], + [ + "https://rdap.registry.cloud/rdap/" + ] + ], + [ + [ + "coop", + "creditunion" + ], + [ + "https://rdap.registry.coop/rdap/" + ] + ], + [ + [ + "ec" + ], + [ + "https://rdap.registry.ec/" + ] + ], + [ + [ + "gy" + ], + [ + "https://rdap.registry.gy/" + ] + ], + [ + [ + "hiphop" + ], + [ + "https://rdap.registry.hiphop/rdap/" + ] + ], + [ + [ + "love" + ], + [ + "https://rdap.registry.love/rdap/" + ] + ], + [ + [ + "music" + ], + [ + "https://rdap.registryservices.music/rdap/" + ] + ], + [ + [ + "rw" + ], + [ + "https://rdap.ricta.org.rw/" + ] + ], + [ + [ + "cologne", + "koeln", + "tirol", + "wien" + ], + [ + "https://rdap.ryce-rsp.com/rdap/" + ] + ], + [ + [ + "sg" + ], + [ + "https://rdap.sgnic.sg/rdap/" + ] + ], + [ + [ + "nl" + ], + [ + "https://rdap.sidn.nl/" + ] + ], + [ + [ + "xn--clchc0ea0b2g2a9gcd" + ], + [ + "https://rdap.ta.sgnic.sg/rdap/" + ] + ], + [ + [ + "anquan", + "shouji", + "xihuan", + "xn--vuq861b", + "yun" + ], + [ + "https://rdap.teleinfo.cn/" + ] + ], + [ + [ + "xn--3ds443g" + ], + [ + "https://rdap.teleinfo.cn/xn--3ds443g/" + ] + ], + [ + [ + "xn--fiq228c5hs" + ], + [ + "https://rdap.teleinfo.cn/xn--fiq228c5hs/" + ] + ], + [ + [ + "xn--kput3i" + ], + [ + "https://rdap.teleinfo.cn/xn--kput3i/" + ] + ], + [ + [ + "xn--nyqy26a" + ], + [ + "https://rdap.teleinfo.cn/xn--nyqy26a/" + ] + ], + [ + [ + "xn--rhqv96g" + ], + [ + "https://rdap.teleinfo.cn/xn--rhqv96g/" + ] + ], + [ + [ + "th", + "xn--o3cw4h" + ], + [ + "https://rdap.thains.co.th/" + ] + ], + [ + [ + "to" + ], + [ + "https://rdap.tonicregistry.to/rdap/" + ] + ], + [ + [ + "click", + "country", + "diy", + "food", + "gift", + "hiv", + "lifestyle", + "link", + "living", + "property", + "sexy", + "trust", + "vana" + ], + [ + "https://rdap.tucowsregistry.net/rdap/" + ] + ], + [ + [ + "xn--mxtq1m" + ], + [ + "https://rdap.twnic.tw/rdap/" + ] + ], + [ + [ + "com" + ], + [ + "https://rdap.verisign.com/com/v1/" + ] + ], + [ + [ + "net" + ], + [ + "https://rdap.verisign.com/net/v1/" + ] + ], + [ + [ + "ye" + ], + [ + "https://rdap.y.net.ye/" + ] + ], + [ + [ + "xn--45q11c" + ], + [ + "https://rdap.zdnsgtld.com/XN--45Q11C/" + ] + ], + [ + [ + "xn--efvy88h" + ], + [ + "https://rdap.zdnsgtld.com/XN--EFVY88H/" + ] + ], + [ + [ + "baidu" + ], + [ + "https://rdap.zdnsgtld.com/baidu/" + ] + ], + [ + [ + "citic" + ], + [ + "https://rdap.zdnsgtld.com/citic/" + ] + ], + [ + [ + "icbc" + ], + [ + "https://rdap.zdnsgtld.com/icbc/" + ] + ], + [ + [ + "ren" + ], + [ + "https://rdap.zdnsgtld.com/ren/" + ] + ], + [ + [ + "sohu" + ], + [ + "https://rdap.zdnsgtld.com/sohu/" + ] + ], + [ + [ + "top" + ], + [ + "https://rdap.zdnsgtld.com/top/" + ] + ], + [ + [ + "unicom" + ], + [ + "https://rdap.zdnsgtld.com/unicom/" + ] + ], + [ + [ + "wang" + ], + [ + "https://rdap.zdnsgtld.com/wang/" + ] + ], + [ + [ + "xn--30rr7y" + ], + [ + "https://rdap.zdnsgtld.com/xn--30rr7y/" + ] + ], + [ + [ + "xn--3bst00m" + ], + [ + "https://rdap.zdnsgtld.com/xn--3bst00m/" + ] + ], + [ + [ + "xn--6qq986b3xl" + ], + [ + "https://rdap.zdnsgtld.com/xn--6qq986b3xl/" + ] + ], + [ + [ + "xn--8y0a063a" + ], + [ + "https://rdap.zdnsgtld.com/xn--8y0a063a/" + ] + ], + [ + [ + "xn--9et52u" + ], + [ + "https://rdap.zdnsgtld.com/xn--9et52u/" + ] + ], + [ + [ + "xn--czr694b" + ], + [ + "https://rdap.zdnsgtld.com/xn--czr694b/" + ] + ], + [ + [ + "xn--czru2d" + ], + [ + "https://rdap.zdnsgtld.com/xn--czru2d/" + ] + ], + [ + [ + "xn--fiq64b" + ], + [ + "https://rdap.zdnsgtld.com/xn--fiq64b/" + ] + ], + [ + [ + "xn--hxt814e" + ], + [ + "https://rdap.zdnsgtld.com/xn--hxt814e/" + ] + ], + [ + [ + "xn--imr513n" + ], + [ + "https://rdap.zdnsgtld.com/xn--imr513n/" + ] + ], + [ + [ + "xn--otu796d" + ], + [ + "https://rdap.zdnsgtld.com/xn--otu796d/" + ] + ], + [ + [ + "xn--ses554g" + ], + [ + "https://rdap.zdnsgtld.com/xn--ses554g/" + ] + ], + [ + [ + "xn--yfro4i67o" + ], + [ + "https://rdap.zh.sgnic.sg/rdap/" + ] + ], + [ + [ + "xn--1qqw23a", + "xn--55qx5d", + "xn--io0a7i", + "xn--xhq521b" + ], + [ + "https://restwhois.ngtld.cn/" + ] + ], + [ + [ + "cc" + ], + [ + "https://tld-rdap.verisign.com/cc/v1/" + ] + ], + [ + [ + "comsec" + ], + [ + "https://tld-rdap.verisign.com/comsec/v1/" + ] + ], + [ + [ + "name" + ], + [ + "https://tld-rdap.verisign.com/name/v1/" + ] + ], + [ + [ + "verisign" + ], + [ + "https://tld-rdap.verisign.com/verisign/v1/" + ] + ], + [ + [ + "xn--11b4c3d" + ], + [ + "https://tld-rdap.verisign.com/xn--11b4c3d/v1/" + ] + ], + [ + [ + "xn--3pxu8k" + ], + [ + "https://tld-rdap.verisign.com/xn--3pxu8k/v1/" + ] + ], + [ + [ + "xn--42c2d9a" + ], + [ + "https://tld-rdap.verisign.com/xn--42c2d9a/v1/" + ] + ], + [ + [ + "xn--9dbq2a" + ], + [ + "https://tld-rdap.verisign.com/xn--9dbq2a/v1/" + ] + ], + [ + [ + "xn--c2br7g" + ], + [ + "https://tld-rdap.verisign.com/xn--c2br7g/v1/" + ] + ], + [ + [ + "xn--fhbei" + ], + [ + "https://tld-rdap.verisign.com/xn--fhbei/v1/" + ] + ], + [ + [ + "xn--j1aef" + ], + [ + "https://tld-rdap.verisign.com/xn--j1aef/v1/" + ] + ], + [ + [ + "xn--mk1bu44c" + ], + [ + "https://tld-rdap.verisign.com/xn--mk1bu44c/v1/" + ] + ], + [ + [ + "xn--pssy2u" + ], + [ + "https://tld-rdap.verisign.com/xn--pssy2u/v1/" + ] + ], + [ + [ + "xn--t60b56a" + ], + [ + "https://tld-rdap.verisign.com/xn--t60b56a/v1/" + ] + ], + [ + [ + "xn--tckwe" + ], + [ + "https://tld-rdap.verisign.com/xn--tckwe/v1/" + ] + ], + [ + [ + "ky" + ], + [ + "https://whois.kyregistry.ky/rdap/" + ] + ], + [ + [ + "mtr" + ], + [ + "https://whois.nic.mtr/rdap/" + ] + ], + [ + [ + "tatar" + ], + [ + "https://whois.nic.tatar/rdap/" + ] + ], + [ + [ + "xn--d1acj3b" + ], + [ + "https://whois.nic.xn--d1acj3b/rdap/" + ] + ], + [ + [ + "sr" + ], + [ + "https://whois.sr/rdap/" + ] + ], + [ + [ + "tz" + ], + [ + "https://whois.tznic.or.tz/rdap/" + ] + ], + [ + [ + "fj" + ], + [ + "https://www.rdap.fj/" + ] + ] + ], + "version": "1.0" +} \ No newline at end of file diff --git a/rdap_result.go b/rdap_result.go new file mode 100644 index 0000000..faec44e --- /dev/null +++ b/rdap_result.go @@ -0,0 +1,292 @@ +package whois + +import ( + "encoding/json" + "fmt" + "strings" +) + +type rdapDomainDoc struct { + ObjectClassName string `json:"objectClassName"` + LDHName string `json:"ldhName"` + UnicodeName string `json:"unicodeName"` + Handle string `json:"handle"` + Status []string `json:"status"` + Nameservers []rdapNameServer `json:"nameservers"` + Events []rdapEvent `json:"events"` + Entities []rdapEntityDoc `json:"entities"` +} + +type rdapNameServer struct { + LDHName string `json:"ldhName"` + UnicodeName string `json:"unicodeName"` +} + +type rdapEvent struct { + EventAction string `json:"eventAction"` + EventDate string `json:"eventDate"` +} + +type rdapEntityDoc struct { + Handle string `json:"handle"` + Roles []string `json:"roles"` + PublicIDs []rdapPublicID `json:"publicIds"` + VCard any `json:"vcardArray"` +} + +type rdapPublicID struct { + Type string `json:"type"` + Identifier string `json:"identifier"` +} + +func rdapResponseToResult(query string, resp *RDAPResponse, targetKind lookupTargetKind) (Result, error) { + if resp == nil { + return Result{}, fmt.Errorf("whois/rdap: nil response") + } + var doc rdapDomainDoc + if err := json.Unmarshal(resp.Body, &doc); err != nil { + return Result{}, fmt.Errorf("whois/rdap: decode domain response failed: %w", err) + } + + normalizedQuery := strings.TrimSpace(query) + if targetKind == lookupTargetDomain { + normalizedQuery = strings.ToLower(strings.Trim(normalizedQuery, ".")) + } + result := Result{ + exists: false, + domain: normalizedQuery, + rawData: string(resp.Body), + whoisSer: resp.Endpoint, + } + + switch targetKind { + case lookupTargetDomain: + if n := strings.TrimSpace(doc.LDHName); n != "" { + result.domain = strings.ToLower(n) + result.exists = true + } else if n := strings.TrimSpace(doc.UnicodeName); n != "" { + result.domain = n + result.exists = true + } + if strings.EqualFold(strings.TrimSpace(doc.ObjectClassName), "domain") { + result.exists = true + } + case lookupTargetIP, lookupTargetASN: + if strings.TrimSpace(doc.ObjectClassName) != "" || strings.TrimSpace(doc.Handle) != "" { + result.exists = true + } + if result.domain == "" { + result.domain = normalizedQuery + } + } + if h := strings.TrimSpace(doc.Handle); h != "" { + result.domainID = h + } + + result.statusRaw = appendUniqueStrings(result.statusRaw, normalizeStringSlice(doc.Status)...) + for _, ns := range doc.Nameservers { + v := strings.TrimSpace(ns.LDHName) + if v == "" { + v = strings.TrimSpace(ns.UnicodeName) + } + if v != "" { + result.nsServers = appendUniqueStrings(result.nsServers, v) + } + } + + for _, ev := range doc.Events { + action := strings.ToLower(strings.TrimSpace(ev.EventAction)) + if action == "" { + continue + } + t := parseDateAuto(strings.TrimSpace(ev.EventDate)) + if t.IsZero() { + continue + } + switch { + case strings.Contains(action, "registration"), + strings.Contains(action, "registered"), + strings.Contains(action, "creation"): + if !result.hasRegisterDate { + result.hasRegisterDate = true + result.registerDate = t + } + case strings.Contains(action, "expiration"), + strings.Contains(action, "expiry"), + strings.Contains(action, "expire"): + if !result.hasExpireDate { + result.hasExpireDate = true + result.expireDate = t + } + case strings.Contains(action, "changed"), + strings.Contains(action, "update"): + if !result.hasUpdateDate { + result.hasUpdateDate = true + result.updateDate = t + } + } + } + + for _, ent := range doc.Entities { + info := parseRDAPEntityPersonalInfo(ent.VCard) + isRegistrar, isRegistrant, isAdmin, isTech := classifyRDAPEntityRoles(ent.Roles) + + if isRegistrar { + if result.registrar == "" { + result.registrar = firstNonEmpty(info.Org, info.Name, ent.Handle) + } + if result.ianaID == "" { + for _, pid := range ent.PublicIDs { + typed := strings.ToLower(strings.TrimSpace(pid.Type)) + if strings.Contains(typed, "iana") { + result.ianaID = strings.TrimSpace(pid.Identifier) + break + } + } + } + } + if isRegistrant { + result.registerInfo = mergePersonalInfo(result.registerInfo, info) + } + if isAdmin { + result.adminInfo = mergePersonalInfo(result.adminInfo, info) + } + if isTech { + result.techInfo = mergePersonalInfo(result.techInfo, info) + } + } + + result.meta = buildResultMeta(result, "rdap", resp.Endpoint) + result.meta.Charset = "utf-8" + return result, nil +} + +func parseRDAPEntityPersonalInfo(vcard any) PersonalInfo { + info := PersonalInfo{} + root, ok := vcard.([]any) + if !ok || len(root) < 2 { + return info + } + props, ok := root[1].([]any) + if !ok { + return info + } + + for _, p := range props { + kv, ok := p.([]any) + if !ok || len(kv) < 4 { + continue + } + key := strings.ToLower(strings.TrimSpace(fmt.Sprint(kv[0]))) + val := kv[3] + switch key { + case "fn": + info.Name = firstNonEmpty(info.Name, rdapValueString(val)) + case "org": + info.Org = firstNonEmpty(info.Org, rdapValueString(val)) + case "email": + info.Email = firstNonEmpty(info.Email, rdapValueString(val)) + case "tel": + info.Phone = firstNonEmpty(info.Phone, rdapValueString(val)) + case "adr": + addr, city, state, zip, country := rdapAddressFields(val) + info.Addr = firstNonEmpty(info.Addr, addr) + info.City = firstNonEmpty(info.City, city) + info.State = firstNonEmpty(info.State, state) + info.Zip = firstNonEmpty(info.Zip, zip) + info.Country = firstNonEmpty(info.Country, country) + } + } + return info +} + +func rdapAddressFields(v any) (addr, city, state, zip, country string) { + arr, ok := v.([]any) + if !ok { + return rdapValueString(v), "", "", "", "" + } + get := func(i int) string { + if i < 0 || i >= len(arr) { + return "" + } + return strings.TrimSpace(rdapValueString(arr[i])) + } + addr = strings.TrimSpace(strings.Join(compactStrings([]string{get(0), get(1), get(2)}), " ")) + city = get(3) + state = get(4) + zip = get(5) + country = get(6) + return +} + +func rdapValueString(v any) string { + switch x := v.(type) { + case string: + return strings.TrimSpace(x) + case []any: + parts := make([]string, 0, len(x)) + for _, item := range x { + s := strings.TrimSpace(rdapValueString(item)) + if s != "" { + parts = append(parts, s) + } + } + return strings.Join(parts, " ") + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} + +func classifyRDAPEntityRoles(roles []string) (isRegistrar, isRegistrant, isAdmin, isTech bool) { + for _, role := range roles { + r := strings.ToLower(strings.TrimSpace(role)) + switch { + case strings.Contains(r, "registrar"): + isRegistrar = true + case strings.Contains(r, "registrant"), strings.Contains(r, "holder"): + isRegistrant = true + case strings.Contains(r, "administrative"), strings.Contains(r, "admin"): + isAdmin = true + case strings.Contains(r, "technical"), strings.Contains(r, "tech"): + isTech = true + } + } + return +} + +func normalizeStringSlice(in []string) []string { + out := make([]string, 0, len(in)) + seen := make(map[string]struct{}, len(in)) + for _, v := range in { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +func compactStrings(in []string) []string { + out := make([]string, 0, len(in)) + for _, s := range in { + s = strings.TrimSpace(s) + if s != "" { + out = append(out, s) + } + } + return out +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} diff --git a/result_meta.go b/result_meta.go new file mode 100644 index 0000000..a585531 --- /dev/null +++ b/result_meta.go @@ -0,0 +1,80 @@ +package whois + +import "strings" + +func buildResultMeta(r Result, source, server string) ResultMeta { + if strings.TrimSpace(source) == "" { + source = "whois" + } + meta := ResultMeta{ + Source: strings.TrimSpace(source), + Server: strings.TrimSpace(server), + RawLen: len(r.rawData), + } + meta.ReasonCode, meta.Reason = classifyResultReason(r) + meta.QualityScore = scoreResultQuality(r, meta.ReasonCode) + return meta +} + +func classifyResultReason(r Result) (ErrorCode, string) { + raw := strings.TrimSpace(r.rawData) + if raw == "" { + return ErrorCodeEmptyResponse, "empty response" + } + if !r.exists { + if rawHasAccessDenied(raw) { + return ErrorCodeAccessDenied, "access denied" + } + if rawHasNotFound(raw) { + return ErrorCodeNotFound, "not found" + } + return ErrorCodeNotFound, "not found" + } + + score := scoreResultQuality(r, ErrorCodeOK) + if score < 60 { + return ErrorCodeParseWeak, "weak parse" + } + return ErrorCodeOK, "ok" +} + +func scoreResultQuality(r Result, code ErrorCode) int { + switch code { + case ErrorCodeEmptyResponse: + return 0 + case ErrorCodeAccessDenied: + return 10 + case ErrorCodeNotFound: + return 20 + } + + score := 40 + if strings.TrimSpace(r.domain) != "" { + score += 15 + } + if strings.TrimSpace(r.registrar) != "" { + score += 10 + } + if len(r.nsServers) > 0 { + score += 10 + } + if len(r.statusRaw) > 0 { + score += 5 + } + if r.hasRegisterDate { + score += 7 + } + if r.hasExpireDate { + score += 7 + } + if r.hasUpdateDate { + score += 6 + } + if score > 100 { + score = 100 + } + if score < 0 { + score = 0 + } + return score +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..7602fce --- /dev/null +++ b/types.go @@ -0,0 +1,96 @@ +package whois + +import "time" + +type QueryLevel int + +const ( + QueryAuto QueryLevel = iota + QueryRegistryOnly + QueryRegistrarOnly + QueryBoth +) + +type QueryOptions struct { + Level QueryLevel + OverrideServer string + OverrideServers []string + ReferralMaxDepth int + NegativeCacheTTL time.Duration +} + +// ResultMeta provides quality and provenance diagnostics for a lookup result. +type ResultMeta struct { + Source string + Server string + Charset string + RawLen int + ReasonCode ErrorCode + Reason string + QualityScore int +} + +type PersonalInfo struct { + FirstName string + LastName string + Name string + Org string + Fax string + FaxExt string + Addr string + City string + State string + Country string + Zip string + Phone string + PhoneExt string + Email string +} + +type Result struct { + exists bool + domain string + domainID string + rawData string + registrar string + hasRegisterDate bool + registerDate time.Time + hasUpdateDate bool + updateDate time.Time + hasExpireDate bool + expireDate time.Time + statusRaw []string + nsServers []string + nsIps []string + dnssec string + whoisSer string + ianaID string + meta ResultMeta + registerInfo PersonalInfo + adminInfo PersonalInfo + techInfo PersonalInfo +} + +func (r Result) Exists() bool { return r.exists } +func (r Result) Domain() string { return r.domain } +func (r Result) DomainID() string { return r.domainID } +func (r Result) RawData() string { return r.rawData } +func (r Result) Registrar() string { return r.registrar } +func (r Result) Registar() string { return r.registrar } // 兼容旧拼写 +func (r Result) HasRegisterDate() bool { return r.hasRegisterDate } +func (r Result) RegisterDate() time.Time { return r.registerDate } +func (r Result) HasUpdateDate() bool { return r.hasUpdateDate } +func (r Result) UpdateDate() time.Time { return r.updateDate } +func (r Result) HasExpireDate() bool { return r.hasExpireDate } +func (r Result) ExpireDate() time.Time { return r.expireDate } +func (r Result) Status() []string { return r.statusRaw } +func (r Result) NsServers() []string { return r.nsServers } +func (r Result) NsIps() []string { return r.nsIps } +func (r Result) Dnssec() string { return r.dnssec } +func (r Result) WhoisSer() string { return r.whoisSer } +func (r Result) IanaID() string { return r.ianaID } +func (r Result) Meta() ResultMeta { return r.meta } +func (r Result) TypedError() error { return reasonErrorFromMeta(r.domain, r.meta) } +func (r Result) RegisterInfo() PersonalInfo { return r.registerInfo } +func (r Result) AdminInfo() PersonalInfo { return r.adminInfo } +func (r Result) TechInfo() PersonalInfo { return r.techInfo } diff --git a/whois.go b/whois.go deleted file mode 100644 index e6bdd17..0000000 --- a/whois.go +++ /dev/null @@ -1,414 +0,0 @@ -package whois - -import ( - "errors" - "fmt" - "io" - "net" - "strconv" - "strings" - "sync" - "time" - - "golang.org/x/net/proxy" -) - -const ( - defWhoisServer = "whois.iana.org" - defWhoisPort = "43" - defTimeout = 30 * time.Second - asnPrefix = "AS" -) - -const ( - StatusAddPeriod = "AddPeriod" - StatusAutoRenewPeriod = "AutoRenewPeriod" - StatusInActive = "Inactive" - StatusOk = "Ok" - StatusPendingDelete = "PendingDelete" - StatusPendingCreate = "PendingCreate" - StatusPendingRenew = "PendingRenew" - StatusPendingTransfer = "PendingTransfer" - StatusPendingRestore = "PendingRestore" - StatusPendingUpdate = "PendingUpdate" - StatusRedemptionPeriod = "RedemptionPeriod" - StatusRenewPeriod = "RenewPeriod" - StatusServerDeleteProhibited = "ServerDeleteProhibited" - StatusServerHold = "ServerHold" - StatusServerRenewProhibited = "ServerRenewProhibited" - StatusServerTransferProhibited = "ServerTransferProhibited" - StatusServerUpdateProhibited = "ServerUpdateProhibited" - StatusTransferPeriod = "TransferPeriod" - StatusClientDeleteProhibited = "ClientDeleteProhibited" - StatusClientHold = "ClientHold" - StatusClientRenewProhibited = "ClientRenewProhibited" - StatusClientTransferProhibited = "ClientTransferProhibited" - StatusClientUpdateProhibited = "ClientUpdateProhibited" -) - -/* -var statusMap = map[string]string{ - "addPeriod": StatusAddPeriod, - "autoRenewPeriod": StatusAutoRenewPeriod, - "inactive": StatusInActive, - "ok": StatusOk, - "pendingDelete": StatusPendingDelete, - "pendingCreate": StatusPendingCreate, - "pendingRenew": StatusPendingRenew, - "pendingTransfer": StatusPendingTransfer, - "pendingRestore": StatusPendingRestore, - "pendingUpdate": StatusPendingUpdate, - "redemptionPeriod": StatusRedemptionPeriod, - "renewPeriod": StatusRenewPeriod, - "serverDeleteProhibited": StatusServerDeleteProhibited, - "serverHold": StatusServerHold, - "serverRenewProhibited": StatusServerRenewProhibited, - "serverTransferProhibited": StatusServerTransferProhibited, - "serverUpdateProhibited": StatusServerUpdateProhibited, - "transferPeriod": StatusTransferPeriod, - "clientDeleteProhibited": StatusClientDeleteProhibited, - "clientHold": StatusClientHold, - "clientRenewProhibited": StatusClientRenewProhibited, - "clientTransferProhibited": StatusClientTransferProhibited, - "clientUpdateProhibited": StatusClientUpdateProhibited, -} -*/ - -// DefaultClient is default whois client -var DefaultClient = NewClient() - -var defaultWhoisMap = map[string]string{} - -// Client is whois client -type Client struct { - extCache map[string]string - mu sync.Mutex - dialer proxy.Dialer - timeout time.Duration - elapsed time.Duration - disableReferral bool -} -type PersonalInfo struct { - FirstName string - LastName string - Name string - Org string - Fax string - FaxExt string - Addr string - City string - State string - Country string - Zip string - Phone string - PhoneExt string - Email string -} - -type Result struct { - exists bool - domain string - domainID string - rawData string - registar string - hasRegisterDate bool - registerDate time.Time - hasUpdateDate bool - updateDate time.Time - hasExpireDate bool - expireDate time.Time - statusRaw []string - nsServers []string - nsIps []string - dnssec string - whoisSer string - ianaID string - registerInfo PersonalInfo - adminInfo PersonalInfo - techInfo PersonalInfo -} - -func (r Result) NsIps() []string { - return r.nsIps -} - -func (r Result) HasRegisterDate() bool { - return r.hasRegisterDate -} - -func (r Result) HasUpdateDate() bool { - return r.hasUpdateDate -} - -func (r Result) HasExpireDate() bool { - return r.hasExpireDate -} - -func (r Result) Exists() bool { - return r.exists -} - -func (r Result) Domain() string { - return r.domain -} - -func (r Result) DomainID() string { - return r.domainID -} - -func (r Result) RawData() string { - return r.rawData -} - -func (r Result) Registar() string { - return r.registar -} - -func (r Result) RegisterDate() time.Time { - return r.registerDate -} - -func (r Result) UpdateDate() time.Time { - return r.updateDate -} - -func (r Result) ExpireDate() time.Time { - return r.expireDate -} - -func (r Result) Status() []string { - return r.statusRaw -} - -func (r Result) NsServers() []string { - return r.nsServers -} - -func (r Result) Dnssec() string { - return r.dnssec -} - -func (r Result) WhoisSer() string { - return r.whoisSer -} - -func (r Result) IanaID() string { - return r.ianaID -} - -func (r Result) RegisterInfo() PersonalInfo { - return r.registerInfo -} - -func (r Result) AdminInfo() PersonalInfo { - return r.adminInfo -} - -func (r Result) TechInfo() PersonalInfo { - return r.techInfo -} - -// NewClient returns new whois client -func NewClient() *Client { - return &Client{ - dialer: &net.Dialer{ - Timeout: defTimeout, - }, - extCache: make(map[string]string), - timeout: defTimeout, - } -} - -func (c *Client) Whois(domain string, servers ...string) (Result, error) { - if len(servers) == 0 { - if v, ok := defaultWhoisMap[getExtension(domain)]; ok { - servers = append(servers, v) - } - } - data, err := c.whois(domain, servers...) - if err != nil { - return Result{}, err - } - return parse(domain, data) -} - -func (c *Client) whois(domain string, servers ...string) (result string, err error) { - start := time.Now() - defer func() { - result = strings.TrimSpace(result) - if result != "" { - result = fmt.Sprintf("%s\n\n%% Query time: %d msec\n%% WHEN: %s\n", - result, time.Since(start).Milliseconds(), start.Format("Mon Jan 02 15:04:05 MST 2006"), - ) - } - }() - - domain = strings.Trim(strings.TrimSpace(domain), ".") - if domain == "" { - return "", errors.New("whois: domain is empty") - } - - isASN := IsASN(domain) - if isASN { - if !strings.HasPrefix(strings.ToUpper(domain), asnPrefix) { - domain = asnPrefix + domain - } - } - - if !strings.Contains(domain, ".") && !strings.Contains(domain, ":") && !isASN { - return c.rawQuery(domain, defWhoisServer, defWhoisPort) - } - - var server, port string - if len(servers) > 0 && servers[0] != "" { - server = strings.ToLower(servers[0]) - port = defWhoisPort - } else { - ext := getExtension(domain) - if _, ok := c.extCache[ext]; !ok { - result, err := c.rawQuery(ext, defWhoisServer, defWhoisPort) - if err != nil { - return "", fmt.Errorf("whois: query for whois server failed: %w", err) - } - server, port = getServer(result) - if server == "" { - return "", fmt.Errorf("%w: %s", errors.New("whois server not found"), domain) - } - c.mu.Lock() - c.extCache[ext] = fmt.Sprintf("%s:%s", server, port) - c.mu.Unlock() - } else { - v := strings.Split(c.extCache[ext], ":") - server, port = v[0], v[1] - } - } - - result, err = c.rawQuery(domain, server, port) - if err != nil { - return - } - - if c.disableReferral { - return - } - - refServer, refPort := getServer(result) - if refServer == "" || refServer == server { - return - } - - data, err := c.rawQuery(domain, refServer, refPort) - if err == nil { - result += data - } - - return -} - -// SetDialer set query net dialer -func (c *Client) SetDialer(dialer proxy.Dialer) *Client { - c.dialer = dialer - return c -} - -// SetTimeout set query timeout -func (c *Client) SetTimeout(timeout time.Duration) *Client { - c.timeout = timeout - return c -} - -// rawQuery do raw query to the server -func (c *Client) rawQuery(domain, server, port string) (string, error) { - c.elapsed = 0 - start := time.Now() - - if server == "whois.arin.net" { - if IsASN(domain) { - domain = "a + " + domain - } else { - domain = "n + " + domain - } - } - - conn, err := c.dialer.Dial("tcp", net.JoinHostPort(server, port)) - if err != nil { - return "", fmt.Errorf("whois: connect to whois server failed: %w", err) - } - - defer conn.Close() - c.elapsed = time.Since(start) - - _ = conn.SetWriteDeadline(time.Now().Add(c.timeout - c.elapsed)) - _, err = conn.Write([]byte(domain + "\r\n")) - if err != nil { - return "", fmt.Errorf("whois: send to whois server failed: %w", err) - } - - c.elapsed = time.Since(start) - - _ = conn.SetReadDeadline(time.Now().Add(c.timeout - c.elapsed)) - buffer, err := io.ReadAll(conn) - if err != nil { - return "", fmt.Errorf("whois: read from whois server failed: %w", err) - } - - c.elapsed = time.Since(start) - - return string(buffer), nil -} - -// getExtension returns extension of domain -func getExtension(domain string) string { - ext := domain - - if net.ParseIP(domain) == nil { - domains := strings.Split(domain, ".") - ext = domains[len(domains)-1] - } - - if strings.Contains(ext, "/") { - ext = strings.Split(ext, "/")[0] - } - - return ext -} - -// getServer returns server from whois data -func getServer(data string) (string, string) { - tokens := []string{ - "Registrar WHOIS Server: ", - "whois: ", - "ReferralServer: ", - "refer: ", - } - for _, token := range tokens { - start := strings.Index(data, token) - if start != -1 { - start += len(token) - end := strings.Index(data[start:], "\n") - server := strings.TrimSpace(data[start : start+end]) - server = strings.TrimPrefix(server, "http:") - server = strings.TrimPrefix(server, "https:") - server = strings.TrimPrefix(server, "whois:") - server = strings.TrimPrefix(server, "rwhois:") - server = strings.Trim(server, "/") - port := defWhoisPort - if strings.Contains(server, ":") { - v := strings.Split(server, ":") - server, port = v[0], v[1] - } - return server, port - } - } - return "", "" -} - -// IsASN returns if s is ASN -func IsASN(s string) bool { - s = strings.ToUpper(s) - - s = strings.TrimPrefix(s, asnPrefix) - _, err := strconv.Atoi(s) - - return err == nil -}