update rdap parser
This commit is contained in:
parent
129514a159
commit
f3df91173c
201
LICENSE
Normal file
201
LICENSE
Normal 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
108
README.md
Normal 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
78
charset.go
Normal 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
732
client.go
Normal 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
78
client_dial_test.go
Normal 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
223
client_internal_test.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
142
client_referral_cache_test.go
Normal file
142
client_referral_cache_test.go
Normal 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
132
date_parse.go
Normal 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
45
date_parse_unit_test.go
Normal 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
91
domain_normalize.go
Normal 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
29
domain_normalize_test.go
Normal 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
124
errors.go
Normal 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
7
go.mod
@ -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
6
go.sum
@ -1,2 +1,8 @@
|
|||||||
|
b612.me/starnet v0.4.2 h1:cTcEoN5RtKYT0fwuvUOTbqyUaaEm3xuYvbFjB+Mx8zo=
|
||||||
|
b612.me/starnet v0.4.2/go.mod h1:6q+AXhYeXsIiKV+hZZmqAMn8S48QcdonURJyH66rbzI=
|
||||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
golang.org/x/net v0.28.0 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
533
lookup.go
Normal 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
82
lookup_ip_asn_test.go
Normal 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
265
lookup_ops.go
Normal 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
80
lookup_ops_proxy_test.go
Normal 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
89
lookup_strategy.go
Normal 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
459
lookup_test.go
Normal 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
154
parse_alias.go
Normal 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
430
parse_common.go
Normal 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
137
parse_common_unit_test.go
Normal 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
33
parse_dispatch.go
Normal 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
|
||||||
|
}
|
||||||
123
parse_test.go
123
parse_test.go
@ -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)))
|
||||||
|
}
|
||||||
|
|||||||
@ -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
697
rdap_bootstrap.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
rdap_bootstrap_conditional_test.go
Normal file
62
rdap_bootstrap_conditional_test.go
Normal 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
379
rdap_bootstrap_test.go
Normal 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
525
rdap_client.go
Normal 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
94
rdap_client_retry_test.go
Normal 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
151
rdap_client_test.go
Normal 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
5412
rdap_dns.json
Normal file
File diff suppressed because it is too large
Load Diff
292
rdap_result.go
Normal file
292
rdap_result.go
Normal 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
80
result_meta.go
Normal 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
96
types.go
Normal 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
414
whois.go
@ -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
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user