update rdap parser

This commit is contained in:
兔子 2026-03-19 11:53:07 +08:00
parent 129514a159
commit f3df91173c
Signed by: b612
GPG Key ID: 99DD2222B612B612
37 changed files with 12398 additions and 803 deletions

201
LICENSE Normal file
View File

@ -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.

108
README.md Normal file
View File

@ -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` 文件。

78
charset.go Normal file
View File

@ -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
}

732
client.go Normal file
View File

@ -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
}

78
client_dial_test.go Normal file
View File

@ -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()
}

223
client_internal_test.go Normal file
View File

@ -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()
}
}

View File

@ -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()
}
}

132
date_parse.go Normal file
View File

@ -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)
}

45
date_parse_unit_test.go Normal file
View File

@ -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")
}
}

91
domain_normalize.go Normal file
View File

@ -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
}

29
domain_normalize_test.go Normal file
View File

@ -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")
}
}

124
errors.go Normal file
View File

@ -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)
}

7
go.mod
View File

@ -2,4 +2,9 @@ module b612.me/sdk/whois
go 1.21.2 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
)

6
go.sum
View File

@ -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 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= 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=

533
lookup.go Normal file
View File

@ -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
}

82
lookup_ip_asn_test.go Normal file
View File

@ -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())
}
}

265
lookup_ops.go Normal file
View File

@ -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)
}
}

80
lookup_ops_proxy_test.go Normal file
View File

@ -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)
}
}

89
lookup_strategy.go Normal file
View File

@ -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)
}
}

459
lookup_test.go Normal file
View File

@ -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()
}
}

154
parse_alias.go Normal file
View File

@ -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
}

430
parse_common.go Normal file
View File

@ -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 ""
}

137
parse_common_unit_test.go Normal file
View File

@ -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())
}
}

33
parse_dispatch.go Normal file
View File

@ -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
}

View File

@ -6,9 +6,17 @@ import (
"testing" "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) { func TestWhoisInfo(t *testing.T) {
requireIntegration(t)
c := NewClient() c := NewClient()
domain := "who.int" domain := "ra.com"
h, err := c.Whois(domain) h, err := c.Whois(domain)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -58,6 +66,7 @@ func TestWhoisInfo(t *testing.T) {
} }
func TestWhois(t *testing.T) { func TestWhois(t *testing.T) {
requireIntegration(t)
os.MkdirAll("./bin", 0755) os.MkdirAll("./bin", 0755)
domainSuffix := []string{"com", "net", "org", "cn", "io", "me", "cc", "top", "xyz", "vip", "club", "site", "win", "bid", 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", "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) os.WriteFile("./bin/"+domain+".txt", []byte(h.RawData()), 0644)
if !h.exists { if !h.exists {
fmt.Println(idx, h.Domain(), "fail") 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(idx, h.Domain(), "ok")
fmt.Println(h.ExpireDate(), h.RegisterDate()) fmt.Println(h.ExpireDate(), h.RegisterDate())
@ -127,3 +137,112 @@ func TestWhois(t *testing.T) {
fmt.Println(h.Status()) 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)))
}

View File

@ -5,284 +5,7 @@ import (
"time" "time"
) )
func parse(domain string, result string) (Result, error) { // ===== Specialized parsers kept from old parse.go =====
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
}
func dotCNParser(domain, data string) (Result, error) { func dotCNParser(domain, data string) (Result, error) {
var res = Result{ var res = Result{
@ -290,14 +13,17 @@ func dotCNParser(domain, data string) (Result, error) {
rawData: data, rawData: data,
} }
var r PersonalInfo 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 res.exists = false
return res, nil 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 res.exists = false
return res, nil return res, nil
} }
res.hasUpdateDate = false res.hasUpdateDate = false
res.hasRegisterDate = true res.hasRegisterDate = true
res.hasExpireDate = 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:"))) res.expireDate = parseCNDate(strings.TrimSpace(strings.TrimPrefix(line, "Expiration Time:")))
} }
if strings.HasPrefix(line, "Sponsoring Registrar:") { 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:") { if strings.HasPrefix(line, "Status:") {
statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), " ")[0]] = struct{}{} 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, "[住所]") { if strings.HasPrefix(line, "[住所]") {
r.Addr = strings.TrimSpace(strings.TrimPrefix(line, "[住所]")) r.Addr = strings.TrimSpace(strings.TrimPrefix(line, "[住所]"))
startAddress = true
continue continue
} }
if strings.HasPrefix(line, "[Postal Address]") { if strings.HasPrefix(line, "[Postal Address]") {
@ -435,7 +162,7 @@ func dotTWParser(domain, data string) (Result, error) {
var r, a, t, p PersonalInfo var r, a, t, p PersonalInfo
for idx, line := range split { for idx, line := range split {
line = strings.TrimSpace(line) 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 res.exists = false
return res, nil return res, nil
} }
@ -449,7 +176,7 @@ func dotTWParser(domain, data string) (Result, error) {
} }
if strings.HasPrefix(line, "Registration Service Provider:") { if strings.HasPrefix(line, "Registration Service Provider:") {
startNs = false 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") { if strings.HasPrefix(line, "Record created on") {
res.registerDate = parseCNDate(strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "Record created on"), "(UTC+8)"))) 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{} p = PersonalInfo{}
continue 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 currentStatus > 0 {
if line == "(Redacted for privacy)" { if line == "(Redacted for privacy)" {
switch currentStatus { switch currentStatus {
@ -491,7 +230,7 @@ func dotTWParser(domain, data string) (Result, error) {
p.Country = line p.Country = line
} }
} }
if startNs { if startNs && line != "" {
res.nsServers = append(res.nsServers, line) res.nsServers = append(res.nsServers, line)
} }
} }
@ -559,7 +298,7 @@ func dotEduParser(domain, data string) (Result, error) {
startNs = true startNs = true
continue continue
} }
if startNs { if startNs && line != "" {
res.nsServers = append(res.nsServers, line) res.nsServers = append(res.nsServers, line)
} }
if currentStatus > 0 { if currentStatus > 0 {
@ -683,72 +422,6 @@ func dotIntParser(domain, data string) (Result, error) {
return res, nil 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) { func dotAmParser(domain, data string) (Result, error) {
var res = Result{ var res = Result{
domain: domain, domain: domain,
@ -784,21 +457,24 @@ func dotAmParser(domain, data string) (Result, error) {
res.updateDate = parseYMDDate(strings.TrimSpace(strings.TrimPrefix(line, "Last modified:"))) res.updateDate = parseYMDDate(strings.TrimSpace(strings.TrimPrefix(line, "Last modified:")))
} }
if strings.HasPrefix(line, "Registrar:") { 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:") { if strings.HasPrefix(line, "Registrant:") {
currentStatus = 1 currentStatus = 1
p = PersonalInfo{} p = PersonalInfo{}
tmpSlice = []string{}
continue continue
} }
if strings.HasPrefix(line, "Administrative contact:") { if strings.HasPrefix(line, "Administrative contact:") {
currentStatus = 2 currentStatus = 2
p = PersonalInfo{} p = PersonalInfo{}
tmpSlice = []string{}
continue continue
} }
if strings.HasPrefix(line, "Technical contact:") { if strings.HasPrefix(line, "Technical contact:") {
currentStatus = 3 currentStatus = 3
p = PersonalInfo{} p = PersonalInfo{}
tmpSlice = []string{}
continue continue
} }
if strings.HasPrefix(line, "DNS servers (") { if strings.HasPrefix(line, "DNS servers (") {
@ -809,13 +485,16 @@ func dotAmParser(domain, data string) (Result, error) {
tmp := strings.Split(line, "-") tmp := strings.Split(line, "-")
for idx, ns := range tmp { for idx, ns := range tmp {
ns = strings.TrimSpace(ns) ns = strings.TrimSpace(ns)
if idx == 0 { if ns == "" {
res.nsServers = append(res.nsServers, ns)
continue continue
} }
if idx == 0 {
res.nsServers = append(res.nsServers, ns)
} else {
res.nsIps = append(res.nsIps, ns) res.nsIps = append(res.nsIps, ns)
} }
} }
}
if len(line) == 0 { if len(line) == 0 {
startNs = false startNs = false
} }
@ -824,30 +503,28 @@ func dotAmParser(domain, data string) (Result, error) {
if len(line) == 0 { if len(line) == 0 {
switch currentStatus { switch currentStatus {
case 1: case 1:
p.Addr = strings.Join(tmpSlice, "\n") p.Addr = strings.TrimSpace(strings.Join(tmpSlice, "\n"))
tmpSlice = []string{}
r = p r = p
case 2: case 2:
if len(tmpSlice) > 2 { if len(tmpSlice) > 2 {
p.Addr = strings.Join(tmpSlice[:len(tmpSlice)-2], "\n") p.Addr = strings.TrimSpace(strings.Join(tmpSlice[:len(tmpSlice)-2], "\n"))
p.Phone = tmpSlice[len(tmpSlice)-1] p.Email = strings.TrimSpace(tmpSlice[len(tmpSlice)-2])
p.Email = tmpSlice[len(tmpSlice)-2] p.Phone = strings.TrimSpace(tmpSlice[len(tmpSlice)-1])
} else { } else {
p.Addr = strings.Join(tmpSlice, "\n") p.Addr = strings.TrimSpace(strings.Join(tmpSlice, "\n"))
} }
tmpSlice = []string{}
a = p a = p
case 3: case 3:
if len(tmpSlice) > 2 { if len(tmpSlice) > 2 {
p.Addr = strings.Join(tmpSlice[:len(tmpSlice)-2], "\n") p.Addr = strings.TrimSpace(strings.Join(tmpSlice[:len(tmpSlice)-2], "\n"))
p.Phone = tmpSlice[len(tmpSlice)-1] p.Email = strings.TrimSpace(tmpSlice[len(tmpSlice)-2])
p.Email = tmpSlice[len(tmpSlice)-2] p.Phone = strings.TrimSpace(tmpSlice[len(tmpSlice)-1])
} else { } else {
p.Addr = strings.Join(tmpSlice, "\n") p.Addr = strings.TrimSpace(strings.Join(tmpSlice, "\n"))
} }
tmpSlice = []string{}
t = p t = p
} }
tmpSlice = []string{}
currentStatus = 0 currentStatus = 0
} }
} }
@ -861,43 +538,212 @@ func dotAmParser(domain, data string) (Result, error) {
return res, nil return res, nil
} }
func parseDate(date string) time.Time { func dotMkParser(domain, data string) (Result, error) {
t, err := time.Parse("2006-01-02T15:04:05Z", date) var res = Result{
if err == nil { domain: domain,
t = t.In(time.Local) rawData: data,
return t
} }
t, err = time.Parse("2006-01-02T15:04:05-0700", date) var r, a, t PersonalInfo
t = t.In(time.Local)
return t 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 { func parseCNDate(date string) time.Time {
t, _ := time.ParseInLocation("2006-01-02 15:04:05", date, time.FixedZone("CST", 8*3600)) t, _ := time.ParseInLocation("2006-01-02 15:04:05", date, time.FixedZone("CST", 8*3600))
t = t.In(time.Local) return t.In(time.Local)
return t
} }
func parseJPDate(date string, onlyMD bool) time.Time { func parseJPDate(date string, onlyMD bool) time.Time {
if onlyMD { if onlyMD {
t, _ := time.ParseInLocation("2006/01/02", date, time.FixedZone("JST", 9*3600)) t, _ := time.ParseInLocation("2006/01/02", date, time.FixedZone("JST", 9*3600))
t = t.In(time.Local) return t.In(time.Local)
return t
} }
t, _ := time.ParseInLocation("2006/01/02 15:04:05 (JST)", date, time.FixedZone("JST", 9*3600)) t, _ := time.ParseInLocation("2006/01/02 15:04:05 (JST)", date, time.FixedZone("JST", 9*3600))
t = t.In(time.Local) return t.In(time.Local)
return t
} }
func parseEduDate(date string) time.Time { func parseEduDate(date string) time.Time {
//example 20-Dec-1996
t, _ := time.Parse("02-Jan-2006", date) t, _ := time.Parse("02-Jan-2006", date)
t = t.In(time.Local) return t.In(time.Local)
return t
} }
func parseYMDDate(date string) time.Time { func parseYMDDate(date string) time.Time {
t, _ := time.Parse("2006-01-02", date) t, _ := time.Parse("2006-01-02", date)
t = t.In(time.Local) return t.In(time.Local)
return t }
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)
} }

697
rdap_bootstrap.go Normal file
View File

@ -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)
}
}

View File

@ -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)
}
}

379
rdap_bootstrap_test.go Normal file
View File

@ -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
}

525
rdap_client.go Normal file
View File

@ -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)"
}

94
rdap_client_retry_test.go Normal file
View File

@ -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)
}
}

151
rdap_client_test.go Normal file
View File

@ -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)
}
}

5412
rdap_dns.json Normal file

File diff suppressed because it is too large Load Diff

292
rdap_result.go Normal file
View File

@ -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 ""
}

80
result_meta.go Normal file
View File

@ -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
}

96
types.go Normal file
View File

@ -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 }

414
whois.go
View File

@ -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
}