rdapper 0.1.1 → 0.3.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/README.md +6 -1
- package/dist/index.d.ts +95 -5
- package/dist/index.js +1026 -114
- package/package.json +12 -9
- package/dist/lib/async.d.ts +0 -2
- package/dist/lib/async.js +0 -18
- package/dist/lib/constants.d.ts +0 -1
- package/dist/lib/constants.js +0 -1
- package/dist/lib/dates.d.ts +0 -1
- package/dist/lib/dates.js +0 -84
- package/dist/lib/domain.d.ts +0 -8
- package/dist/lib/domain.js +0 -64
- package/dist/lib/text.d.ts +0 -6
- package/dist/lib/text.js +0 -77
- package/dist/rdap/bootstrap.d.ts +0 -6
- package/dist/rdap/bootstrap.js +0 -30
- package/dist/rdap/client.d.ts +0 -9
- package/dist/rdap/client.js +0 -21
- package/dist/rdap/normalize.d.ts +0 -6
- package/dist/rdap/normalize.js +0 -214
- package/dist/types.d.ts +0 -83
- package/dist/types.js +0 -1
- package/dist/whois/client.d.ts +0 -10
- package/dist/whois/client.js +0 -46
- package/dist/whois/discovery.d.ts +0 -24
- package/dist/whois/discovery.js +0 -99
- package/dist/whois/normalize.d.ts +0 -6
- package/dist/whois/normalize.js +0 -214
- package/dist/whois/servers.d.ts +0 -1
- package/dist/whois/servers.js +0 -64
package/dist/index.js
CHANGED
|
@@ -1,120 +1,1032 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import psl from "psl";
|
|
2
|
+
import { createConnection } from "node:net";
|
|
3
|
+
|
|
4
|
+
//#region src/lib/dates.ts
|
|
5
|
+
function toISO(dateLike) {
|
|
6
|
+
if (dateLike == null) return void 0;
|
|
7
|
+
if (dateLike instanceof Date) return toIsoFromDate(dateLike);
|
|
8
|
+
if (typeof dateLike === "number") return toIsoFromDate(new Date(dateLike));
|
|
9
|
+
const raw = String(dateLike).trim();
|
|
10
|
+
if (!raw) return void 0;
|
|
11
|
+
for (const re of [
|
|
12
|
+
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z|([+-]\d{2})(?::?(\d{2}))?)?$/,
|
|
13
|
+
/^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z|([+-]\d{2})(?::?(\d{2}))?)?$/,
|
|
14
|
+
/^(\d{2})-([A-Za-z]{3})-(\d{4})$/,
|
|
15
|
+
/^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/
|
|
16
|
+
]) {
|
|
17
|
+
const m = raw.match(re);
|
|
18
|
+
if (!m) continue;
|
|
19
|
+
const d = parseDateWithRegex(m, re);
|
|
20
|
+
if (d) return toIsoFromDate(d);
|
|
21
|
+
}
|
|
22
|
+
const native = new Date(raw);
|
|
23
|
+
if (!Number.isNaN(native.getTime())) return toIsoFromDate(native);
|
|
24
|
+
}
|
|
25
|
+
function toIsoFromDate(d) {
|
|
26
|
+
try {
|
|
27
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), 0)).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function parseDateWithRegex(m, _re) {
|
|
33
|
+
const monthMap = {
|
|
34
|
+
jan: 0,
|
|
35
|
+
feb: 1,
|
|
36
|
+
mar: 2,
|
|
37
|
+
apr: 3,
|
|
38
|
+
may: 4,
|
|
39
|
+
jun: 5,
|
|
40
|
+
jul: 6,
|
|
41
|
+
aug: 7,
|
|
42
|
+
sep: 8,
|
|
43
|
+
oct: 9,
|
|
44
|
+
nov: 10,
|
|
45
|
+
dec: 11
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
if (m[0].includes(":")) {
|
|
49
|
+
const [_$1, y, mo, d, hh, mm, ss, offH, offM] = m;
|
|
50
|
+
let dt = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mm), Number(ss));
|
|
51
|
+
if (offH) {
|
|
52
|
+
const sign = offH.startsWith("-") ? -1 : 1;
|
|
53
|
+
const hours = Math.abs(Number(offH));
|
|
54
|
+
const minutes = offM ? Number(offM) : 0;
|
|
55
|
+
const offsetMs = sign * (hours * 60 + minutes) * 60 * 1e3;
|
|
56
|
+
dt -= offsetMs;
|
|
57
|
+
}
|
|
58
|
+
return new Date(dt);
|
|
59
|
+
}
|
|
60
|
+
if (m[0].includes("-")) {
|
|
61
|
+
const [_$1, dd$1, monStr$1, yyyy$1] = m;
|
|
62
|
+
const mon$1 = monthMap[monStr$1.toLowerCase()];
|
|
63
|
+
return new Date(Date.UTC(Number(yyyy$1), mon$1, Number(dd$1)));
|
|
64
|
+
}
|
|
65
|
+
const [_, monStr, dd, yyyy] = m;
|
|
66
|
+
const mon = monthMap[monStr.toLowerCase()];
|
|
67
|
+
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/lib/domain.ts
|
|
73
|
+
function getDomainParts(domain) {
|
|
74
|
+
const lower = domain.toLowerCase().trim();
|
|
75
|
+
let publicSuffix;
|
|
76
|
+
try {
|
|
77
|
+
publicSuffix = (psl.parse?.(lower))?.tld;
|
|
78
|
+
} catch {}
|
|
79
|
+
if (!publicSuffix) {
|
|
80
|
+
const parts = lower.split(".").filter(Boolean);
|
|
81
|
+
publicSuffix = parts.length ? parts[parts.length - 1] : lower;
|
|
82
|
+
}
|
|
83
|
+
const labels = publicSuffix.split(".").filter(Boolean);
|
|
84
|
+
const tld = labels.length ? labels[labels.length - 1] : publicSuffix;
|
|
85
|
+
return {
|
|
86
|
+
publicSuffix,
|
|
87
|
+
tld
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function isLikelyDomain(input) {
|
|
91
|
+
return /^[a-z0-9.-]+$/i.test(input) && input.includes(".");
|
|
92
|
+
}
|
|
93
|
+
const WHOIS_AVAILABLE_PATTERNS = [
|
|
94
|
+
/\bno match\b/i,
|
|
95
|
+
/\bnot found\b/i,
|
|
96
|
+
/\bno entries found\b/i,
|
|
97
|
+
/\bno data found\b/i,
|
|
98
|
+
/\bavailable for registration\b/i,
|
|
99
|
+
/\bdomain\s+available\b/i,
|
|
100
|
+
/\bdomain status[:\s]+available\b/i,
|
|
101
|
+
/\bobject does not exist\b/i,
|
|
102
|
+
/\bthe queried object does not exist\b/i
|
|
103
|
+
];
|
|
104
|
+
function isWhoisAvailable(text) {
|
|
105
|
+
if (!text) return false;
|
|
106
|
+
return WHOIS_AVAILABLE_PATTERNS.some((re) => re.test(text));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/lib/async.ts
|
|
111
|
+
function withTimeout(promise, timeoutMs, reason = "Timeout") {
|
|
112
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
|
|
113
|
+
let timer;
|
|
114
|
+
const timeout = new Promise((_, reject) => {
|
|
115
|
+
timer = setTimeout(() => reject(new Error(reason)), timeoutMs);
|
|
116
|
+
});
|
|
117
|
+
return Promise.race([promise.finally(() => {
|
|
118
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
119
|
+
}), timeout]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/lib/constants.ts
|
|
124
|
+
const DEFAULT_TIMEOUT_MS = 15e3;
|
|
125
|
+
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/rdap/bootstrap.ts
|
|
128
|
+
/**
|
|
129
|
+
* Resolve RDAP base URLs for a given TLD using IANA's bootstrap registry.
|
|
130
|
+
* Returns zero or more base URLs (always suffixed with a trailing slash).
|
|
131
|
+
*/
|
|
132
|
+
async function getRdapBaseUrlsForTld(tld, options) {
|
|
133
|
+
const bootstrapUrl = options?.customBootstrapUrl ?? "https://data.iana.org/rdap/dns.json";
|
|
134
|
+
const res = await withTimeout(fetch(bootstrapUrl, {
|
|
135
|
+
method: "GET",
|
|
136
|
+
headers: { accept: "application/json" },
|
|
137
|
+
signal: options?.signal
|
|
138
|
+
}), options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, "RDAP bootstrap timeout");
|
|
139
|
+
if (!res.ok) return [];
|
|
140
|
+
const data = await res.json();
|
|
141
|
+
const target = tld.toLowerCase();
|
|
142
|
+
const bases = [];
|
|
143
|
+
for (const svc of data.services) {
|
|
144
|
+
const tlds = svc[0];
|
|
145
|
+
const urls = svc[1];
|
|
146
|
+
if (tlds.map((x) => x.toLowerCase()).includes(target)) for (const u of urls) {
|
|
147
|
+
const base = u.endsWith("/") ? u : `${u}/`;
|
|
148
|
+
bases.push(base);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return Array.from(new Set(bases));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/rdap/client.ts
|
|
156
|
+
/**
|
|
157
|
+
* Fetch RDAP JSON for a domain from a specific RDAP base URL.
|
|
158
|
+
* Throws on HTTP >= 400 (includes RDAP error JSON payloads).
|
|
159
|
+
*/
|
|
160
|
+
async function fetchRdapDomain(domain, baseUrl, options) {
|
|
161
|
+
const url = new URL(`domain/${encodeURIComponent(domain)}`, baseUrl).toString();
|
|
162
|
+
const res = await withTimeout(fetch(url, {
|
|
163
|
+
method: "GET",
|
|
164
|
+
headers: { accept: "application/rdap+json, application/json" },
|
|
165
|
+
signal: options?.signal
|
|
166
|
+
}), options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, "RDAP lookup timeout");
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
const bodyText = await res.text();
|
|
169
|
+
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
|
|
170
|
+
}
|
|
171
|
+
const json = await res.json();
|
|
172
|
+
return {
|
|
173
|
+
url,
|
|
174
|
+
json
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/rdap/links.ts
|
|
180
|
+
/** Extract candidate RDAP link URLs from an RDAP document. */
|
|
181
|
+
function extractRdapRelatedLinks(doc, opts) {
|
|
182
|
+
const rels = (opts?.rdapLinkRels?.length ? opts.rdapLinkRels : [
|
|
183
|
+
"related",
|
|
184
|
+
"entity",
|
|
185
|
+
"registrar",
|
|
186
|
+
"alternate"
|
|
187
|
+
]).map((r) => r.toLowerCase());
|
|
188
|
+
const d = doc ?? {};
|
|
189
|
+
const arr = Array.isArray(d?.links) ? d.links : [];
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const link of arr) {
|
|
192
|
+
const rel = String(link.rel || "").toLowerCase();
|
|
193
|
+
const type = String(link.type || "").toLowerCase();
|
|
194
|
+
if (!rels.includes(rel)) continue;
|
|
195
|
+
if (type && !/application\/rdap\+json/i.test(type)) continue;
|
|
196
|
+
const url = link.href || link.value;
|
|
197
|
+
if (url && /^https?:\/\//i.test(url)) out.push(url);
|
|
198
|
+
}
|
|
199
|
+
return Array.from(new Set(out));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/rdap/merge.ts
|
|
204
|
+
/** Merge RDAP documents with a conservative, additive strategy. */
|
|
205
|
+
function mergeRdapDocs(baseDoc, others) {
|
|
206
|
+
const merged = { ...baseDoc };
|
|
207
|
+
for (const doc of others) {
|
|
208
|
+
const cur = doc ?? {};
|
|
209
|
+
merged.status = uniqStrings([...toStringArray(merged.status), ...toStringArray(cur.status)]);
|
|
210
|
+
merged.events = uniqBy([...toArray(merged.events), ...toArray(cur.events)], (e) => `${String(e?.eventAction ?? "").toLowerCase()}|${String(e?.eventDate ?? "")}`);
|
|
211
|
+
merged.nameservers = uniqBy([...toArray(merged.nameservers), ...toArray(cur.nameservers)], (n) => `${String(n?.ldhName ?? n?.unicodeName ?? "").toLowerCase()}`);
|
|
212
|
+
merged.entities = uniqBy([...toArray(merged.entities), ...toArray(cur.entities)], (e) => `${String(e?.handle ?? "").toLowerCase()}|${String(JSON.stringify(e?.roles || [])).toLowerCase()}|${String(JSON.stringify(e?.vcardArray || [])).toLowerCase()}`);
|
|
213
|
+
if (merged.secureDNS == null && cur.secureDNS != null) merged.secureDNS = cur.secureDNS;
|
|
214
|
+
if (merged.port43 == null && cur.port43 != null) merged.port43 = cur.port43;
|
|
215
|
+
const mergedRemarks = merged.remarks;
|
|
216
|
+
const curRemarks = cur.remarks;
|
|
217
|
+
if (Array.isArray(mergedRemarks) || Array.isArray(curRemarks)) {
|
|
218
|
+
const a = toArray(mergedRemarks);
|
|
219
|
+
const b = toArray(curRemarks);
|
|
220
|
+
merged.remarks = [...a, ...b];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return merged;
|
|
224
|
+
}
|
|
225
|
+
/** Fetch and merge RDAP related documents up to a hop limit. */
|
|
226
|
+
async function fetchAndMergeRdapRelated(domain, baseDoc, opts) {
|
|
227
|
+
const tried = [];
|
|
228
|
+
if (opts?.rdapFollowLinks === false) return {
|
|
229
|
+
merged: baseDoc,
|
|
230
|
+
serversTried: tried
|
|
231
|
+
};
|
|
232
|
+
const maxHops = Math.max(0, opts?.maxRdapLinkHops ?? 2);
|
|
233
|
+
if (maxHops === 0) return {
|
|
234
|
+
merged: baseDoc,
|
|
235
|
+
serversTried: tried
|
|
236
|
+
};
|
|
237
|
+
const visited = /* @__PURE__ */ new Set();
|
|
238
|
+
let current = baseDoc;
|
|
239
|
+
let hops = 0;
|
|
240
|
+
while (hops < maxHops) {
|
|
241
|
+
const nextBatch = extractRdapRelatedLinks(current, { rdapLinkRels: opts?.rdapLinkRels }).filter((u) => !visited.has(u));
|
|
242
|
+
if (nextBatch.length === 0) break;
|
|
243
|
+
const fetchedDocs = [];
|
|
244
|
+
for (const url of nextBatch) {
|
|
245
|
+
visited.add(url);
|
|
246
|
+
try {
|
|
247
|
+
const { json } = await fetchRdapUrl(url, opts);
|
|
248
|
+
tried.push(url);
|
|
249
|
+
const ldh = String(json?.ldhName ?? "").toLowerCase();
|
|
250
|
+
const uni = String(json?.unicodeName ?? "").toLowerCase();
|
|
251
|
+
if (ldh && !sameDomain(ldh, domain)) continue;
|
|
252
|
+
if (uni && !sameDomain(uni, domain)) continue;
|
|
253
|
+
fetchedDocs.push(json);
|
|
254
|
+
} catch {}
|
|
255
|
+
}
|
|
256
|
+
if (fetchedDocs.length === 0) break;
|
|
257
|
+
current = mergeRdapDocs(current, fetchedDocs);
|
|
258
|
+
hops += 1;
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
merged: current,
|
|
262
|
+
serversTried: tried
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async function fetchRdapUrl(url, options) {
|
|
266
|
+
const res = await withTimeout(fetch(url, {
|
|
267
|
+
method: "GET",
|
|
268
|
+
headers: { accept: "application/rdap+json, application/json" },
|
|
269
|
+
signal: options?.signal
|
|
270
|
+
}), options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, "RDAP link fetch timeout");
|
|
271
|
+
if (!res.ok) {
|
|
272
|
+
const bodyText = await res.text();
|
|
273
|
+
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
|
|
274
|
+
}
|
|
275
|
+
const json = await res.json();
|
|
276
|
+
return {
|
|
277
|
+
url,
|
|
278
|
+
json
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function toArray(val) {
|
|
282
|
+
return Array.isArray(val) ? val : [];
|
|
283
|
+
}
|
|
284
|
+
function toStringArray(val) {
|
|
285
|
+
return Array.isArray(val) ? val.map((v) => String(v)) : [];
|
|
286
|
+
}
|
|
287
|
+
function uniqStrings(arr) {
|
|
288
|
+
return Array.from(new Set(arr));
|
|
289
|
+
}
|
|
290
|
+
function uniqBy(arr, key) {
|
|
291
|
+
const seen = /* @__PURE__ */ new Set();
|
|
292
|
+
const out = [];
|
|
293
|
+
for (const item of arr) {
|
|
294
|
+
const k = key(item);
|
|
295
|
+
if (seen.has(k)) continue;
|
|
296
|
+
seen.add(k);
|
|
297
|
+
out.push(item);
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
function sameDomain(a, b) {
|
|
302
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
//#endregion
|
|
306
|
+
//#region src/lib/text.ts
|
|
307
|
+
function uniq(arr) {
|
|
308
|
+
if (!arr) return void 0;
|
|
309
|
+
return Array.from(new Set(arr));
|
|
310
|
+
}
|
|
311
|
+
function parseKeyValueLines(text) {
|
|
312
|
+
const map = /* @__PURE__ */ new Map();
|
|
313
|
+
const lines = text.split(/\r?\n/);
|
|
314
|
+
let lastKey;
|
|
315
|
+
for (const rawLine of lines) {
|
|
316
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
317
|
+
if (!line.trim()) continue;
|
|
318
|
+
const bracket = line.match(/^\s*\[([^\]]+)\]\s*(.*)$/);
|
|
319
|
+
if (bracket) {
|
|
320
|
+
const key = bracket[1].trim().toLowerCase();
|
|
321
|
+
const value = bracket[2].trim();
|
|
322
|
+
const list = map.get(key) ?? [];
|
|
323
|
+
if (value) list.push(value);
|
|
324
|
+
map.set(key, list);
|
|
325
|
+
lastKey = key;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const idx = line.indexOf(":");
|
|
329
|
+
if (idx !== -1) {
|
|
330
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
331
|
+
const value = line.slice(idx + 1).trim();
|
|
332
|
+
if (!key) {
|
|
333
|
+
lastKey = void 0;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const list = map.get(key) ?? [];
|
|
337
|
+
if (value) list.push(value);
|
|
338
|
+
map.set(key, list);
|
|
339
|
+
lastKey = key;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (lastKey && /^\s+/.test(line)) {
|
|
343
|
+
const value = line.trim();
|
|
344
|
+
if (value) {
|
|
345
|
+
const list = map.get(lastKey) ?? [];
|
|
346
|
+
list.push(value);
|
|
347
|
+
map.set(lastKey, list);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return Object.fromEntries(map);
|
|
352
|
+
}
|
|
353
|
+
function asString(value) {
|
|
354
|
+
return typeof value === "string" ? value : void 0;
|
|
355
|
+
}
|
|
356
|
+
function asStringArray(value) {
|
|
357
|
+
return Array.isArray(value) ? value.filter((x) => typeof x === "string") : void 0;
|
|
358
|
+
}
|
|
359
|
+
function asDateLike(value) {
|
|
360
|
+
if (typeof value === "string" || typeof value === "number" || value instanceof Date) return value;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/rdap/normalize.ts
|
|
365
|
+
/**
|
|
366
|
+
* Convert RDAP JSON into our normalized DomainRecord.
|
|
367
|
+
* This function is defensive: RDAP servers vary in completeness and field naming.
|
|
368
|
+
*/
|
|
369
|
+
function normalizeRdap(inputDomain, tld, rdap, rdapServersTried, fetchedAtISO, includeRaw = false) {
|
|
370
|
+
const doc = rdap ?? {};
|
|
371
|
+
const ldhName = asString(doc.ldhName) || asString(doc.handle);
|
|
372
|
+
const unicodeName = asString(doc.unicodeName);
|
|
373
|
+
const registrar = extractRegistrar(doc.entities);
|
|
374
|
+
const nameservers = Array.isArray(doc.nameservers) ? doc.nameservers.map((ns) => {
|
|
375
|
+
const host = (asString(ns.ldhName) ?? asString(ns.unicodeName) ?? "").toLowerCase();
|
|
376
|
+
const ip = ns.ipAddresses;
|
|
377
|
+
const ipv4 = asStringArray(ip?.v4);
|
|
378
|
+
const ipv6 = asStringArray(ip?.v6);
|
|
379
|
+
const n = { host };
|
|
380
|
+
if (ipv4?.length) n.ipv4 = ipv4;
|
|
381
|
+
if (ipv6?.length) n.ipv6 = ipv6;
|
|
382
|
+
return n;
|
|
383
|
+
}).filter((n) => !!n.host) : void 0;
|
|
384
|
+
const contacts = extractContacts(doc.entities);
|
|
385
|
+
const statuses = Array.isArray(doc.status) ? doc.status.filter((s) => typeof s === "string").map((s) => ({
|
|
386
|
+
status: s,
|
|
387
|
+
raw: s
|
|
388
|
+
})) : void 0;
|
|
389
|
+
const secureDNS = doc.secureDNS;
|
|
390
|
+
const dnssec = secureDNS ? {
|
|
391
|
+
enabled: !!secureDNS.delegationSigned,
|
|
392
|
+
dsRecords: Array.isArray(secureDNS.dsData) ? secureDNS.dsData.map((d) => ({
|
|
393
|
+
keyTag: d.keyTag,
|
|
394
|
+
algorithm: d.algorithm,
|
|
395
|
+
digestType: d.digestType,
|
|
396
|
+
digest: d.digest
|
|
397
|
+
})) : void 0
|
|
398
|
+
} : void 0;
|
|
399
|
+
const events = Array.isArray(doc.events) ? doc.events : [];
|
|
400
|
+
const byAction = (action) => events.find((e) => typeof e?.eventAction === "string" && e.eventAction.toLowerCase().includes(action));
|
|
401
|
+
const creationDate = toISO(asDateLike(byAction("registration")?.eventDate) ?? asDateLike(doc.registrationDate));
|
|
402
|
+
const updatedDate = toISO(asDateLike(byAction("last changed")?.eventDate) ?? asDateLike(doc.lastChangedDate));
|
|
403
|
+
const expirationDate = toISO(asDateLike(byAction("expiration")?.eventDate) ?? asDateLike(doc.expirationDate));
|
|
404
|
+
const deletionDate = toISO(asDateLike(byAction("deletion")?.eventDate) ?? asDateLike(doc.deletionDate));
|
|
405
|
+
const transferLock = !!statuses?.some((s) => /transferprohibited/i.test(s.status));
|
|
406
|
+
const whoisServer = asString(doc.port43);
|
|
407
|
+
return {
|
|
408
|
+
domain: unicodeName || ldhName || inputDomain,
|
|
409
|
+
tld,
|
|
410
|
+
isRegistered: true,
|
|
411
|
+
isIDN: /(^|\.)xn--/i.test(ldhName || inputDomain),
|
|
412
|
+
unicodeName: unicodeName || void 0,
|
|
413
|
+
punycodeName: ldhName || void 0,
|
|
414
|
+
registry: void 0,
|
|
415
|
+
registrar,
|
|
416
|
+
reseller: void 0,
|
|
417
|
+
statuses,
|
|
418
|
+
creationDate,
|
|
419
|
+
updatedDate,
|
|
420
|
+
expirationDate,
|
|
421
|
+
deletionDate,
|
|
422
|
+
transferLock,
|
|
423
|
+
dnssec,
|
|
424
|
+
nameservers: nameservers ? uniq(nameservers.map((n) => ({
|
|
425
|
+
...n,
|
|
426
|
+
host: n.host.toLowerCase()
|
|
427
|
+
}))) : void 0,
|
|
428
|
+
contacts,
|
|
429
|
+
whoisServer,
|
|
430
|
+
rdapServers: rdapServersTried,
|
|
431
|
+
rawRdap: includeRaw ? rdap : void 0,
|
|
432
|
+
rawWhois: void 0,
|
|
433
|
+
source: "rdap",
|
|
434
|
+
fetchedAt: fetchedAtISO,
|
|
435
|
+
warnings: void 0
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function extractRegistrar(entities) {
|
|
439
|
+
if (!Array.isArray(entities)) return void 0;
|
|
440
|
+
for (const ent of entities) {
|
|
441
|
+
if (!(Array.isArray(ent?.roles) ? ent.roles.filter((r) => typeof r === "string") : []).some((r) => /registrar/i.test(r))) continue;
|
|
442
|
+
const v = parseVcard(ent?.vcardArray);
|
|
443
|
+
const ianaId = Array.isArray(ent?.publicIds) ? ent.publicIds.find((id) => /iana\s*registrar\s*id/i.test(String(id?.type)))?.identifier : void 0;
|
|
444
|
+
return {
|
|
445
|
+
name: v.fn || v.org || asString(ent?.handle) || void 0,
|
|
446
|
+
ianaId: asString(ianaId),
|
|
447
|
+
url: v.url ?? void 0,
|
|
448
|
+
email: v.email ?? void 0,
|
|
449
|
+
phone: v.tel ?? void 0
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function extractContacts(entities) {
|
|
454
|
+
if (!Array.isArray(entities)) return void 0;
|
|
455
|
+
const out = [];
|
|
456
|
+
for (const ent of entities) {
|
|
457
|
+
const roles = Array.isArray(ent?.roles) ? ent.roles.filter((r) => typeof r === "string") : [];
|
|
458
|
+
const v = parseVcard(ent?.vcardArray);
|
|
459
|
+
const type = roles.find((r) => /registrant|administrative|technical|billing|abuse|reseller/i.test(r));
|
|
460
|
+
if (!type) continue;
|
|
461
|
+
const roleKey = {
|
|
462
|
+
registrant: "registrant",
|
|
463
|
+
administrative: "admin",
|
|
464
|
+
technical: "tech",
|
|
465
|
+
billing: "billing",
|
|
466
|
+
abuse: "abuse",
|
|
467
|
+
reseller: "reseller"
|
|
468
|
+
}[type.toLowerCase()] ?? "unknown";
|
|
469
|
+
out.push({
|
|
470
|
+
type: roleKey,
|
|
471
|
+
name: v.fn,
|
|
472
|
+
organization: v.org,
|
|
473
|
+
email: v.email,
|
|
474
|
+
phone: v.tel,
|
|
475
|
+
fax: v.fax,
|
|
476
|
+
street: v.street,
|
|
477
|
+
city: v.locality,
|
|
478
|
+
state: v.region,
|
|
479
|
+
postalCode: v.postcode,
|
|
480
|
+
country: v.country,
|
|
481
|
+
countryCode: v.countryCode
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return out.length ? out : void 0;
|
|
485
|
+
}
|
|
486
|
+
function parseVcard(vcardArray) {
|
|
487
|
+
if (!Array.isArray(vcardArray) || vcardArray[0] !== "vcard" || !Array.isArray(vcardArray[1])) return {};
|
|
488
|
+
const entries = vcardArray[1];
|
|
489
|
+
const out = {};
|
|
490
|
+
for (const e of entries) {
|
|
491
|
+
const key = e?.[0];
|
|
492
|
+
const value = e?.[3];
|
|
493
|
+
if (!key) continue;
|
|
494
|
+
switch (String(key).toLowerCase()) {
|
|
495
|
+
case "fn":
|
|
496
|
+
out.fn = asString(value);
|
|
497
|
+
break;
|
|
498
|
+
case "org":
|
|
499
|
+
out.org = Array.isArray(value) ? value.map((x) => String(x)).join(" ") : asString(value);
|
|
500
|
+
break;
|
|
501
|
+
case "email":
|
|
502
|
+
out.email = asString(value);
|
|
503
|
+
break;
|
|
504
|
+
case "tel":
|
|
505
|
+
out.tel = asString(value);
|
|
506
|
+
break;
|
|
507
|
+
case "url":
|
|
508
|
+
out.url = asString(value);
|
|
509
|
+
break;
|
|
510
|
+
case "adr":
|
|
511
|
+
if (Array.isArray(value)) {
|
|
512
|
+
out.street = value[2] ? String(value[2]).split(/\n|,\s*/) : void 0;
|
|
513
|
+
out.locality = asString(value[3]);
|
|
514
|
+
out.region = asString(value[4]);
|
|
515
|
+
out.postcode = asString(value[5]);
|
|
516
|
+
out.country = asString(value[6]);
|
|
517
|
+
}
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
//#endregion
|
|
525
|
+
//#region src/whois/client.ts
|
|
526
|
+
/**
|
|
527
|
+
* Perform a WHOIS query against an RFC 3912 server over TCP 43.
|
|
528
|
+
* Returns the raw text and the server used.
|
|
529
|
+
*/
|
|
530
|
+
async function whoisQuery(server, query, options) {
|
|
531
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
532
|
+
const port = 43;
|
|
533
|
+
const host = server.replace(/^whois:\/\//i, "");
|
|
534
|
+
const text = await withTimeout(queryTcp(host, port, query, options), timeoutMs, "WHOIS timeout");
|
|
535
|
+
return {
|
|
536
|
+
serverQueried: server,
|
|
537
|
+
text
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function queryTcp(host, port, query, options) {
|
|
541
|
+
return new Promise((resolve, reject) => {
|
|
542
|
+
const socket = createConnection({
|
|
543
|
+
host,
|
|
544
|
+
port
|
|
545
|
+
});
|
|
546
|
+
let data = "";
|
|
547
|
+
let done = false;
|
|
548
|
+
const cleanup = () => {
|
|
549
|
+
if (done) return;
|
|
550
|
+
done = true;
|
|
551
|
+
socket.destroy();
|
|
552
|
+
};
|
|
553
|
+
socket.setTimeout((options?.timeoutMs ?? DEFAULT_TIMEOUT_MS) - 1e3, () => {
|
|
554
|
+
cleanup();
|
|
555
|
+
reject(/* @__PURE__ */ new Error("WHOIS socket timeout"));
|
|
556
|
+
});
|
|
557
|
+
socket.on("error", (err) => {
|
|
558
|
+
cleanup();
|
|
559
|
+
reject(err);
|
|
560
|
+
});
|
|
561
|
+
socket.on("data", (chunk) => {
|
|
562
|
+
data += chunk.toString("utf8");
|
|
563
|
+
});
|
|
564
|
+
socket.on("end", () => {
|
|
565
|
+
cleanup();
|
|
566
|
+
resolve(data);
|
|
567
|
+
});
|
|
568
|
+
socket.on("connect", () => {
|
|
569
|
+
socket.write(`${query}\r\n`);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
//#endregion
|
|
575
|
+
//#region src/whois/servers.ts
|
|
576
|
+
const WHOIS_TLD_EXCEPTIONS = {
|
|
577
|
+
com: "whois.verisign-grs.com",
|
|
578
|
+
net: "whois.verisign-grs.com",
|
|
579
|
+
org: "whois.publicinterestregistry.org",
|
|
580
|
+
biz: "whois.nic.biz",
|
|
581
|
+
name: "whois.nic.name",
|
|
582
|
+
edu: "whois.educause.edu",
|
|
583
|
+
gov: "whois.nic.gov",
|
|
584
|
+
de: "whois.denic.de",
|
|
585
|
+
jp: "whois.jprs.jp",
|
|
586
|
+
fr: "whois.nic.fr",
|
|
587
|
+
it: "whois.nic.it",
|
|
588
|
+
pl: "whois.dns.pl",
|
|
589
|
+
nl: "whois.domain-registry.nl",
|
|
590
|
+
be: "whois.dns.be",
|
|
591
|
+
se: "whois.iis.se",
|
|
592
|
+
no: "whois.norid.no",
|
|
593
|
+
fi: "whois.fi",
|
|
594
|
+
cz: "whois.nic.cz",
|
|
595
|
+
es: "whois.nic.es",
|
|
596
|
+
br: "whois.registro.br",
|
|
597
|
+
ca: "whois.cira.ca",
|
|
598
|
+
dk: "whois.punktum.dk",
|
|
599
|
+
hk: "whois.hkirc.hk",
|
|
600
|
+
sg: "whois.sgnic.sg",
|
|
601
|
+
in: "whois.nixiregistry.in",
|
|
602
|
+
nz: "whois.irs.net.nz",
|
|
603
|
+
ch: "whois.nic.ch",
|
|
604
|
+
li: "whois.nic.li",
|
|
605
|
+
io: "whois.nic.io",
|
|
606
|
+
ai: "whois.nic.ai",
|
|
607
|
+
ru: "whois.tcinet.ru",
|
|
608
|
+
su: "whois.tcinet.ru",
|
|
609
|
+
us: "whois.nic.us",
|
|
610
|
+
co: "whois.nic.co",
|
|
611
|
+
me: "whois.nic.me",
|
|
612
|
+
tv: "whois.nic.tv",
|
|
613
|
+
cc: "ccwhois.verisign-grs.com",
|
|
614
|
+
eu: "whois.eu",
|
|
615
|
+
au: "whois.auda.org.au",
|
|
616
|
+
kr: "whois.kr",
|
|
617
|
+
tw: "whois.twnic.net.tw",
|
|
618
|
+
uk: "whois.nic.uk",
|
|
619
|
+
nu: "whois.iis.nu",
|
|
620
|
+
"xn--p1ai": "whois.tcinet.ru",
|
|
621
|
+
"uk.com": "whois.centralnic.com",
|
|
622
|
+
"uk.net": "whois.centralnic.com",
|
|
623
|
+
"gb.com": "whois.centralnic.com",
|
|
624
|
+
"gb.net": "whois.centralnic.com",
|
|
625
|
+
"eu.com": "whois.centralnic.com",
|
|
626
|
+
"us.com": "whois.centralnic.com",
|
|
627
|
+
"se.com": "whois.centralnic.com",
|
|
628
|
+
"de.com": "whois.centralnic.com",
|
|
629
|
+
"br.com": "whois.centralnic.com",
|
|
630
|
+
"ru.com": "whois.centralnic.com",
|
|
631
|
+
"cn.com": "whois.centralnic.com",
|
|
632
|
+
"sa.com": "whois.centralnic.com",
|
|
633
|
+
"co.com": "whois.centralnic.com"
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
//#endregion
|
|
637
|
+
//#region src/whois/discovery.ts
|
|
638
|
+
/**
|
|
639
|
+
* Parse the IANA WHOIS response for a TLD and extract the WHOIS server
|
|
640
|
+
* without crossing line boundaries. Some TLDs (e.g. .np) leave the field
|
|
641
|
+
* blank, in which case this returns undefined.
|
|
642
|
+
*/
|
|
643
|
+
function parseIanaWhoisServer(text) {
|
|
644
|
+
const fields = [
|
|
645
|
+
"whois",
|
|
646
|
+
"refer",
|
|
647
|
+
"whois server"
|
|
648
|
+
];
|
|
649
|
+
const lines = String(text).split(/\r?\n/);
|
|
650
|
+
for (const field of fields) for (const raw of lines) {
|
|
651
|
+
const line = raw.trimEnd();
|
|
652
|
+
const re = new RegExp(`^\\s*${field}\\s*:\\s*(.*?)$`, "i");
|
|
653
|
+
const m = line.match(re);
|
|
654
|
+
if (m) {
|
|
655
|
+
const value = (m[1] || "").trim();
|
|
656
|
+
if (value) return value;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Parse a likely registration information URL from an IANA WHOIS response.
|
|
662
|
+
* Looks at lines like:
|
|
663
|
+
* remarks: Registration information: http://example.tld
|
|
664
|
+
* url: https://registry.example
|
|
665
|
+
*/
|
|
666
|
+
function parseIanaRegistrationInfoUrl(text) {
|
|
667
|
+
const lines = String(text).split(/\r?\n/);
|
|
668
|
+
for (const raw of lines) {
|
|
669
|
+
const line = raw.trim();
|
|
670
|
+
if (!/^\s*(remarks|url|website)\s*:/i.test(line)) continue;
|
|
671
|
+
const urlMatch = line.match(/https?:\/\/\S+/i);
|
|
672
|
+
if (urlMatch?.[0]) return urlMatch[0];
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/** Fetch raw IANA WHOIS text for a TLD (best-effort). */
|
|
676
|
+
async function getIanaWhoisTextForTld(tld, options) {
|
|
677
|
+
try {
|
|
678
|
+
return (await whoisQuery("whois.iana.org", tld.toLowerCase(), options)).text;
|
|
679
|
+
} catch {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Best-effort discovery of the authoritative WHOIS server for a TLD via IANA root DB.
|
|
685
|
+
*/
|
|
686
|
+
async function ianaWhoisServerForTld(tld, options) {
|
|
687
|
+
const key = tld.toLowerCase();
|
|
688
|
+
const hint = options?.whoisHints?.[key];
|
|
689
|
+
if (hint) return normalizeServer(hint);
|
|
690
|
+
try {
|
|
691
|
+
const txt = (await whoisQuery("whois.iana.org", key, options)).text;
|
|
692
|
+
const server = parseIanaWhoisServer(txt);
|
|
693
|
+
if (server) return normalizeServer(server);
|
|
694
|
+
} catch {}
|
|
695
|
+
const exception = WHOIS_TLD_EXCEPTIONS[key];
|
|
696
|
+
if (exception) return normalizeServer(exception);
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Extract registrar referral WHOIS server from a WHOIS response, if present.
|
|
700
|
+
*/
|
|
701
|
+
function extractWhoisReferral(text) {
|
|
702
|
+
for (const re of [
|
|
703
|
+
/^Registrar WHOIS Server:\s*(.+)$/im,
|
|
704
|
+
/^Whois Server:\s*(.+)$/im,
|
|
705
|
+
/^ReferralServer:\s*whois:\/\/(.+)$/im
|
|
706
|
+
]) {
|
|
707
|
+
const m = text.match(re);
|
|
708
|
+
if (m?.[1]) return m[1].trim();
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
function normalizeServer(server) {
|
|
712
|
+
return server.replace(/^whois:\/\//i, "").replace(/\/$/, "");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
//#endregion
|
|
716
|
+
//#region src/whois/normalize.ts
|
|
717
|
+
/**
|
|
718
|
+
* Convert raw WHOIS text into our normalized DomainRecord.
|
|
719
|
+
* Heuristics cover many gTLD and ccTLD formats; exact fields vary per registry.
|
|
720
|
+
*/
|
|
721
|
+
function normalizeWhois(domain, tld, whoisText, whoisServer, fetchedAtISO, includeRaw = false) {
|
|
722
|
+
const map = parseKeyValueLines(whoisText);
|
|
723
|
+
const creationDate = anyValue(map, [
|
|
724
|
+
"creation date",
|
|
725
|
+
"created on",
|
|
726
|
+
"registered on",
|
|
727
|
+
"domain registration date",
|
|
728
|
+
"domain create date",
|
|
729
|
+
"created",
|
|
730
|
+
"registered",
|
|
731
|
+
"domain record activated"
|
|
732
|
+
]);
|
|
733
|
+
const updatedDate = anyValue(map, [
|
|
734
|
+
"updated date",
|
|
735
|
+
"last updated",
|
|
736
|
+
"last modified",
|
|
737
|
+
"modified",
|
|
738
|
+
"domain record last updated"
|
|
739
|
+
]);
|
|
740
|
+
const expirationDate = anyValue(map, [
|
|
741
|
+
"registry expiry date",
|
|
742
|
+
"registry expiration date",
|
|
743
|
+
"expiry date",
|
|
744
|
+
"expiration date",
|
|
745
|
+
"registrar registration expiration date",
|
|
746
|
+
"registrar registration expiry date",
|
|
747
|
+
"registrar expiration date",
|
|
748
|
+
"registrar expiry date",
|
|
749
|
+
"domain expires",
|
|
750
|
+
"paid-till",
|
|
751
|
+
"expires on",
|
|
752
|
+
"renewal date"
|
|
753
|
+
]);
|
|
754
|
+
const registrar = (() => {
|
|
755
|
+
const name = anyValue(map, [
|
|
756
|
+
"registrar",
|
|
757
|
+
"sponsoring registrar",
|
|
758
|
+
"registrar name"
|
|
759
|
+
]);
|
|
760
|
+
const ianaId = anyValue(map, ["registrar iana id", "iana id"]);
|
|
761
|
+
const url = anyValue(map, [
|
|
762
|
+
"registrar url",
|
|
763
|
+
"url of the registrar",
|
|
764
|
+
"referrer"
|
|
765
|
+
]);
|
|
766
|
+
const abuseEmail = anyValue(map, ["registrar abuse contact email", "abuse contact email"]);
|
|
767
|
+
const abusePhone = anyValue(map, ["registrar abuse contact phone", "abuse contact phone"]);
|
|
768
|
+
if (!name && !ianaId && !url && !abuseEmail && !abusePhone) return void 0;
|
|
769
|
+
return {
|
|
770
|
+
name: name || void 0,
|
|
771
|
+
ianaId: ianaId || void 0,
|
|
772
|
+
url: url || void 0,
|
|
773
|
+
email: abuseEmail || void 0,
|
|
774
|
+
phone: abusePhone || void 0
|
|
775
|
+
};
|
|
776
|
+
})();
|
|
777
|
+
const statusLines = map["domain status"] || map.status || [];
|
|
778
|
+
const statuses = statusLines.length ? statusLines.map((line) => ({
|
|
779
|
+
status: line.split(/\s+/)[0],
|
|
780
|
+
raw: line
|
|
781
|
+
})) : void 0;
|
|
782
|
+
const nsLines = [
|
|
783
|
+
...map["name server"] || [],
|
|
784
|
+
...map.nameserver || [],
|
|
785
|
+
...map["name servers"] || [],
|
|
786
|
+
...map.nserver || []
|
|
787
|
+
];
|
|
788
|
+
const nameservers = nsLines.length ? uniq(nsLines.map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
789
|
+
const parts = line.split(/\s+/);
|
|
790
|
+
const host = parts.shift()?.toLowerCase() || "";
|
|
791
|
+
const ipv4 = [];
|
|
792
|
+
const ipv6 = [];
|
|
793
|
+
for (const p of parts) if (/^\d+\.\d+\.\d+\.\d+$/.test(p)) ipv4.push(p);
|
|
794
|
+
else if (/^[0-9a-f:]+$/i.test(p)) ipv6.push(p);
|
|
795
|
+
if (!host) return void 0;
|
|
796
|
+
const ns = { host };
|
|
797
|
+
if (ipv4.length) ns.ipv4 = ipv4;
|
|
798
|
+
if (ipv6.length) ns.ipv6 = ipv6;
|
|
799
|
+
return ns;
|
|
800
|
+
}).filter((x) => !!x)) : void 0;
|
|
801
|
+
const contacts = collectContacts(map);
|
|
802
|
+
const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase();
|
|
803
|
+
const dnssec = dnssecRaw ? { enabled: /signed|yes|true/.test(dnssecRaw) } : void 0;
|
|
804
|
+
const transferLock = !!statuses?.some((s) => /transferprohibited/i.test(s.status));
|
|
805
|
+
return {
|
|
806
|
+
domain,
|
|
807
|
+
tld,
|
|
808
|
+
isRegistered: !isWhoisAvailable(whoisText),
|
|
809
|
+
isIDN: /(^|\.)xn--/i.test(domain),
|
|
810
|
+
unicodeName: void 0,
|
|
811
|
+
punycodeName: void 0,
|
|
812
|
+
registry: void 0,
|
|
813
|
+
registrar,
|
|
814
|
+
reseller: anyValue(map, ["reseller"]) || void 0,
|
|
815
|
+
statuses,
|
|
816
|
+
creationDate: toISO(creationDate || void 0),
|
|
817
|
+
updatedDate: toISO(updatedDate || void 0),
|
|
818
|
+
expirationDate: toISO(expirationDate || void 0),
|
|
819
|
+
deletionDate: void 0,
|
|
820
|
+
transferLock,
|
|
821
|
+
dnssec,
|
|
822
|
+
nameservers,
|
|
823
|
+
contacts,
|
|
824
|
+
whoisServer,
|
|
825
|
+
rdapServers: void 0,
|
|
826
|
+
rawRdap: void 0,
|
|
827
|
+
rawWhois: includeRaw ? whoisText : void 0,
|
|
828
|
+
source: "whois",
|
|
829
|
+
fetchedAt: fetchedAtISO,
|
|
830
|
+
warnings: void 0
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
function anyValue(map, keys) {
|
|
834
|
+
for (const k of keys) {
|
|
835
|
+
const v = map[k];
|
|
836
|
+
if (v?.length) return v[0];
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function collectContacts(map) {
|
|
840
|
+
const roles = [
|
|
841
|
+
{
|
|
842
|
+
role: "registrant",
|
|
843
|
+
prefix: "registrant"
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
role: "admin",
|
|
847
|
+
prefix: "admin"
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
role: "tech",
|
|
851
|
+
prefix: "tech"
|
|
852
|
+
},
|
|
853
|
+
{
|
|
854
|
+
role: "billing",
|
|
855
|
+
prefix: "billing"
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
role: "abuse",
|
|
859
|
+
prefix: "abuse"
|
|
860
|
+
}
|
|
861
|
+
];
|
|
862
|
+
const contacts = [];
|
|
863
|
+
for (const r of roles) {
|
|
864
|
+
const name = anyValue(map, [
|
|
865
|
+
`${r.prefix} name`,
|
|
866
|
+
`${r.prefix} contact name`,
|
|
867
|
+
`${r.prefix}`
|
|
868
|
+
]);
|
|
869
|
+
const org = anyValue(map, [`${r.prefix} organization`, `${r.prefix} org`]);
|
|
870
|
+
const email = anyValue(map, [
|
|
871
|
+
`${r.prefix} email`,
|
|
872
|
+
`${r.prefix} contact email`,
|
|
873
|
+
`${r.prefix} e-mail`
|
|
874
|
+
]);
|
|
875
|
+
const phone = anyValue(map, [
|
|
876
|
+
`${r.prefix} phone`,
|
|
877
|
+
`${r.prefix} contact phone`,
|
|
878
|
+
`${r.prefix} telephone`
|
|
879
|
+
]);
|
|
880
|
+
const fax = anyValue(map, [`${r.prefix} fax`, `${r.prefix} facsimile`]);
|
|
881
|
+
const street = multi(map, [`${r.prefix} street`, `${r.prefix} address`]);
|
|
882
|
+
const city = anyValue(map, [`${r.prefix} city`]);
|
|
883
|
+
const state = anyValue(map, [
|
|
884
|
+
`${r.prefix} state`,
|
|
885
|
+
`${r.prefix} province`,
|
|
886
|
+
`${r.prefix} state/province`
|
|
887
|
+
]);
|
|
888
|
+
const postalCode = anyValue(map, [
|
|
889
|
+
`${r.prefix} postal code`,
|
|
890
|
+
`${r.prefix} postcode`,
|
|
891
|
+
`${r.prefix} zip`
|
|
892
|
+
]);
|
|
893
|
+
const country = anyValue(map, [`${r.prefix} country`]);
|
|
894
|
+
if (name || org || email || phone || street?.length) contacts.push({
|
|
895
|
+
type: r.role,
|
|
896
|
+
name: name || void 0,
|
|
897
|
+
organization: org || void 0,
|
|
898
|
+
email: email || void 0,
|
|
899
|
+
phone: phone || void 0,
|
|
900
|
+
fax: fax || void 0,
|
|
901
|
+
street,
|
|
902
|
+
city: city || void 0,
|
|
903
|
+
state: state || void 0,
|
|
904
|
+
postalCode: postalCode || void 0,
|
|
905
|
+
country: country || void 0
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return contacts.length ? contacts : void 0;
|
|
909
|
+
}
|
|
910
|
+
function multi(map, keys) {
|
|
911
|
+
for (const k of keys) {
|
|
912
|
+
const v = map[k];
|
|
913
|
+
if (v?.length) return v;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
//#endregion
|
|
918
|
+
//#region src/whois/referral.ts
|
|
919
|
+
/**
|
|
920
|
+
* Follow registrar WHOIS referrals up to a configured hop limit.
|
|
921
|
+
* Returns the last successful WHOIS response (best-effort; keeps original on failures).
|
|
922
|
+
*/
|
|
923
|
+
async function followWhoisReferrals(initialServer, domain, opts) {
|
|
924
|
+
const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
|
|
925
|
+
let current = await whoisQuery(initialServer, domain, opts);
|
|
926
|
+
if (opts?.followWhoisReferral === false || maxHops === 0) return current;
|
|
927
|
+
const visited = new Set([normalize(current.serverQueried)]);
|
|
928
|
+
let hops = 0;
|
|
929
|
+
while (hops < maxHops) {
|
|
930
|
+
const next = extractWhoisReferral(current.text);
|
|
931
|
+
if (!next) break;
|
|
932
|
+
const normalized = normalize(next);
|
|
933
|
+
if (visited.has(normalized)) break;
|
|
934
|
+
visited.add(normalized);
|
|
935
|
+
try {
|
|
936
|
+
current = await whoisQuery(next, domain, opts);
|
|
937
|
+
} catch {
|
|
938
|
+
break;
|
|
939
|
+
}
|
|
940
|
+
hops += 1;
|
|
941
|
+
}
|
|
942
|
+
return current;
|
|
943
|
+
}
|
|
944
|
+
function normalize(server) {
|
|
945
|
+
return server.replace(/^whois:\/\//i, "").toLowerCase();
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
//#endregion
|
|
949
|
+
//#region src/index.ts
|
|
10
950
|
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (publicSuffix.includes(".") &&
|
|
76
|
-
/no match|not found/i.test(res.text) &&
|
|
77
|
-
opts?.followWhoisReferral !== false) {
|
|
78
|
-
const candidates = [];
|
|
79
|
-
const ps = publicSuffix.toLowerCase();
|
|
80
|
-
// Prefer explicit exceptions when known
|
|
81
|
-
const exception = WHOIS_TLD_EXCEPTIONS[ps];
|
|
82
|
-
if (exception)
|
|
83
|
-
candidates.push(exception);
|
|
84
|
-
for (const server of candidates) {
|
|
85
|
-
try {
|
|
86
|
-
const alt = await whoisQuery(server, domain, opts);
|
|
87
|
-
if (alt.text && !/error/i.test(alt.text)) {
|
|
88
|
-
res = alt;
|
|
89
|
-
break;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
catch {
|
|
93
|
-
// try next
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
const record = normalizeWhois(domain, tld, res.text, res.serverQueried, now, !!opts?.includeRaw);
|
|
98
|
-
return { ok: true, record };
|
|
99
|
-
}
|
|
100
|
-
catch (err) {
|
|
101
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
102
|
-
return { ok: false, error: message };
|
|
103
|
-
}
|
|
951
|
+
* High-level lookup that prefers RDAP and falls back to WHOIS.
|
|
952
|
+
* Ensures a standardized DomainRecord, independent of the source.
|
|
953
|
+
*/
|
|
954
|
+
async function lookupDomain(domain, opts) {
|
|
955
|
+
try {
|
|
956
|
+
if (!isLikelyDomain(domain)) return {
|
|
957
|
+
ok: false,
|
|
958
|
+
error: "Input does not look like a domain"
|
|
959
|
+
};
|
|
960
|
+
const { publicSuffix, tld } = getDomainParts(domain);
|
|
961
|
+
const now = toISO(/* @__PURE__ */ new Date()) ?? (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
962
|
+
if (!opts?.whoisOnly) {
|
|
963
|
+
const bases = await getRdapBaseUrlsForTld(tld, opts);
|
|
964
|
+
const tried = [];
|
|
965
|
+
for (const base of bases) {
|
|
966
|
+
tried.push(base);
|
|
967
|
+
try {
|
|
968
|
+
const { json } = await fetchRdapDomain(domain, base, opts);
|
|
969
|
+
const rdapEnriched = await fetchAndMergeRdapRelated(domain, json, opts);
|
|
970
|
+
return {
|
|
971
|
+
ok: true,
|
|
972
|
+
record: normalizeRdap(domain, tld, rdapEnriched.merged, [...tried, ...rdapEnriched.serversTried], now, !!opts?.includeRaw)
|
|
973
|
+
};
|
|
974
|
+
} catch {}
|
|
975
|
+
}
|
|
976
|
+
if (opts?.rdapOnly) return {
|
|
977
|
+
ok: false,
|
|
978
|
+
error: `RDAP not available or failed for TLD '${tld}'. Many TLDs do not publish RDAP; try WHOIS fallback (omit rdapOnly).`
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
const whoisServer = await ianaWhoisServerForTld(tld, opts);
|
|
982
|
+
if (!whoisServer) {
|
|
983
|
+
const ianaText = await getIanaWhoisTextForTld(tld, opts);
|
|
984
|
+
const regUrl = ianaText ? parseIanaRegistrationInfoUrl(ianaText) : void 0;
|
|
985
|
+
const hint = regUrl ? ` See registration info at ${regUrl}.` : "";
|
|
986
|
+
return {
|
|
987
|
+
ok: false,
|
|
988
|
+
error: `No WHOIS server discovered for TLD '${tld}'. This registry may not publish public WHOIS over port 43.${hint}`
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
const res = await followWhoisReferrals(whoisServer, domain, opts);
|
|
992
|
+
if (publicSuffix.includes(".") && /no match|not found/i.test(res.text) && opts?.followWhoisReferral !== false) {
|
|
993
|
+
const candidates = [];
|
|
994
|
+
const ps = publicSuffix.toLowerCase();
|
|
995
|
+
const exception = WHOIS_TLD_EXCEPTIONS[ps];
|
|
996
|
+
if (exception) candidates.push(exception);
|
|
997
|
+
for (const server of candidates) try {
|
|
998
|
+
const alt = await whoisQuery(server, domain, opts);
|
|
999
|
+
if (alt.text && !/error/i.test(alt.text)) return {
|
|
1000
|
+
ok: true,
|
|
1001
|
+
record: normalizeWhois(domain, tld, alt.text, alt.serverQueried, now, !!opts?.includeRaw)
|
|
1002
|
+
};
|
|
1003
|
+
} catch {}
|
|
1004
|
+
}
|
|
1005
|
+
return {
|
|
1006
|
+
ok: true,
|
|
1007
|
+
record: normalizeWhois(domain, tld, res.text, res.serverQueried, now, !!opts?.includeRaw)
|
|
1008
|
+
};
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
return {
|
|
1011
|
+
ok: false,
|
|
1012
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
104
1015
|
}
|
|
105
1016
|
/** Determine if a domain appears available (not registered).
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return res.record.isRegistered === false;
|
|
1017
|
+
* Performs a lookup and resolves to a boolean. Rejects on lookup error. */
|
|
1018
|
+
async function isAvailable(domain, opts) {
|
|
1019
|
+
const res = await lookupDomain(domain, opts);
|
|
1020
|
+
if (!res.ok || !res.record) throw new Error(res.error || "Lookup failed");
|
|
1021
|
+
return res.record.isRegistered === false;
|
|
112
1022
|
}
|
|
113
1023
|
/** Determine if a domain appears registered.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return res.record.isRegistered === true;
|
|
1024
|
+
* Performs a lookup and resolves to a boolean. Rejects on lookup error. */
|
|
1025
|
+
async function isRegistered(domain, opts) {
|
|
1026
|
+
const res = await lookupDomain(domain, opts);
|
|
1027
|
+
if (!res.ok || !res.record) throw new Error(res.error || "Lookup failed");
|
|
1028
|
+
return res.record.isRegistered === true;
|
|
120
1029
|
}
|
|
1030
|
+
|
|
1031
|
+
//#endregion
|
|
1032
|
+
export { isAvailable, isRegistered, lookupDomain };
|