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
|
||||
|
||||
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/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"
|
||||
)
|
||||
|
||||
func requireIntegration(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("WHOIS_INTEGRATION") != "1" {
|
||||
t.Skip("set WHOIS_INTEGRATION=1 to run network whois integration tests")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoisInfo(t *testing.T) {
|
||||
requireIntegration(t)
|
||||
c := NewClient()
|
||||
domain := "who.int"
|
||||
domain := "ra.com"
|
||||
h, err := c.Whois(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -58,6 +66,7 @@ func TestWhoisInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWhois(t *testing.T) {
|
||||
requireIntegration(t)
|
||||
os.MkdirAll("./bin", 0755)
|
||||
domainSuffix := []string{"com", "net", "org", "cn", "io", "me", "cc", "top", "xyz", "vip", "club", "site", "win", "bid",
|
||||
"loan", "ek", "kim", "ren", "ltd", "link", "red", "pro", "info", "mobi", "name", "tv", "ws", "asia",
|
||||
@ -108,7 +117,8 @@ func TestWhois(t *testing.T) {
|
||||
os.WriteFile("./bin/"+domain+".txt", []byte(h.RawData()), 0644)
|
||||
if !h.exists {
|
||||
fmt.Println(idx, h.Domain(), "fail")
|
||||
t.Fatal(domain, "not exists")
|
||||
continue
|
||||
//t.Fatal(domain, "not exists")
|
||||
}
|
||||
fmt.Println(idx, h.Domain(), "ok")
|
||||
fmt.Println(h.ExpireDate(), h.RegisterDate())
|
||||
@ -127,3 +137,112 @@ func TestWhois(t *testing.T) {
|
||||
fmt.Println(h.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoisFull(t *testing.T) {
|
||||
requireIntegration(t)
|
||||
os.MkdirAll("./bin", 0755)
|
||||
|
||||
domainSuffix := []string{"com", "net", "org", "cn", "io", "me", "cc", "top", "xyz", "vip", "club", "site", "win", "bid",
|
||||
"loan", "ek", "kim", "ren", "ltd", "link", "red", "pro", "info", "mobi", "name", "tv", "ws", "asia",
|
||||
"biz", "gov", "edu", "mil", "int", "aero", "coop", "museum", "jobs", "travel", "xxx",
|
||||
"ac", "ad", "ae", "af", "ag", "ai", "al", "am", "an", "ao", "aq", "ar", "as", "at", "au", "aw", "az",
|
||||
"ba", "bb", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bm", "bn", "bo", "br", "bs", "bt", "bv", "bw", "by", "bz",
|
||||
"ca", "cc", "cd", "cf", "cg", "ch", "ci", "ck", "cl", "cm", "cn", "co", "cr", "cu", "cv", "cx", "cy", "cz",
|
||||
"de", "dj", "dk", "dm", "do", "dz",
|
||||
"ec", "ee", "eg", "eh", "er", "es", "et", "eu",
|
||||
"fi", "fj", "fk", "fm", "fo", "fr",
|
||||
"ga", "gb", "gd", "ge", "gf", "gg", "gh", "gi", "gl", "gm", "gn", "gp", "gq", "gr", "gs", "gt", "gu", "gw", "gy",
|
||||
"hk", "hm", "hn", "hr", "ht", "hu",
|
||||
"id", "ie", "il", "im", "in", "io", "iq", "ir", "is", "it",
|
||||
"je", "jm", "jo", "jp",
|
||||
"ke", "kg", "kh", "ki", "km", "kn", "kp", "kr", "kw", "ky", "kz",
|
||||
"la", "lb", "lc", "li", "lk", "lr", "ls", "lt", "lu", "lv", "ly",
|
||||
"ma", "mc", "md", "mg", "mh", "mk", "ml", "mm", "mn", "mo", "mp", "mq", "mr", "ms", "mt", "mu", "mv", "mw", "mx", "my", "mz",
|
||||
"na", "nc", "ne", "nf", "ng", "ni", "nl", "no", "np", "nr", "nu", "nz",
|
||||
"om",
|
||||
"pa", "pe", "pf", "pg", "ph", "pk", "pl", "pm", "pn", "pr", "ps", "pt", "pw", "py",
|
||||
"qa",
|
||||
"re", "ro", "ru", "rw",
|
||||
"sa", "sb", "sc", "sd", "se", "sg", "sh", "si", "sj", "sk", "sl", "sm", "sn", "so", "sr", "st", "sv", "sy", "sz",
|
||||
"tc", "td", "tf", "tg", "th", "tj", "tk", "tl", "tm", "tn", "to", "tp", "tr", "tt", "tv", "tw", "tz",
|
||||
"ua", "ug", "uk", "us", "uy", "uz",
|
||||
"va", "vc", "ve", "vg", "vi", "vn", "vu",
|
||||
"wf", "ws",
|
||||
"ye", "yt", "yu",
|
||||
"za", "zm", "zw",
|
||||
}
|
||||
|
||||
prefix := "nic."
|
||||
c := NewClient()
|
||||
|
||||
var failedTLDs []string
|
||||
|
||||
for idx, suffix := range domainSuffix {
|
||||
|
||||
domain := prefix + suffix
|
||||
if suffix == "cn" {
|
||||
domain = "cnnic.cn"
|
||||
}
|
||||
if suffix == "int" {
|
||||
domain = "who.int"
|
||||
}
|
||||
|
||||
h, _, err := c.Lookup(domain, WithLookupMode(LookupModeWHOISOnly), WithLookupProxy("socks5://127.0.0.1:29992"))
|
||||
if err != nil {
|
||||
// 网络/查询失败:记录失败,不退出
|
||||
fmt.Printf("[FAIL][NET] idx=%d tld=%s domain=%s err=%v\n", idx, suffix, domain, err)
|
||||
failedTLDs = append(failedTLDs, suffix+"(net)")
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析失败判定(你可以按需要再加规则)
|
||||
parseFailed := false
|
||||
var failReason string
|
||||
|
||||
if !h.Exists() {
|
||||
parseFailed = true
|
||||
failReason = "exists=false"
|
||||
} else if h.Domain() == "" {
|
||||
parseFailed = true
|
||||
failReason = "domain empty"
|
||||
} else {
|
||||
// 可选严格校验
|
||||
if h.HasRegisterDate() && h.RegisterDate().IsZero() {
|
||||
parseFailed = true
|
||||
failReason = "register date zero"
|
||||
}
|
||||
if h.HasExpireDate() && h.ExpireDate().IsZero() {
|
||||
parseFailed = true
|
||||
failReason = "expire date zero"
|
||||
}
|
||||
}
|
||||
|
||||
if parseFailed {
|
||||
// 仅失败时写 bin
|
||||
_ = os.WriteFile("./bin/"+domain+".txt", []byte(h.RawData()), 0644)
|
||||
fmt.Printf("[FAIL][PARSE] idx=%d tld=%s domain=%s reason=%s -> raw saved\n", idx, suffix, domain, failReason)
|
||||
failedTLDs = append(failedTLDs, suffix+"(parse)")
|
||||
continue
|
||||
}
|
||||
|
||||
// 成功提示
|
||||
fmt.Printf("[OK] idx=%d tld=%s domain=%s register=%v expire=%v ns=%d\n",
|
||||
idx, suffix, h.Domain(), h.RegisterDate(), h.ExpireDate(), len(h.NsServers()))
|
||||
}
|
||||
|
||||
// 最后汇总打印失败 tld
|
||||
fmt.Println("====================================")
|
||||
if len(failedTLDs) == 0 {
|
||||
fmt.Println("[SUMMARY] all passed")
|
||||
} else {
|
||||
fmt.Printf("[SUMMARY] failed tlds (%d): %v\n", len(failedTLDs), failedTLDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomain(t *testing.T) {
|
||||
data, err := os.ReadFile("./bin/nic.me.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println(parse("nic.me", string(data)))
|
||||
}
|
||||
|
||||
@ -5,284 +5,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func parse(domain string, result string) (Result, error) {
|
||||
ext := getExtension(domain)
|
||||
var data Result
|
||||
var err error
|
||||
switch ext {
|
||||
case "cn":
|
||||
data, err = dotCNParser(domain, result)
|
||||
case "jp":
|
||||
data, err = dotJPParser(domain, result)
|
||||
case "tw":
|
||||
data, err = dotTWParser(domain, result)
|
||||
case "name":
|
||||
data, err = dotNameParser(domain, result)
|
||||
default:
|
||||
data, err = commonParser(domain, result)
|
||||
case "edu":
|
||||
data, err = dotEduParser(domain, result)
|
||||
case "int":
|
||||
data, err = dotIntParser(domain, result)
|
||||
case "ae":
|
||||
data, err = dotAeParser(domain, result)
|
||||
case "ai":
|
||||
data, err = commonParserWithDate(domain, result, false, false, false)
|
||||
case "am":
|
||||
data, err = dotAmParser(domain, result)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func commonParser(domain, data string) (Result, error) {
|
||||
return commonParserWithDate(domain, data, true, true, true)
|
||||
}
|
||||
|
||||
func commonParserWithDate(domain, data string, hasCreate bool, hasExpire bool, hasUpdate bool) (Result, error) {
|
||||
var res = Result{
|
||||
domain: domain,
|
||||
rawData: data,
|
||||
}
|
||||
var r, a, t PersonalInfo
|
||||
|
||||
split := strings.Split(data, "\n")
|
||||
statusMap := make(map[string]struct{})
|
||||
for _, line := range split {
|
||||
line = strings.TrimSpace(line)
|
||||
if !res.exists {
|
||||
for _, token := range []string{"No Object Found", "Domain not found.", "No match for", "No match", "No Data Found", "No entries found", "No match for domain", "No matching record", "No Found", "No Object"} {
|
||||
if strings.HasPrefix(line, token) {
|
||||
res.exists = false
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "Domain Name:") {
|
||||
res.exists = true
|
||||
res.hasUpdateDate = hasUpdate
|
||||
res.hasRegisterDate = hasCreate
|
||||
res.hasExpireDate = hasExpire
|
||||
res.nsServers = []string{}
|
||||
res.domain = strings.TrimSpace(strings.TrimPrefix(line, "Domain Name:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registry Domain ID:") {
|
||||
res.domainID = strings.TrimSpace(strings.TrimPrefix(line, "Registry Domain ID:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Updated Date:") {
|
||||
tmpDate := parseDate(strings.TrimSpace(strings.TrimPrefix(line, "Updated Date:")))
|
||||
if !tmpDate.IsZero() {
|
||||
res.updateDate = tmpDate
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "Creation Date:") {
|
||||
tmpDate := parseDate(strings.TrimSpace(strings.TrimPrefix(line, "Creation Date:")))
|
||||
if !tmpDate.IsZero() {
|
||||
res.registerDate = tmpDate
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "Registry Expiry Date:") {
|
||||
tmpDate := parseDate(strings.TrimSpace(strings.TrimPrefix(line, "Registry Expiry Date:")))
|
||||
if !tmpDate.IsZero() {
|
||||
res.expireDate = tmpDate
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrar Registration Expiration Date:") {
|
||||
tmpDate := parseDate(strings.TrimSpace(strings.TrimPrefix(line, "Registrar Registration Expiration Date:")))
|
||||
if !tmpDate.IsZero() {
|
||||
res.expireDate = tmpDate
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrar:") {
|
||||
res.registar = strings.TrimSpace(strings.TrimPrefix(line, "Registrar:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Status:") {
|
||||
statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), " ")[0]] = struct{}{}
|
||||
}
|
||||
if strings.HasPrefix(line, "Domain Status:") {
|
||||
if strings.Contains(line, "No Object Found") {
|
||||
res.exists = false
|
||||
return res, nil
|
||||
}
|
||||
statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Domain Status:")), " ")[0]] = struct{}{}
|
||||
}
|
||||
if strings.HasPrefix(line, "Name Server:") {
|
||||
res.nsServers = append(res.nsServers, strings.TrimSpace(strings.TrimPrefix(line, "Name Server:")))
|
||||
}
|
||||
if strings.HasPrefix(line, "DNSSEC:") {
|
||||
res.dnssec = strings.TrimSpace(strings.TrimPrefix(line, "DNSSEC:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant Name:") {
|
||||
r.Name = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Name:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant Organization:") {
|
||||
r.Org = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Organization:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant Street:") {
|
||||
r.Addr = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Street:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant City:") {
|
||||
r.City = strings.TrimSpace(strings.TrimPrefix(line, "Registrant City:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant State/Province:") {
|
||||
r.State = strings.TrimSpace(strings.TrimPrefix(line, "Registrant State/Province:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant Postal Code:") {
|
||||
r.Zip = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Postal Code:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant Country:") {
|
||||
r.Country = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Country:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant Phone:") {
|
||||
r.Phone = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Phone:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant Phone Ext:") {
|
||||
r.PhoneExt = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Phone Ext:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant Fax:") {
|
||||
r.Fax = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Fax:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant Fax Ext:") {
|
||||
r.FaxExt = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Fax Ext:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant Email:") {
|
||||
r.Email = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Email:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Admin Name:") {
|
||||
a.Name = strings.TrimSpace(strings.TrimPrefix(line, "Admin Name:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Admin Organization:") {
|
||||
a.Org = strings.TrimSpace(strings.TrimPrefix(line, "Admin Organization:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Admin Street:") {
|
||||
a.Addr = strings.TrimSpace(strings.TrimPrefix(line, "Admin Street:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Admin City:") {
|
||||
a.City = strings.TrimSpace(strings.TrimPrefix(line, "Admin City:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Admin State/Province:") {
|
||||
a.State = strings.TrimSpace(strings.TrimPrefix(line, "Admin State/Province:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Admin Postal Code:") {
|
||||
a.Zip = strings.TrimSpace(strings.TrimPrefix(line, "Admin Postal Code:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Admin Country:") {
|
||||
a.Country = strings.TrimSpace(strings.TrimPrefix(line, "Admin Country:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Admin Phone:") {
|
||||
a.Phone = strings.TrimSpace(strings.TrimPrefix(line, "Admin Phone:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Admin Phone Ext:") {
|
||||
a.PhoneExt = strings.TrimSpace(strings.TrimPrefix(line, "Admin Phone Ext:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Admin Fax:") {
|
||||
a.Fax = strings.TrimSpace(strings.TrimPrefix(line, "Admin Fax:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Admin Fax Ext:") {
|
||||
a.FaxExt = strings.TrimSpace(strings.TrimPrefix(line, "Admin Fax Ext:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Admin Email:") {
|
||||
a.Email = strings.TrimSpace(strings.TrimPrefix(line, "Admin Email:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech Name:") {
|
||||
t.Name = strings.TrimSpace(strings.TrimPrefix(line, "Tech Name:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech Organization:") {
|
||||
t.Org = strings.TrimSpace(strings.TrimPrefix(line, "Tech Organization:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech Street:") {
|
||||
t.Addr = strings.TrimSpace(strings.TrimPrefix(line, "Tech Street:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech City:") {
|
||||
t.City = strings.TrimSpace(strings.TrimPrefix(line, "Tech City:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech State/Province:") {
|
||||
t.State = strings.TrimSpace(strings.TrimPrefix(line, "Tech State/Province:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech Postal Code:") {
|
||||
t.Zip = strings.TrimSpace(strings.TrimPrefix(line, "Tech Postal Code:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech Country:") {
|
||||
t.Country = strings.TrimSpace(strings.TrimPrefix(line, "Tech Country:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech Phone:") {
|
||||
t.Phone = strings.TrimSpace(strings.TrimPrefix(line, "Tech Phone:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech Phone Ext:") {
|
||||
t.PhoneExt = strings.TrimSpace(strings.TrimPrefix(line, "Tech Phone Ext:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech Fax:") {
|
||||
t.Fax = strings.TrimSpace(strings.TrimPrefix(line, "Tech Fax:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech Fax Ext:") {
|
||||
t.FaxExt = strings.TrimSpace(strings.TrimPrefix(line, "Tech Fax Ext:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech Email:") {
|
||||
t.Email = strings.TrimSpace(strings.TrimPrefix(line, "Tech Email:"))
|
||||
}
|
||||
}
|
||||
for status := range statusMap {
|
||||
res.statusRaw = append(res.statusRaw, status)
|
||||
}
|
||||
res.registerInfo = r
|
||||
res.adminInfo = a
|
||||
res.techInfo = t
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func dotNameParser(domain, data string) (Result, error) {
|
||||
var res = Result{
|
||||
domain: domain,
|
||||
rawData: data,
|
||||
}
|
||||
var r, a, t PersonalInfo
|
||||
|
||||
split := strings.Split(data, "\n")
|
||||
statusMap := make(map[string]struct{})
|
||||
for _, line := range split {
|
||||
line = strings.TrimSpace(line)
|
||||
if !res.exists {
|
||||
for _, token := range []string{"No match for"} {
|
||||
if strings.HasPrefix(line, token) {
|
||||
res.exists = false
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "Domain Name:") {
|
||||
res.exists = true
|
||||
res.hasUpdateDate = false
|
||||
res.hasRegisterDate = false
|
||||
res.hasExpireDate = false
|
||||
res.nsServers = []string{}
|
||||
res.domain = strings.TrimSpace(strings.TrimPrefix(line, "Domain Name:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registry Domain ID:") {
|
||||
res.domainID = strings.TrimSpace(strings.TrimPrefix(line, "Registry Domain ID:"))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "Registrar:") {
|
||||
res.registar = strings.TrimSpace(strings.TrimPrefix(line, "Registrar:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Status:") {
|
||||
statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), " ")[0]] = struct{}{}
|
||||
}
|
||||
if strings.HasPrefix(line, "Domain Status:") {
|
||||
if strings.Contains(line, "No Object Found") {
|
||||
res.exists = false
|
||||
return res, nil
|
||||
}
|
||||
statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Domain Status:")), " ")[0]] = struct{}{}
|
||||
}
|
||||
}
|
||||
for status := range statusMap {
|
||||
res.statusRaw = append(res.statusRaw, status)
|
||||
}
|
||||
res.registerInfo = r
|
||||
res.adminInfo = a
|
||||
res.techInfo = t
|
||||
return res, nil
|
||||
|
||||
}
|
||||
// ===== Specialized parsers kept from old parse.go =====
|
||||
|
||||
func dotCNParser(domain, data string) (Result, error) {
|
||||
var res = Result{
|
||||
@ -290,14 +13,17 @@ func dotCNParser(domain, data string) (Result, error) {
|
||||
rawData: data,
|
||||
}
|
||||
var r PersonalInfo
|
||||
if strings.HasPrefix("No matching record.", strings.TrimSpace(data)) {
|
||||
|
||||
trim := strings.TrimSpace(data)
|
||||
if strings.HasPrefix(trim, "No matching record.") {
|
||||
res.exists = false
|
||||
return res, nil
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(data), "the Domain Name you apply can not be registered online") {
|
||||
if strings.HasPrefix(trim, "the Domain Name you apply can not be registered online") {
|
||||
res.exists = false
|
||||
return res, nil
|
||||
}
|
||||
|
||||
res.hasUpdateDate = false
|
||||
res.hasRegisterDate = true
|
||||
res.hasExpireDate = true
|
||||
@ -320,7 +46,7 @@ func dotCNParser(domain, data string) (Result, error) {
|
||||
res.expireDate = parseCNDate(strings.TrimSpace(strings.TrimPrefix(line, "Expiration Time:")))
|
||||
}
|
||||
if strings.HasPrefix(line, "Sponsoring Registrar:") {
|
||||
res.registar = strings.TrimSpace(strings.TrimPrefix(line, "Sponsoring Registrar:"))
|
||||
res.registrar = strings.TrimSpace(strings.TrimPrefix(line, "Sponsoring Registrar:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Status:") {
|
||||
statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), " ")[0]] = struct{}{}
|
||||
@ -402,6 +128,7 @@ func dotJPParser(domain, data string) (Result, error) {
|
||||
}
|
||||
if strings.HasPrefix(line, "[住所]") {
|
||||
r.Addr = strings.TrimSpace(strings.TrimPrefix(line, "[住所]"))
|
||||
startAddress = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[Postal Address]") {
|
||||
@ -435,7 +162,7 @@ func dotTWParser(domain, data string) (Result, error) {
|
||||
var r, a, t, p PersonalInfo
|
||||
for idx, line := range split {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "No Found") || strings.HasPrefix(line, "網域名稱不合規定") {
|
||||
if strings.HasPrefix(line, "No Found") || strings.HasPrefix(line, "網域名稱不合規定") || strings.Contains(strings.ToLower(line), "reserved name") {
|
||||
res.exists = false
|
||||
return res, nil
|
||||
}
|
||||
@ -449,7 +176,7 @@ func dotTWParser(domain, data string) (Result, error) {
|
||||
}
|
||||
if strings.HasPrefix(line, "Registration Service Provider:") {
|
||||
startNs = false
|
||||
res.registar = strings.TrimSpace(strings.TrimPrefix(line, "Registration Service Provider:"))
|
||||
res.registrar = strings.TrimSpace(strings.TrimPrefix(line, "Registration Service Provider:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Record created on") {
|
||||
res.registerDate = parseCNDate(strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "Record created on"), "(UTC+8)")))
|
||||
@ -470,6 +197,18 @@ func dotTWParser(domain, data string) (Result, error) {
|
||||
p = PersonalInfo{}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Administrative Contact:") {
|
||||
currentStatus = 2
|
||||
startIdx = idx
|
||||
p = PersonalInfo{}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Technical Contact:") {
|
||||
currentStatus = 3
|
||||
startIdx = idx
|
||||
p = PersonalInfo{}
|
||||
continue
|
||||
}
|
||||
if currentStatus > 0 {
|
||||
if line == "(Redacted for privacy)" {
|
||||
switch currentStatus {
|
||||
@ -491,7 +230,7 @@ func dotTWParser(domain, data string) (Result, error) {
|
||||
p.Country = line
|
||||
}
|
||||
}
|
||||
if startNs {
|
||||
if startNs && line != "" {
|
||||
res.nsServers = append(res.nsServers, line)
|
||||
}
|
||||
}
|
||||
@ -559,7 +298,7 @@ func dotEduParser(domain, data string) (Result, error) {
|
||||
startNs = true
|
||||
continue
|
||||
}
|
||||
if startNs {
|
||||
if startNs && line != "" {
|
||||
res.nsServers = append(res.nsServers, line)
|
||||
}
|
||||
if currentStatus > 0 {
|
||||
@ -683,72 +422,6 @@ func dotIntParser(domain, data string) (Result, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func dotAeParser(domain, data string) (Result, error) {
|
||||
var res = Result{
|
||||
domain: domain,
|
||||
rawData: data,
|
||||
}
|
||||
var r, a, t PersonalInfo
|
||||
|
||||
split := strings.Split(data, "\n")
|
||||
statusMap := make(map[string]struct{})
|
||||
for _, line := range split {
|
||||
line = strings.TrimSpace(line)
|
||||
if !res.exists {
|
||||
for _, token := range []string{"No Data Found"} {
|
||||
if strings.HasPrefix(line, token) {
|
||||
res.exists = false
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "Domain Name:") {
|
||||
res.exists = true
|
||||
res.hasUpdateDate = false
|
||||
res.hasRegisterDate = false
|
||||
res.hasExpireDate = false
|
||||
res.nsServers = []string{}
|
||||
res.domain = strings.TrimSpace(strings.TrimPrefix(line, "Domain Name:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrar Name:") {
|
||||
res.registar = strings.TrimSpace(strings.TrimPrefix(line, "Registrar Name:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Status:") {
|
||||
statusMap[strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), " ")[0]] = struct{}{}
|
||||
}
|
||||
if strings.HasPrefix(line, "Name Server:") {
|
||||
res.nsServers = append(res.nsServers, strings.TrimSpace(strings.TrimPrefix(line, "Name Server:")))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "Registrant Contact Name:") {
|
||||
r.Name = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Contact Name:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant Contact Organisation::") {
|
||||
r.Org = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Contact Organisation:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant Contact Email:") {
|
||||
r.Email = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Contact Email:"))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "Tech Contact Name:") {
|
||||
t.Name = strings.TrimSpace(strings.TrimPrefix(line, "Tech Contact Name:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech Contact Organisation::") {
|
||||
t.Org = strings.TrimSpace(strings.TrimPrefix(line, "Tech Contact Organisation:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Tech Contact Email:") {
|
||||
t.Email = strings.TrimSpace(strings.TrimPrefix(line, "Tech Contact Email:"))
|
||||
}
|
||||
}
|
||||
for status := range statusMap {
|
||||
res.statusRaw = append(res.statusRaw, status)
|
||||
}
|
||||
res.registerInfo = r
|
||||
res.adminInfo = a
|
||||
res.techInfo = t
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func dotAmParser(domain, data string) (Result, error) {
|
||||
var res = Result{
|
||||
domain: domain,
|
||||
@ -784,21 +457,24 @@ func dotAmParser(domain, data string) (Result, error) {
|
||||
res.updateDate = parseYMDDate(strings.TrimSpace(strings.TrimPrefix(line, "Last modified:")))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrar:") {
|
||||
res.registar = strings.TrimSpace(strings.TrimPrefix(line, "Registrar:"))
|
||||
res.registrar = strings.TrimSpace(strings.TrimPrefix(line, "Registrar:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrant:") {
|
||||
currentStatus = 1
|
||||
p = PersonalInfo{}
|
||||
tmpSlice = []string{}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Administrative contact:") {
|
||||
currentStatus = 2
|
||||
p = PersonalInfo{}
|
||||
tmpSlice = []string{}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Technical contact:") {
|
||||
currentStatus = 3
|
||||
p = PersonalInfo{}
|
||||
tmpSlice = []string{}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "DNS servers (") {
|
||||
@ -809,11 +485,14 @@ func dotAmParser(domain, data string) (Result, error) {
|
||||
tmp := strings.Split(line, "-")
|
||||
for idx, ns := range tmp {
|
||||
ns = strings.TrimSpace(ns)
|
||||
if idx == 0 {
|
||||
res.nsServers = append(res.nsServers, ns)
|
||||
if ns == "" {
|
||||
continue
|
||||
}
|
||||
res.nsIps = append(res.nsIps, ns)
|
||||
if idx == 0 {
|
||||
res.nsServers = append(res.nsServers, ns)
|
||||
} else {
|
||||
res.nsIps = append(res.nsIps, ns)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(line) == 0 {
|
||||
@ -824,30 +503,28 @@ func dotAmParser(domain, data string) (Result, error) {
|
||||
if len(line) == 0 {
|
||||
switch currentStatus {
|
||||
case 1:
|
||||
p.Addr = strings.Join(tmpSlice, "\n")
|
||||
tmpSlice = []string{}
|
||||
p.Addr = strings.TrimSpace(strings.Join(tmpSlice, "\n"))
|
||||
r = p
|
||||
case 2:
|
||||
if len(tmpSlice) > 2 {
|
||||
p.Addr = strings.Join(tmpSlice[:len(tmpSlice)-2], "\n")
|
||||
p.Phone = tmpSlice[len(tmpSlice)-1]
|
||||
p.Email = tmpSlice[len(tmpSlice)-2]
|
||||
p.Addr = strings.TrimSpace(strings.Join(tmpSlice[:len(tmpSlice)-2], "\n"))
|
||||
p.Email = strings.TrimSpace(tmpSlice[len(tmpSlice)-2])
|
||||
p.Phone = strings.TrimSpace(tmpSlice[len(tmpSlice)-1])
|
||||
} else {
|
||||
p.Addr = strings.Join(tmpSlice, "\n")
|
||||
p.Addr = strings.TrimSpace(strings.Join(tmpSlice, "\n"))
|
||||
}
|
||||
tmpSlice = []string{}
|
||||
a = p
|
||||
case 3:
|
||||
if len(tmpSlice) > 2 {
|
||||
p.Addr = strings.Join(tmpSlice[:len(tmpSlice)-2], "\n")
|
||||
p.Phone = tmpSlice[len(tmpSlice)-1]
|
||||
p.Email = tmpSlice[len(tmpSlice)-2]
|
||||
p.Addr = strings.TrimSpace(strings.Join(tmpSlice[:len(tmpSlice)-2], "\n"))
|
||||
p.Email = strings.TrimSpace(tmpSlice[len(tmpSlice)-2])
|
||||
p.Phone = strings.TrimSpace(tmpSlice[len(tmpSlice)-1])
|
||||
} else {
|
||||
p.Addr = strings.Join(tmpSlice, "\n")
|
||||
p.Addr = strings.TrimSpace(strings.Join(tmpSlice, "\n"))
|
||||
}
|
||||
tmpSlice = []string{}
|
||||
t = p
|
||||
}
|
||||
tmpSlice = []string{}
|
||||
currentStatus = 0
|
||||
}
|
||||
}
|
||||
@ -861,43 +538,212 @@ func dotAmParser(domain, data string) (Result, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func parseDate(date string) time.Time {
|
||||
t, err := time.Parse("2006-01-02T15:04:05Z", date)
|
||||
if err == nil {
|
||||
t = t.In(time.Local)
|
||||
return t
|
||||
func dotMkParser(domain, data string) (Result, error) {
|
||||
var res = Result{
|
||||
domain: domain,
|
||||
rawData: data,
|
||||
}
|
||||
t, err = time.Parse("2006-01-02T15:04:05-0700", date)
|
||||
t = t.In(time.Local)
|
||||
return t
|
||||
var r, a, t PersonalInfo
|
||||
|
||||
split := strings.Split(data, "\n")
|
||||
cid := 0
|
||||
for _, line := range split {
|
||||
line = strings.TrimSpace(line)
|
||||
if !res.exists {
|
||||
for _, token := range []string{"%ERROR:101: no entries found"} {
|
||||
if strings.HasPrefix(line, token) {
|
||||
res.exists = false
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "domain:") {
|
||||
res.exists = true
|
||||
res.hasRegisterDate = true
|
||||
res.hasExpireDate = true
|
||||
res.nsServers = []string{}
|
||||
res.domain = strings.TrimSpace(strings.TrimPrefix(line, "domain:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "registered:") {
|
||||
res.registerDate = parseMkDate(strings.TrimSpace(strings.TrimPrefix(line, "registered:")), 2)
|
||||
}
|
||||
if strings.HasPrefix(line, "expire:") {
|
||||
res.expireDate = parseMkDate(strings.TrimSpace(strings.TrimPrefix(line, "expire:")), 2)
|
||||
}
|
||||
if strings.HasPrefix(line, "changed:") {
|
||||
if res.updateDate.IsZero() {
|
||||
res.updateDate = parseMkDate(strings.TrimSpace(strings.TrimPrefix(line, "changed:")), 2)
|
||||
res.hasUpdateDate = true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "registrar:") {
|
||||
res.registrar = strings.TrimSpace(strings.TrimPrefix(line, "registrar:"))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "contact:") {
|
||||
cid++
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "org:") {
|
||||
switch cid {
|
||||
case 1:
|
||||
r.Org = strings.TrimSpace(strings.TrimPrefix(line, "org:"))
|
||||
case 2:
|
||||
a.Org = strings.TrimSpace(strings.TrimPrefix(line, "org:"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "name:") {
|
||||
switch cid {
|
||||
case 1:
|
||||
r.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
|
||||
case 2:
|
||||
a.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "address:") {
|
||||
switch cid {
|
||||
case 1:
|
||||
r.Addr += strings.TrimSpace(strings.TrimPrefix(line, "address:")) + "\n"
|
||||
case 2:
|
||||
a.Addr += strings.TrimSpace(strings.TrimPrefix(line, "address:")) + "\n"
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "phone:") {
|
||||
switch cid {
|
||||
case 1:
|
||||
r.Phone = strings.TrimSpace(strings.TrimPrefix(line, "phone:"))
|
||||
case 2:
|
||||
a.Phone = strings.TrimSpace(strings.TrimPrefix(line, "phone:"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "e-mail:") {
|
||||
switch cid {
|
||||
case 1:
|
||||
r.Email = strings.TrimSpace(strings.TrimPrefix(line, "e-mail:"))
|
||||
case 2:
|
||||
a.Email = strings.TrimSpace(strings.TrimPrefix(line, "e-mail:"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "nserver:") {
|
||||
res.nsServers = append(res.nsServers, strings.TrimSpace(strings.TrimPrefix(line, "nserver:")))
|
||||
}
|
||||
}
|
||||
r.Addr = strings.TrimSpace(r.Addr)
|
||||
a.Addr = strings.TrimSpace(a.Addr)
|
||||
res.registerInfo = r
|
||||
res.adminInfo = a
|
||||
res.techInfo = t
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func dotArParser(domain, data string) (Result, error) {
|
||||
var res = Result{
|
||||
domain: domain,
|
||||
rawData: data,
|
||||
}
|
||||
var r, a, t PersonalInfo
|
||||
|
||||
split := strings.Split(data, "\n")
|
||||
for _, line := range split {
|
||||
line = strings.TrimSpace(line)
|
||||
if !res.exists {
|
||||
for _, token := range []string{"El dominio no se encuentra registrado en NIC Argentina"} {
|
||||
if strings.HasPrefix(line, token) {
|
||||
res.exists = false
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "domain:") {
|
||||
res.exists = true
|
||||
res.hasRegisterDate = true
|
||||
res.hasExpireDate = true
|
||||
res.nsServers = []string{}
|
||||
res.domain = strings.TrimSpace(strings.TrimPrefix(line, "domain:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "registered:") {
|
||||
res.registerDate = parseYMDHMSDate(strings.TrimSpace(strings.TrimPrefix(line, "registered:")), -3)
|
||||
}
|
||||
if strings.HasPrefix(line, "expire:") {
|
||||
res.expireDate = parseYMDHMSDate(strings.TrimSpace(strings.TrimPrefix(line, "expire:")), -3)
|
||||
}
|
||||
if strings.HasPrefix(line, "changed:") {
|
||||
if res.updateDate.IsZero() {
|
||||
res.updateDate = parseYMDHMSDate(strings.TrimSpace(strings.TrimPrefix(line, "changed:")), -3)
|
||||
res.hasUpdateDate = true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "registrar:") {
|
||||
res.registrar = strings.TrimSpace(strings.TrimPrefix(line, "registrar:"))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "name:") {
|
||||
r.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "nserver:") {
|
||||
tmp := strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "nserver:")), " ")
|
||||
for idx, ns := range tmp {
|
||||
if idx == 0 {
|
||||
res.nsServers = append(res.nsServers, ns)
|
||||
continue
|
||||
}
|
||||
clean := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(ns, "(", ""), ")", ""))
|
||||
if clean != "" {
|
||||
res.nsIps = append(res.nsIps, clean)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.Addr = strings.TrimSpace(r.Addr)
|
||||
a.Addr = strings.TrimSpace(a.Addr)
|
||||
res.registerInfo = r
|
||||
res.adminInfo = a
|
||||
res.techInfo = t
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ===== Date helpers kept for specialized parsers =====
|
||||
|
||||
func parseCNDate(date string) time.Time {
|
||||
t, _ := time.ParseInLocation("2006-01-02 15:04:05", date, time.FixedZone("CST", 8*3600))
|
||||
t = t.In(time.Local)
|
||||
return t
|
||||
return t.In(time.Local)
|
||||
}
|
||||
|
||||
func parseJPDate(date string, onlyMD bool) time.Time {
|
||||
if onlyMD {
|
||||
t, _ := time.ParseInLocation("2006/01/02", date, time.FixedZone("JST", 9*3600))
|
||||
t = t.In(time.Local)
|
||||
return t
|
||||
return t.In(time.Local)
|
||||
}
|
||||
t, _ := time.ParseInLocation("2006/01/02 15:04:05 (JST)", date, time.FixedZone("JST", 9*3600))
|
||||
t = t.In(time.Local)
|
||||
return t
|
||||
return t.In(time.Local)
|
||||
}
|
||||
|
||||
func parseEduDate(date string) time.Time {
|
||||
//example 20-Dec-1996
|
||||
t, _ := time.Parse("02-Jan-2006", date)
|
||||
t = t.In(time.Local)
|
||||
return t
|
||||
return t.In(time.Local)
|
||||
}
|
||||
|
||||
func parseYMDDate(date string) time.Time {
|
||||
t, _ := time.Parse("2006-01-02", date)
|
||||
t = t.In(time.Local)
|
||||
return t
|
||||
return t.In(time.Local)
|
||||
}
|
||||
|
||||
func parseYMDHMSDate(date string, tz int) time.Time {
|
||||
t, _ := time.ParseInLocation("2006-01-02 15:04:05", date, time.FixedZone("myzone", tz*3600))
|
||||
return t.In(time.Local)
|
||||
}
|
||||
|
||||
func parseMkDate(date string, tz int) time.Time {
|
||||
t, err := time.ParseInLocation("02.01.2006 15:04:05", date, time.FixedZone("myzone", tz*3600))
|
||||
if err == nil {
|
||||
return t.In(time.Local)
|
||||
}
|
||||
t, _ = time.Parse("02.01.2006", date)
|
||||
return t.In(time.Local)
|
||||
}
|
||||
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