@dakaio/domainscout-mcp 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +192 -2
- package/dist/checkers/dns.js +58 -0
- package/dist/checkers/dns.js.map +1 -0
- package/dist/checkers/rdap-bootstrap.js +55 -0
- package/dist/checkers/rdap-bootstrap.js.map +1 -0
- package/dist/checkers/rdap.js +75 -0
- package/dist/checkers/rdap.js.map +1 -0
- package/dist/checkers/whois-client.js +45 -0
- package/dist/checkers/whois-client.js.map +1 -0
- package/dist/checkers/whois-reply.js +109 -0
- package/dist/checkers/whois-reply.js.map +1 -0
- package/dist/checkers/whois.js +100 -0
- package/dist/checkers/whois.js.map +1 -0
- package/dist/cli/cli-args.js +50 -0
- package/dist/cli/cli-args.js.map +1 -0
- package/dist/cli/cli.js +62 -0
- package/dist/cli/cli.js.map +1 -0
- package/dist/config/config-reader.js +104 -0
- package/dist/config/config-reader.js.map +1 -0
- package/dist/config/config.js +24 -0
- package/dist/config/config.js.map +1 -0
- package/dist/config/version.js +7 -0
- package/dist/config/version.js.map +1 -0
- package/dist/confirm/apis/cloudflare-api.js +121 -0
- package/dist/confirm/apis/cloudflare-api.js.map +1 -0
- package/dist/confirm/apis/namecom-api.js +82 -0
- package/dist/confirm/apis/namecom-api.js.map +1 -0
- package/dist/confirm/apis/porkbun-api.js +96 -0
- package/dist/confirm/apis/porkbun-api.js.map +1 -0
- package/dist/confirm/apis/spaceship-api.js +97 -0
- package/dist/confirm/apis/spaceship-api.js.map +1 -0
- package/dist/confirm/confirmer.js +101 -0
- package/dist/confirm/confirmer.js.map +1 -0
- package/dist/confirm/json-client.js +45 -0
- package/dist/confirm/json-client.js.map +1 -0
- package/dist/confirm/outcome.js +60 -0
- package/dist/confirm/outcome.js.map +1 -0
- package/dist/confirm/registrar-api.js +2 -0
- package/dist/confirm/registrar-api.js.map +1 -0
- package/dist/confirm/registrar-apis.js +20 -0
- package/dist/confirm/registrar-apis.js.map +1 -0
- package/dist/constants.js +309 -0
- package/dist/constants.js.map +1 -0
- package/dist/core/arrays.js +9 -0
- package/dist/core/arrays.js.map +1 -0
- package/dist/core/checker.js +147 -0
- package/dist/core/checker.js.map +1 -0
- package/dist/core/domain-name.js +89 -0
- package/dist/core/domain-name.js.map +1 -0
- package/dist/core/errors.js +42 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/evidence.js +50 -0
- package/dist/core/evidence.js.map +1 -0
- package/dist/core/host-queues.js +60 -0
- package/dist/core/host-queues.js.map +1 -0
- package/dist/core/http-status.js +15 -0
- package/dist/core/http-status.js.map +1 -0
- package/dist/core/http.js +42 -0
- package/dist/core/http.js.map +1 -0
- package/dist/core/network.js +64 -0
- package/dist/core/network.js.map +1 -0
- package/dist/core/promise-cache.js +28 -0
- package/dist/core/promise-cache.js.map +1 -0
- package/dist/core/retry.js +27 -0
- package/dist/core/retry.js.map +1 -0
- package/dist/core/services.js +2 -0
- package/dist/core/services.js.map +1 -0
- package/dist/core/signals.js +6 -0
- package/dist/core/signals.js.map +1 -0
- package/dist/core/status.js +29 -0
- package/dist/core/status.js.map +1 -0
- package/dist/core/target-parser.js +79 -0
- package/dist/core/target-parser.js.map +1 -0
- package/dist/core/types.js +63 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/verdicts.js +39 -0
- package/dist/core/verdicts.js.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/messages/cli.js +29 -0
- package/dist/messages/cli.js.map +1 -0
- package/dist/messages/config.js +19 -0
- package/dist/messages/config.js.map +1 -0
- package/dist/messages/errors.js +41 -0
- package/dist/messages/errors.js.map +1 -0
- package/dist/messages/index.js +10 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/notes.js +28 -0
- package/dist/messages/notes.js.map +1 -0
- package/dist/messages/prices.js +8 -0
- package/dist/messages/prices.js.map +1 -0
- package/dist/messages/report.js +80 -0
- package/dist/messages/report.js.map +1 -0
- package/dist/messages/tool.js +50 -0
- package/dist/messages/tool.js.map +1 -0
- package/dist/pricing/cloudflare-prices.js +32 -0
- package/dist/pricing/cloudflare-prices.js.map +1 -0
- package/dist/pricing/porkbun-prices.js +33 -0
- package/dist/pricing/porkbun-prices.js.map +1 -0
- package/dist/pricing/price-book.js +71 -0
- package/dist/pricing/price-book.js.map +1 -0
- package/dist/pricing/price-source.js +2 -0
- package/dist/pricing/price-source.js.map +1 -0
- package/dist/pricing/price.js +56 -0
- package/dist/pricing/price.js.map +1 -0
- package/dist/registrars/affiliates.js +28 -0
- package/dist/registrars/affiliates.js.map +1 -0
- package/dist/registrars/buy-links.js +47 -0
- package/dist/registrars/buy-links.js.map +1 -0
- package/dist/registrars/registrars.js +68 -0
- package/dist/registrars/registrars.js.map +1 -0
- package/dist/report/money.js +18 -0
- package/dist/report/money.js.map +1 -0
- package/dist/report/summary.js +24 -0
- package/dist/report/summary.js.map +1 -0
- package/dist/report/text-report.js +185 -0
- package/dist/report/text-report.js.map +1 -0
- package/dist/scout/domain-scout.js +136 -0
- package/dist/scout/domain-scout.js.map +1 -0
- package/dist/server/schemas.js +83 -0
- package/dist/server/schemas.js.map +1 -0
- package/dist/server/server.js +72 -0
- package/dist/server/server.js.map +1 -0
- package/package.json +67 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dakaio.com
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,193 @@
|
|
|
1
|
-
#
|
|
1
|
+
# DomainScout (@dakaio/domainscout-mcp)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Every good .com is taken. Find the ones that aren't.
|
|
4
|
+
|
|
5
|
+
DomainScout is an MCP server for bulk domain search. Your AI assistant brainstorms hundreds of
|
|
6
|
+
names, and DomainScout checks all of them in one call: which are free, what they cost and where
|
|
7
|
+
to buy them. It runs on your own machine. You need no account and no server of ours, and API keys
|
|
8
|
+
are optional.
|
|
9
|
+
|
|
10
|
+
Website: [domainscout.dakaio.com](https://domainscout.dakaio.com)
|
|
11
|
+
|
|
12
|
+
You ask your assistant *"I'm naming a note-taking app. Brainstorm 500 names and tell me which .com
|
|
13
|
+
domains are free."* The assistant calls `check_domains` once with all 500 names and gets back:
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
Checked 500 domain(s) in 21.8s: 27 available, 473 taken.
|
|
17
|
+
|
|
18
|
+
AVAILABLE (cheapest known offer for each):
|
|
19
|
+
- quillloom.com: $10.46/yr at Cloudflare https://www.cloudflare.com/domains/search/?q=quillloom.com
|
|
20
|
+
- scribeloom.com: $10.46/yr at Cloudflare https://www.cloudflare.com/domains/search/?q=scribeloom.com
|
|
21
|
+
- scribebloom.com: $10.46/yr at Cloudflare https://www.cloudflare.com/domains/search/?q=scribebloom.com
|
|
22
|
+
...
|
|
23
|
+
TAKEN: quillwise.com, quillnest.com, quillflow.com, ...
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Ask about a few favourites afterwards and each one gets every registrar's link and price. More
|
|
27
|
+
real output, including the command line and JSON: [examples](examples/README.md).
|
|
28
|
+
|
|
29
|
+
## Built for long lists
|
|
30
|
+
|
|
31
|
+
- **One call for the whole brainstorm.** Up to 500 domains per call by default
|
|
32
|
+
(`DOMAINSCOUT_MAX_DOMAINS`); 500 .com names take about 20 seconds.
|
|
33
|
+
- **Readable answers.** With more than five free domains, each one gets a single line with its
|
|
34
|
+
cheapest offer, so the answer stays short; every registrar's link is still in the structured
|
|
35
|
+
result.
|
|
36
|
+
- **Polite to the registries.** Taken names usually stop at DNS, so the registries only see the
|
|
37
|
+
names that might be free. Every server gets its own rate limit.
|
|
38
|
+
|
|
39
|
+
## How it checks
|
|
40
|
+
|
|
41
|
+
For each domain, in parallel and within polite rate limits:
|
|
42
|
+
|
|
43
|
+
1. **DNS.** If the TLD zone delegates the name, it is taken. This step is free and saves the
|
|
44
|
+
registries' rate limits.
|
|
45
|
+
2. **The registry over RDAP**, the authoritative source. If the domain is found, it is taken;
|
|
46
|
+
the result includes expiry, and a note when the domain is being deleted and may become
|
|
47
|
+
available soon. A 404 means it is likely available.
|
|
48
|
+
3. **WHOIS**, only for TLDs without RDAP (for example `.io`, `.ru`, `.de`) or when RDAP fails.
|
|
49
|
+
4. **Your registrar API, optional.** If you set an API key, the registrar confirms that each
|
|
50
|
+
free-looking name can really be bought, flags premium names and gives the exact price.
|
|
51
|
+
|
|
52
|
+
Having no DNS never proves a domain is free: a bought but unused domain has no DNS either.
|
|
53
|
+
Without a registrar API key, "not in the registry" is reported as `likely_available`. Premium
|
|
54
|
+
and registry-reserved names look the same there, so the registrar's page has the final price.
|
|
55
|
+
|
|
56
|
+
| Status | Meaning |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `available` | A registrar API confirmed it can be registered; `premium` says whether it costs extra |
|
|
59
|
+
| `likely_available` | Not in the registry or DNS, not confirmed by a registrar |
|
|
60
|
+
| `taken` | Registered |
|
|
61
|
+
| `reserved` | Blocked by the registry, or the registrar cannot sell it |
|
|
62
|
+
| `unknown` | Could not verify right now (timeout, rate limit); try again |
|
|
63
|
+
|
|
64
|
+
Free domains come with buy links for Cloudflare, Porkbun, Namecheap, Spaceship, GoDaddy,
|
|
65
|
+
Name.com, Dynadot, NameSilo and Hover. Links are sorted by the cost of the first term plus one
|
|
66
|
+
renewal year, using public price lists (Cloudflare, Porkbun) or the exact price from your
|
|
67
|
+
registrar API.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
Node 20 or newer.
|
|
72
|
+
|
|
73
|
+
**Claude Code**
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
claude mcp add domainscout -- npx -y @dakaio/domainscout-mcp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Claude Desktop / Cursor**: add to the MCP config:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"mcpServers": {
|
|
84
|
+
"domainscout": {
|
|
85
|
+
"command": "npx",
|
|
86
|
+
"args": ["-y", "@dakaio/domainscout-mcp"],
|
|
87
|
+
"env": { "DOMAINSCOUT_TLDS": "com,net,ai,io" }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
On Windows, if `npx` is not found, use `"command": "cmd"` with
|
|
94
|
+
`"args": ["/c", "npx", "-y", "@dakaio/domainscout-mcp"]`.
|
|
95
|
+
|
|
96
|
+
Then just ask: *"Brainstorm 200 names for my coffee subscription app and tell me which .com
|
|
97
|
+
domains are free."*
|
|
98
|
+
|
|
99
|
+
**From source:** `npm install && npm run build`, then run `node /path/to/domainscout-mcp/dist/index.js`
|
|
100
|
+
in place of `npx -y @dakaio/domainscout-mcp`.
|
|
101
|
+
|
|
102
|
+
## Settings
|
|
103
|
+
|
|
104
|
+
Set these as environment variables in the MCP config. All are optional.
|
|
105
|
+
|
|
106
|
+
| Variable | Default | What it does |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `DOMAINSCOUT_TLDS` | `com,net,ai` | TLDs tried for names given without one |
|
|
109
|
+
| `DOMAINSCOUT_REGISTRARS` | all | Registrars to show buy links for, e.g. `cloudflare,porkbun,namecheap` |
|
|
110
|
+
| `DOMAINSCOUT_MAX_CONCURRENCY` | `10` | Network requests in flight at once |
|
|
111
|
+
| `DOMAINSCOUT_PER_HOST_CONCURRENCY` | `2` | Requests in flight to one RDAP server |
|
|
112
|
+
| `DOMAINSCOUT_TIMEOUT_MS` | `10000` | Timeout of one request |
|
|
113
|
+
| `DOMAINSCOUT_MAX_DOMAINS` | `500` | Domains per call, after adding TLDs |
|
|
114
|
+
| `DOMAINSCOUT_CONFIRM_MAX` | `50` | Free domains per call confirmed through your registrar API |
|
|
115
|
+
| `DOMAINSCOUT_DNS` | `on` | DNS pre-check |
|
|
116
|
+
| `DOMAINSCOUT_DNS_SERVERS` | system | DNS servers to use, comma-separated |
|
|
117
|
+
| `DOMAINSCOUT_PRICES` | `on` | Public price lists |
|
|
118
|
+
| `DOMAINSCOUT_AFFILIATE` | `on` | Affiliate buy links (`off` gives plain links) |
|
|
119
|
+
|
|
120
|
+
Each RDAP server gets at most 5 requests per second. Each WHOIS server gets one query at a
|
|
121
|
+
time, at most one per second. A server that answers "too many requests" is left alone for a while.
|
|
122
|
+
|
|
123
|
+
## Registrar API keys (optional)
|
|
124
|
+
|
|
125
|
+
With a key, free-looking names are confirmed: you get `available` or `reserved` instead of
|
|
126
|
+
`likely_available`, premium names are flagged, and the registrar's exact price is shown. One
|
|
127
|
+
key is enough. With several, the APIs are asked in this order, each name only until one answers.
|
|
128
|
+
|
|
129
|
+
| Registrar | Variables | Where to get it |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| Name.com | `DOMAINSCOUT_NAMECOM_USERNAME`, `DOMAINSCOUT_NAMECOM_TOKEN` | Account Settings → Security → API Tokens |
|
|
132
|
+
| Cloudflare | `DOMAINSCOUT_CLOUDFLARE_ACCOUNT_ID`, `DOMAINSCOUT_CLOUDFLARE_API_TOKEN` | API token with Registrar permission. The Registrar API is in beta |
|
|
133
|
+
| Spaceship | `DOMAINSCOUT_SPACESHIP_API_KEY`, `DOMAINSCOUT_SPACESHIP_API_SECRET` | API Manager; the key needs the `domains:read` scope |
|
|
134
|
+
| Porkbun | `DOMAINSCOUT_PORKBUN_API_KEY`, `DOMAINSCOUT_PORKBUN_SECRET_KEY` | porkbun.com/account/api |
|
|
135
|
+
|
|
136
|
+
Porkbun throttles an account to 50 names per 5 minutes for a day once it has checked about
|
|
137
|
+
1,800 names in 3 hours while registering few of them. `DOMAINSCOUT_CONFIRM_MAX` keeps every
|
|
138
|
+
call well below that. A tool call can also pass `confirm: false` to skip confirmation for a
|
|
139
|
+
large list.
|
|
140
|
+
|
|
141
|
+
Each key is sent only to its own registrar's API, and it never appears in output, logs or error
|
|
142
|
+
messages. If a registrar rejects a key, the result says so in its notes, and the check still
|
|
143
|
+
works without that registrar.
|
|
144
|
+
|
|
145
|
+
## Privacy
|
|
146
|
+
|
|
147
|
+
Your queries go straight from your machine to:
|
|
148
|
+
|
|
149
|
+
- your DNS resolver,
|
|
150
|
+
- the registries (RDAP/WHOIS),
|
|
151
|
+
- two public price lists: the Porkbun price API, and cfdomainpricing.com, a community mirror
|
|
152
|
+
of Cloudflare's prices,
|
|
153
|
+
- the APIs of registrars whose keys you set.
|
|
154
|
+
|
|
155
|
+
`DOMAINSCOUT_PRICES=off` turns the price lists off. Nothing goes to a server of this project,
|
|
156
|
+
because there is none.
|
|
157
|
+
|
|
158
|
+
## Affiliate links
|
|
159
|
+
|
|
160
|
+
Some buy links may be affiliate links. When they are, the result says so, and buying through
|
|
161
|
+
them earns the project a commission at no extra cost to you. Links are always ordered by price
|
|
162
|
+
and never by commission. `DOMAINSCOUT_AFFILIATE=off` turns them off.
|
|
163
|
+
|
|
164
|
+
## Command line
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
npx @dakaio/domainscout-mcp check acme getacme.io --tlds com,dev
|
|
168
|
+
npx @dakaio/domainscout-mcp check acme --json --no-confirm
|
|
169
|
+
npx @dakaio/domainscout-mcp check $(cat names.txt) --tlds com
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Development
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
npm run verify # typecheck + lint (Biome) + tests with coverage thresholds
|
|
176
|
+
npm run build
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
| Path | What it is |
|
|
180
|
+
|---|---|
|
|
181
|
+
| `src/core/` | The checking funnel, input parsing, shared types, network limits and retries |
|
|
182
|
+
| `src/checkers/` | DNS, RDAP and WHOIS lookups |
|
|
183
|
+
| `src/confirm/` | Confirmation through registrar APIs, one class per registrar |
|
|
184
|
+
| `src/pricing/`, `src/registrars/` | Public price lists, buy links and affiliate templates |
|
|
185
|
+
| `src/report/`, `src/messages/` | The text report and every text shown to people or to the model |
|
|
186
|
+
| `src/scout/`, `src/server/`, `src/cli/` | The check as a whole, the MCP server and the command line |
|
|
187
|
+
| `test/` | Vitest tests; the network is faked, except for a local WHOIS server |
|
|
188
|
+
| `examples/` | Real output of the tool |
|
|
189
|
+
| `site/` | The landing page at domainscout.dakaio.com: static HTML, deployed as is |
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { NODATA, NOTFOUND } from 'node:dns';
|
|
3
|
+
import { Resolver } from 'node:dns/promises';
|
|
4
|
+
import { DNS_MAX_TIMEOUT_MS, DNS_TRIES, WILDCARD_PROBE_BYTES, WILDCARD_PROBE_ENCODING, } from '../constants.js';
|
|
5
|
+
import { joinLabels } from '../core/domain-name.js';
|
|
6
|
+
import { PromiseCache } from '../core/promise-cache.js';
|
|
7
|
+
import { DnsState } from '../core/types.js';
|
|
8
|
+
/** DNS error codes that are answers, not failures. */
|
|
9
|
+
const ANSWER_CODES = new Map()
|
|
10
|
+
// The name does not exist in the zone.
|
|
11
|
+
.set(NOTFOUND, DnsState.NxDomain)
|
|
12
|
+
// The name exists but has no NS records.
|
|
13
|
+
.set(NODATA, DnsState.NoData);
|
|
14
|
+
/** NS lookups through the system resolver, or the DNS servers the user configured. */
|
|
15
|
+
export class DnsChecker {
|
|
16
|
+
resolver;
|
|
17
|
+
/** Whether each TLD is a wildcard TLD; asked once per TLD. */
|
|
18
|
+
wildcards = new PromiseCache();
|
|
19
|
+
constructor(servers, timeoutMs) {
|
|
20
|
+
this.resolver = new Resolver({
|
|
21
|
+
timeout: Math.min(timeoutMs, DNS_MAX_TIMEOUT_MS),
|
|
22
|
+
tries: DNS_TRIES,
|
|
23
|
+
});
|
|
24
|
+
if (servers.length > 0)
|
|
25
|
+
this.resolver.setServers(servers);
|
|
26
|
+
}
|
|
27
|
+
/** Asks for the domain's nameservers: only a registered domain can be delegated in the TLD zone. */
|
|
28
|
+
async lookupNs(domain) {
|
|
29
|
+
const started = Date.now();
|
|
30
|
+
try {
|
|
31
|
+
const nameservers = await this.resolver.resolveNs(domain);
|
|
32
|
+
const state = nameservers.length > 0 ? DnsState.Delegated : DnsState.NoData;
|
|
33
|
+
return { state, nameservers, ms: Date.now() - started };
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return this.failedLookup(error, Date.now() - started);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** A few TLDs answer for any name (wildcard); for them a DNS answer proves nothing. */
|
|
40
|
+
hasWildcard(tld) {
|
|
41
|
+
return this.wildcards.get(tld, () => this.probeWildcard(tld));
|
|
42
|
+
}
|
|
43
|
+
/** Looks up a random name that nobody has registered: a delegation means the TLD answers for anything. */
|
|
44
|
+
async probeWildcard(tld) {
|
|
45
|
+
const probe = randomBytes(WILDCARD_PROBE_BYTES).toString(WILDCARD_PROBE_ENCODING);
|
|
46
|
+
const lookup = await this.lookupNs(joinLabels(probe, tld));
|
|
47
|
+
return lookup.state === DnsState.Delegated;
|
|
48
|
+
}
|
|
49
|
+
/** "Does not exist" and "no NS records" are answers; anything else is a failure. */
|
|
50
|
+
failedLookup(error, ms) {
|
|
51
|
+
const code = error.code;
|
|
52
|
+
const state = code === undefined ? undefined : ANSWER_CODES.get(code);
|
|
53
|
+
if (state)
|
|
54
|
+
return { state, ms };
|
|
55
|
+
return { state: DnsState.Error, error: code ?? String(error), ms };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=dns.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dns.js","sourceRoot":"","sources":["../../src/checkers/dns.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { HTTPS_PROTOCOL, LABEL_SEPARATOR, RDAP_BOOTSTRAP_TTL_MS, RDAP_BOOTSTRAP_URL, Retry, } from '../constants.js';
|
|
2
|
+
import { ensureOk } from '../core/http.js';
|
|
3
|
+
import { PromiseCache } from '../core/promise-cache.js';
|
|
4
|
+
import { withRetry } from '../core/retry.js';
|
|
5
|
+
/** Base URLs must end with a slash, so relative paths resolve below them. */
|
|
6
|
+
const PATH_SEPARATOR = '/';
|
|
7
|
+
/** IANA's list of RDAP servers (RFC 9224), loaded on first use and reused for a day. */
|
|
8
|
+
export class RdapBootstrap {
|
|
9
|
+
network;
|
|
10
|
+
/** The loaded list. Shared by all lookups, so it never uses a caller's cancel signal. */
|
|
11
|
+
list = new PromiseCache(RDAP_BOOTSTRAP_TTL_MS);
|
|
12
|
+
constructor(network) {
|
|
13
|
+
this.network = network;
|
|
14
|
+
}
|
|
15
|
+
/** The RDAP base URL for a TLD, longest match first: "co.uk" tries "co.uk", then "uk". Undefined: no RDAP. */
|
|
16
|
+
async serverFor(tld) {
|
|
17
|
+
const servers = await this.list.get(RDAP_BOOTSTRAP_URL, () => this.load());
|
|
18
|
+
const match = this.suffixes(tld).find((suffix) => servers.has(suffix));
|
|
19
|
+
return match === undefined ? undefined : servers.get(match);
|
|
20
|
+
}
|
|
21
|
+
/** "co.uk" -> ["co.uk", "uk"] */
|
|
22
|
+
suffixes(tld) {
|
|
23
|
+
const labels = tld.split(LABEL_SEPARATOR);
|
|
24
|
+
return labels.map((_label, start) => labels.slice(start).join(LABEL_SEPARATOR));
|
|
25
|
+
}
|
|
26
|
+
load() {
|
|
27
|
+
return withRetry(() => this.network.run(() => this.fetchList()), Retry.bootstrap);
|
|
28
|
+
}
|
|
29
|
+
async fetchList() {
|
|
30
|
+
const url = new URL(RDAP_BOOTSTRAP_URL);
|
|
31
|
+
const response = await this.network.request(url);
|
|
32
|
+
await ensureOk(response, url);
|
|
33
|
+
const file = (await response.json());
|
|
34
|
+
return this.toServerList(file);
|
|
35
|
+
}
|
|
36
|
+
toServerList(file) {
|
|
37
|
+
const servers = new Map();
|
|
38
|
+
for (const [tlds, urls] of file.services ?? []) {
|
|
39
|
+
const base = this.preferredUrl(urls);
|
|
40
|
+
if (!base)
|
|
41
|
+
continue;
|
|
42
|
+
for (const tld of tlds)
|
|
43
|
+
servers.set(tld.toLowerCase(), base);
|
|
44
|
+
}
|
|
45
|
+
return servers;
|
|
46
|
+
}
|
|
47
|
+
/** The first https URL (or the first URL), ending with a slash. */
|
|
48
|
+
preferredUrl(urls) {
|
|
49
|
+
const url = urls.find((candidate) => new URL(candidate).protocol === HTTPS_PROTOCOL) ?? urls[0];
|
|
50
|
+
if (!url)
|
|
51
|
+
return undefined;
|
|
52
|
+
return url.endsWith(PATH_SEPARATOR) ? url : `${url}${PATH_SEPARATOR}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=rdap-bootstrap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rdap-bootstrap.js","sourceRoot":"","sources":["../../src/checkers/rdap-bootstrap.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,eAAe,EACf,qBAAqB,EACrB,kBAAkB,EAClB,KAAK,GACN,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAE3C,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAU7C,6EAA6E;AAC7E,MAAM,cAAc,GAAG,GAAG,CAAC;AAE3B,wFAAwF;AACxF,MAAM,OAAO,aAAa;IAIK,OAAO;IAHpC,yFAAyF;IACxE,IAAI,GAAG,IAAI,YAAY,CAAqB,qBAAqB,CAAC,CAAC;IAEpF,YAA6B,OAAgB;uBAAhB,OAAO;IAAY,CAAC;IAEjD,8GAA8G;IAC9G,KAAK,CAAC,SAAS,CAAC,GAAW;QACzB,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,kBAAkB,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;QAC3E,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;QACvE,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAC9D,CAAC;IAED,iCAAiC;IACzB,QAAQ,CAAC,GAAW;QAC1B,MAAM,MAAM,GAAG,GAAG,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;QAC1C,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,CAAC;IAClF,CAAC;IAEO,IAAI;QACV,OAAO,SAAS,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IACpF,CAAC;IAEO,KAAK,CAAC,SAAS;QACrB,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,kBAAkB,CAAC,CAAC;QACxC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACjD,MAAM,QAAQ,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;QAC9B,MAAM,IAAI,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAkB,CAAC;QACtD,OAAO,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;IACjC,CAAC;IAEO,YAAY,CAAC,IAAmB;QACtC,MAAM,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAC;QAC1C,KAAK,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,CAAC,QAAQ,IAAI,EAAE,EAAE,CAAC;YAC/C,MAAM,IAAI,GAAG,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;YACrC,IAAI,CAAC,IAAI;gBAAE,SAAS;YACpB,KAAK,MAAM,GAAG,IAAI,IAAI;gBAAE,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,EAAE,IAAI,CAAC,CAAC;QAC/D,CAAC;QACD,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,mEAAmE;IAC3D,YAAY,CAAC,IAAuB;QAC1C,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,SAAS,CAAC,CAAC,QAAQ,KAAK,cAAc,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC;QAChG,IAAI,CAAC,GAAG;YAAE,OAAO,SAAS,CAAC;QAC3B,OAAO,GAAG,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,cAAc,EAAE,CAAC;IACxE,CAAC;CACF"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Header, HttpStatus, RDAP_ACCEPT, RDAP_DOMAIN_PATH, RdapEvent, Retry, } from '../constants.js';
|
|
2
|
+
import { errorMessage } from '../core/errors.js';
|
|
3
|
+
import { discardBody, retryableError } from '../core/http.js';
|
|
4
|
+
import { isRetryableStatus } from '../core/http-status.js';
|
|
5
|
+
import { withRetry } from '../core/retry.js';
|
|
6
|
+
import { LookupState } from '../core/types.js';
|
|
7
|
+
import { becauseOf, ErrorText, httpStatusText } from '../messages/index.js';
|
|
8
|
+
import { RdapBootstrap } from './rdap-bootstrap.js';
|
|
9
|
+
/** Headers of every RDAP request. */
|
|
10
|
+
const RDAP_HEADERS = { [Header.Accept]: RDAP_ACCEPT };
|
|
11
|
+
/** The registry's own answer about a domain, over RDAP (RFC 9082/9083). */
|
|
12
|
+
export class RdapChecker {
|
|
13
|
+
network;
|
|
14
|
+
bootstrap;
|
|
15
|
+
constructor(network, bootstrap = new RdapBootstrap(network)) {
|
|
16
|
+
this.network = network;
|
|
17
|
+
this.bootstrap = bootstrap;
|
|
18
|
+
}
|
|
19
|
+
async lookup(domain, tld, signal) {
|
|
20
|
+
let base;
|
|
21
|
+
try {
|
|
22
|
+
base = await this.bootstrap.serverFor(tld);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const message = becauseOf(ErrorText.RdapListUnavailable, errorMessage(error));
|
|
26
|
+
return { state: LookupState.Error, error: message, ms: 0 };
|
|
27
|
+
}
|
|
28
|
+
if (!base)
|
|
29
|
+
return { state: LookupState.Unsupported };
|
|
30
|
+
return this.ask(new URL(`${RDAP_DOMAIN_PATH}${domain}`, base), signal);
|
|
31
|
+
}
|
|
32
|
+
/** Asks the server within its limits; rate limits, server errors and timeouts are retried. */
|
|
33
|
+
async ask(url, signal) {
|
|
34
|
+
const started = Date.now();
|
|
35
|
+
const send = () => this.fetchOnce(url, started, signal);
|
|
36
|
+
try {
|
|
37
|
+
const queued = () => this.network.viaQueues(this.network.rdapHosts, url.host, send, signal);
|
|
38
|
+
return await withRetry(queued, { ...Retry.rdap, signal });
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const ms = Date.now() - started;
|
|
42
|
+
return { state: LookupState.Error, server: url.host, error: errorMessage(error), ms };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** 404 means not registered, 200 registered; 429 and 5xx throw to be retried. */
|
|
46
|
+
async fetchOnce(url, started, signal) {
|
|
47
|
+
const response = await this.network.request(url, { headers: RDAP_HEADERS }, signal);
|
|
48
|
+
const server = url.host;
|
|
49
|
+
const ms = Date.now() - started;
|
|
50
|
+
if (!response.ok)
|
|
51
|
+
await discardBody(response);
|
|
52
|
+
if (response.status === HttpStatus.NotFound)
|
|
53
|
+
return { state: LookupState.NotFound, server, ms };
|
|
54
|
+
if (isRetryableStatus(response.status))
|
|
55
|
+
throw retryableError(response, url);
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
return { state: LookupState.Error, server, error: httpStatusText(response.status), ms };
|
|
58
|
+
}
|
|
59
|
+
return this.registered((await response.json()), server, ms);
|
|
60
|
+
}
|
|
61
|
+
registered(data, server, ms) {
|
|
62
|
+
return {
|
|
63
|
+
state: LookupState.Registered,
|
|
64
|
+
server,
|
|
65
|
+
statuses: data.status ?? [],
|
|
66
|
+
created: this.eventDate(data, RdapEvent.Registration),
|
|
67
|
+
expires: this.eventDate(data, RdapEvent.Expiration),
|
|
68
|
+
ms,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
eventDate(data, action) {
|
|
72
|
+
return data.events?.find((event) => event.eventAction === action)?.eventDate;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=rdap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rdap.js","sourceRoot":"","sources":["../../src/checkers/rdap.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createConnection } from 'node:net';
|
|
2
|
+
import { WHOIS_ENCODING, WHOIS_LINE_END, WHOIS_MAX_REPLY_BYTES, WHOIS_PORT } from '../constants.js';
|
|
3
|
+
import { errorMessage, RetryableError } from '../core/errors.js';
|
|
4
|
+
import { withTimeout } from '../core/signals.js';
|
|
5
|
+
import { atHost, ErrorText } from '../messages/index.js';
|
|
6
|
+
/**
|
|
7
|
+
* Sends one query to a WHOIS server over TCP (RFC 3912) and returns the whole reply.
|
|
8
|
+
* Timeouts and network errors become RetryableError; a cancel by the caller does not.
|
|
9
|
+
*/
|
|
10
|
+
export async function whoisQuery(server, query, timeoutMs, signal, port = WHOIS_PORT) {
|
|
11
|
+
if (signal?.aborted)
|
|
12
|
+
throw new Error(ErrorText.Cancelled);
|
|
13
|
+
const deadline = withTimeout(timeoutMs, signal);
|
|
14
|
+
const socket = createConnection({ host: server, port, signal: deadline });
|
|
15
|
+
socket.write(`${query}${WHOIS_LINE_END}`);
|
|
16
|
+
try {
|
|
17
|
+
return await readReply(socket);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
throw queryError(error, server, deadline, signal);
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
socket.destroy();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Reads until the server closes the connection, keeping at most WHOIS_MAX_REPLY_BYTES. */
|
|
27
|
+
async function readReply(socket) {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
let size = 0;
|
|
30
|
+
for await (const chunk of socket) {
|
|
31
|
+
size += chunk.length;
|
|
32
|
+
if (size > WHOIS_MAX_REPLY_BYTES)
|
|
33
|
+
break;
|
|
34
|
+
chunks.push(chunk);
|
|
35
|
+
}
|
|
36
|
+
return Buffer.concat(chunks).toString(WHOIS_ENCODING);
|
|
37
|
+
}
|
|
38
|
+
/** A cancel stays a plain error; a timeout or a network error is worth another try. */
|
|
39
|
+
function queryError(error, server, deadline, signal) {
|
|
40
|
+
if (signal?.aborted)
|
|
41
|
+
return new Error(ErrorText.Cancelled);
|
|
42
|
+
const reason = deadline.aborted ? ErrorText.TimedOut : errorMessage(error);
|
|
43
|
+
return new RetryableError(atHost(server, reason));
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=whois-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"whois-client.js","sourceRoot":"","sources":["../../src/checkers/whois-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAe,MAAM,UAAU,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,qBAAqB,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACpG,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACjE,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAUzD;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,MAAc,EACd,KAAa,EACb,SAAiB,EACjB,MAAoB,EACpB,IAAI,GAAW,UAAU;IAEzB,IAAI,MAAM,EAAE,OAAO;QAAE,MAAM,IAAI,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAC1D,MAAM,QAAQ,GAAG,WAAW,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;IAChD,MAAM,MAAM,GAAG,gBAAgB,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAC;IAC1E,MAAM,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,cAAc,EAAE,CAAC,CAAC;IAC1C,IAAI,CAAC;QACH,OAAO,MAAM,SAAS,CAAC,MAAM,CAAC,CAAC;IACjC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;IACpD,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,OAAO,EAAE,CAAC;IACnB,CAAC;AACH,CAAC;AAED,2FAA2F;AAC3F,KAAK,UAAU,SAAS,CAAC,MAAc;IACrC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QACjC,IAAI,IAAK,KAAgB,CAAC,MAAM,CAAC;QACjC,IAAI,IAAI,GAAG,qBAAqB;YAAE,MAAM;QACxC,MAAM,CAAC,IAAI,CAAC,KAAe,CAAC,CAAC;IAC/B,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;AACxD,CAAC;AAED,uFAAuF;AACvF,SAAS,UAAU,CACjB,KAAc,EACd,MAAc,EACd,QAAqB,EACrB,MAAoB;IAEpB,IAAI,MAAM,EAAE,OAAO;QAAE,OAAO,IAAI,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAC3D,MAAM,MAAM,GAAG,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IAC3E,OAAO,IAAI,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AACpD,CAAC"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** What a free-form WHOIS reply says. */
|
|
2
|
+
export const WhoisVerdict = {
|
|
3
|
+
Registered: 'registered',
|
|
4
|
+
NotFound: 'not_found',
|
|
5
|
+
Reserved: 'reserved',
|
|
6
|
+
/** The server only says its query limit was hit. */
|
|
7
|
+
RateLimited: 'rate_limited',
|
|
8
|
+
/** No line in the reply gives an answer. */
|
|
9
|
+
Unknown: 'unknown',
|
|
10
|
+
};
|
|
11
|
+
/** Line flags: `^` matches at every line start, and case does not matter. */
|
|
12
|
+
const LINE_FLAGS = 'im';
|
|
13
|
+
/**
|
|
14
|
+
* A registry states its answer at the start of a line ("Domain not found.", "Status: free"),
|
|
15
|
+
* optionally after a comment marker ("%", "#", ">"). Matching only there keeps the long
|
|
16
|
+
* terms-of-use paragraphs, which talk about throttling and missing data, from deciding anything.
|
|
17
|
+
*/
|
|
18
|
+
function atLineStart(body) {
|
|
19
|
+
return new RegExp(String.raw `^[\s%#>*]*(?:${body})`, LINE_FLAGS);
|
|
20
|
+
}
|
|
21
|
+
/** The registry holds the name back. */
|
|
22
|
+
const RESERVED = [
|
|
23
|
+
// "Status: reserved", "Domain status: blocked"
|
|
24
|
+
atLineStart(String.raw `(domain )?status\s*:\s*(reserved|blocked|restricted|prohibited)\b`),
|
|
25
|
+
// "This domain name is reserved", "Name is blocked"
|
|
26
|
+
atLineStart(String.raw `(this )?(domain|name)( name)? (is )?(reserved|blocked|restricted)\b`),
|
|
27
|
+
// "... reserved by the registry", anywhere in a line
|
|
28
|
+
/\breserved (by|for) the registry\b/i,
|
|
29
|
+
// "... is not available for registration", anywhere in a line
|
|
30
|
+
/\bnot available for registration\b/i,
|
|
31
|
+
];
|
|
32
|
+
/** A creation date: the clearest sign of a registration, so it is checked before "not found" lines. */
|
|
33
|
+
const CREATED = [
|
|
34
|
+
// "Creation Date: 2001-02-03", "created: …", "Registered on: …", "Registration Time: …"
|
|
35
|
+
atLineStart(String.raw `(creation date|created( on)?|registered( on)?|registration (date|time)|domain registered|registered date)\s*:\s*\S`),
|
|
36
|
+
];
|
|
37
|
+
/** The registry does not know the name. */
|
|
38
|
+
const NOT_FOUND = [
|
|
39
|
+
// Verisign "No match for", JPRS "No match!!"
|
|
40
|
+
atLineStart(String.raw `no match\b`),
|
|
41
|
+
// "NOT FOUND", Identity Digital "Domain not found."
|
|
42
|
+
atLineStart(String.raw `(domain )?not found\b`),
|
|
43
|
+
// "No data found", "No entries found", "No matching record"
|
|
44
|
+
atLineStart(String.raw `no (data|entries|object|matching records?)( found)?\b`),
|
|
45
|
+
// "No such domain"
|
|
46
|
+
atLineStart(String.raw `no such domain\b`),
|
|
47
|
+
// "The queried object does not exist"
|
|
48
|
+
atLineStart(String.raw `(the queried )?(object|domain) does not exist\b`),
|
|
49
|
+
// DENIC, EURid, .it: "Status: free", "Status: AVAILABLE"
|
|
50
|
+
atLineStart(String.raw `(domain )?status\s*:\s*(free|available)\b`),
|
|
51
|
+
// SIDN: "example.nl is free"
|
|
52
|
+
atLineStart(String.raw `\S+ is free\b`),
|
|
53
|
+
// Nominet: "This domain name has not been registered."
|
|
54
|
+
atLineStart(String.raw `this domain( name)? has not been registered\b`),
|
|
55
|
+
// SWITCH: "We do not have an entry in our database matching your query."
|
|
56
|
+
atLineStart(String.raw `we do not have an entry in our database\b`),
|
|
57
|
+
// "No information available about domain name … in the Registry"
|
|
58
|
+
atLineStart(String.raw `no information available about domain name\b`),
|
|
59
|
+
// "Domain name not known"
|
|
60
|
+
atLineStart(String.raw `domain name not known\b`),
|
|
61
|
+
];
|
|
62
|
+
/** Other signs of a registration. */
|
|
63
|
+
const REGISTERED = [
|
|
64
|
+
// "Registrar: Example Inc."
|
|
65
|
+
atLineStart(String.raw `(registrar|sponsoring registrar|registrar name)\s*:\s*\S`),
|
|
66
|
+
// "Name Server: ns1.example.com", "nserver: …"
|
|
67
|
+
atLineStart(String.raw `(name ?servers?|nserver)\s*:\s*\S`),
|
|
68
|
+
// "Status: active", "Domain Status: clientTransferProhibited", DENIC "Status: connect"
|
|
69
|
+
atLineStart(String.raw `(domain )?status\s*:\s*(active|ok|registered|connect|client|server)`),
|
|
70
|
+
// "Registry Expiry Date: …", "Expires on: …", "paid-till: …"
|
|
71
|
+
atLineStart(String.raw `(registry expiry date|expir(y|ation) date|expires( on)?|paid-till)\s*:\s*\S`),
|
|
72
|
+
];
|
|
73
|
+
/** Phrases saying a limit was actually hit. Only consulted when the reply holds no answer at all. */
|
|
74
|
+
const RATE_LIMITED = [
|
|
75
|
+
// "Query limit exceeded", "quota has been reached"
|
|
76
|
+
/\b(limit|quota) (has been |was )?(exceeded|reached)\b/i,
|
|
77
|
+
// "You have exceeded the maximum allowable number of queries"
|
|
78
|
+
/\bexceeded (the |your )?(maximum|allowed|allowable|query|rate)\b/i,
|
|
79
|
+
// "Too many queries"
|
|
80
|
+
/\btoo many (queries|requests|connections)\b/i,
|
|
81
|
+
// "Queries exceeded"
|
|
82
|
+
/\b(queries|requests|lookups) (have been |were )?exceeded\b/i,
|
|
83
|
+
// "Please try again later", "try again in 60 seconds"
|
|
84
|
+
/\btry again (later|in \d)/i,
|
|
85
|
+
// "Temporarily blocked", "temporarily denied"
|
|
86
|
+
/\btemporarily (blocked|denied|unavailable)\b/i,
|
|
87
|
+
];
|
|
88
|
+
/**
|
|
89
|
+
* The rules in the order they are tried; the first match decides. A definite answer line wins;
|
|
90
|
+
* rate-limit wording counts only when there is none, because terms of use often describe
|
|
91
|
+
* throttling. A rate-limit message can never turn into "not found": it carries no answer line,
|
|
92
|
+
* and a wrong "available" is the worst possible result. (A list, not a Map: "registered" comes
|
|
93
|
+
* twice, once before and once after "not found".)
|
|
94
|
+
*/
|
|
95
|
+
const RULES = [
|
|
96
|
+
{ verdict: WhoisVerdict.Reserved, patterns: RESERVED },
|
|
97
|
+
{ verdict: WhoisVerdict.Registered, patterns: CREATED },
|
|
98
|
+
{ verdict: WhoisVerdict.NotFound, patterns: NOT_FOUND },
|
|
99
|
+
{ verdict: WhoisVerdict.Registered, patterns: REGISTERED },
|
|
100
|
+
{ verdict: WhoisVerdict.RateLimited, patterns: RATE_LIMITED },
|
|
101
|
+
];
|
|
102
|
+
/** Reads a free-form WHOIS reply. */
|
|
103
|
+
export function interpretWhois(reply) {
|
|
104
|
+
if (!reply.trim())
|
|
105
|
+
return WhoisVerdict.Unknown;
|
|
106
|
+
const rule = RULES.find(({ patterns }) => patterns.some((pattern) => pattern.test(reply)));
|
|
107
|
+
return rule?.verdict ?? WhoisVerdict.Unknown;
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=whois-reply.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"whois-reply.js","sourceRoot":"","sources":["../../src/checkers/whois-reply.ts"],"names":[],"mappings":"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"}
|