mirror of
https://github.com/jakejarvis/rdapper.git
synced 2025-10-18 20:14:27 -04:00
Core functionality for domain lookup using RDAP and WHOIS protocols, along with utility functions and tests.
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
32
README.md
32
README.md
@@ -1,2 +1,34 @@
|
||||
# rdapper
|
||||
🤵Domain RDAP/WHOIS fetched and parser for Node
|
||||
rdapper
|
||||
========
|
||||
|
||||
Fetch and parse domain registration data with RDAP-first and WHOIS fallback. Node 18+.
|
||||
|
||||
Install
|
||||
-------
|
||||
|
||||
```bash
|
||||
pnpm add rdapper
|
||||
```
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
```ts
|
||||
import { lookupDomain } from "rdapper";
|
||||
|
||||
const { ok, record, error } = await lookupDomain("example.com", {
|
||||
timeoutMs: 15000,
|
||||
followWhoisReferral: true,
|
||||
});
|
||||
|
||||
if (!ok) throw new Error(error);
|
||||
console.log(record);
|
||||
```
|
||||
|
||||
Notes
|
||||
-----
|
||||
- Uses IANA RDAP bootstrap and RDAP JSON when available; falls back to WHOIS.
|
||||
- Standardized output regardless of source.
|
||||
- No external HTTP client deps; relies on global fetch. WHOIS uses TCP 43.
|
34
biome.json
Normal file
34
biome.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "rdapper",
|
||||
"version": "0.0.0-beta.1",
|
||||
"license": "MIT",
|
||||
"description": "Fetch and parse domain registration data via RDAP with WHOIS fallback.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"test": "pnpm build && node --test dist/**/*.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.4",
|
||||
"@types/node": "^24.5.2",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a"
|
||||
}
|
130
pnpm-lock.yaml
generated
Normal file
130
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,130 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.2.4
|
||||
version: 2.2.4
|
||||
'@types/node':
|
||||
specifier: ^24.5.2
|
||||
version: 24.5.2
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.2
|
||||
|
||||
packages:
|
||||
|
||||
'@biomejs/biome@2.2.4':
|
||||
resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
hasBin: true
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.2.4':
|
||||
resolution: {integrity: sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.2.4':
|
||||
resolution: {integrity: sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.2.4':
|
||||
resolution: {integrity: sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.2.4':
|
||||
resolution: {integrity: sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.2.4':
|
||||
resolution: {integrity: sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.2.4':
|
||||
resolution: {integrity: sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.2.4':
|
||||
resolution: {integrity: sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/cli-win32-x64@2.2.4':
|
||||
resolution: {integrity: sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/node@24.5.2':
|
||||
resolution: {integrity: sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==}
|
||||
|
||||
typescript@5.9.2:
|
||||
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@7.12.0:
|
||||
resolution: {integrity: sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@biomejs/biome@2.2.4':
|
||||
optionalDependencies:
|
||||
'@biomejs/cli-darwin-arm64': 2.2.4
|
||||
'@biomejs/cli-darwin-x64': 2.2.4
|
||||
'@biomejs/cli-linux-arm64': 2.2.4
|
||||
'@biomejs/cli-linux-arm64-musl': 2.2.4
|
||||
'@biomejs/cli-linux-x64': 2.2.4
|
||||
'@biomejs/cli-linux-x64-musl': 2.2.4
|
||||
'@biomejs/cli-win32-arm64': 2.2.4
|
||||
'@biomejs/cli-win32-x64': 2.2.4
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.2.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.2.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.2.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.2.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.2.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64@2.2.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.2.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-x64@2.2.4':
|
||||
optional: true
|
||||
|
||||
'@types/node@24.5.2':
|
||||
dependencies:
|
||||
undici-types: 7.12.0
|
||||
|
||||
typescript@5.9.2: {}
|
||||
|
||||
undici-types@7.12.0: {}
|
1
src/config.ts
Normal file
1
src/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const DEFAULT_TIMEOUT_MS = 15000;
|
2
src/index.ts
Normal file
2
src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./lookup";
|
||||
export * from "./types";
|
20
src/lookup.smoke.test.ts
Normal file
20
src/lookup.smoke.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { lookupDomain } from "./lookup.js";
|
||||
|
||||
// Run only when SMOKE=1 to avoid flakiness and network in CI by default
|
||||
const shouldRun = process.env.SMOKE === "1";
|
||||
|
||||
(shouldRun ? test : test.skip)(
|
||||
"lookupDomain smoke test (example.com)",
|
||||
async () => {
|
||||
const res = await lookupDomain("example.com", {
|
||||
timeoutMs: 12000,
|
||||
followWhoisReferral: true,
|
||||
});
|
||||
assert.equal(res.ok, true, res.error);
|
||||
assert.ok(res.record?.domain);
|
||||
assert.ok(res.record?.tld);
|
||||
assert.ok(res.record?.source === "rdap" || res.record?.source === "whois");
|
||||
},
|
||||
);
|
83
src/lookup.ts
Normal file
83
src/lookup.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { normalizeRdap } from "./normalize-rdap.js";
|
||||
import { normalizeWhois } from "./normalize-whois.js";
|
||||
import { fetchRdapDomain, getRdapBaseUrlsForTld } from "./rdap.js";
|
||||
import type { DomainRecord, LookupOptions, LookupResult } from "./types.js";
|
||||
import { extractTld, isLikelyDomain, toISO } from "./utils.js";
|
||||
import {
|
||||
extractWhoisReferral,
|
||||
ianaWhoisServerForTld,
|
||||
whoisQuery,
|
||||
} from "./whois.js";
|
||||
|
||||
/**
|
||||
* High-level lookup that prefers RDAP and falls back to WHOIS.
|
||||
* Ensures a standardized DomainRecord, independent of the source.
|
||||
*/
|
||||
export async function lookupDomain(
|
||||
domain: string,
|
||||
opts?: LookupOptions,
|
||||
): Promise<LookupResult> {
|
||||
try {
|
||||
if (!isLikelyDomain(domain)) {
|
||||
return { ok: false, error: "Input does not look like a domain" };
|
||||
}
|
||||
const tld = extractTld(domain);
|
||||
const now = toISO(new Date())!;
|
||||
|
||||
// If WHOIS-only, skip RDAP path
|
||||
if (!opts?.whoisOnly) {
|
||||
const bases = await getRdapBaseUrlsForTld(tld, opts);
|
||||
const tried: string[] = [];
|
||||
for (const base of bases) {
|
||||
tried.push(base);
|
||||
try {
|
||||
const { json } = await fetchRdapDomain(domain, base, opts);
|
||||
const record: DomainRecord = normalizeRdap(
|
||||
domain,
|
||||
tld,
|
||||
json,
|
||||
tried,
|
||||
now,
|
||||
);
|
||||
return { ok: true, record };
|
||||
} catch {
|
||||
// try next base
|
||||
}
|
||||
}
|
||||
// Some TLDs are not in bootstrap yet; continue to WHOIS fallback unless rdapOnly
|
||||
if (opts?.rdapOnly) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "RDAP not available or failed for this TLD",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// WHOIS fallback path
|
||||
const whoisServer = await ianaWhoisServerForTld(tld, opts);
|
||||
if (!whoisServer) {
|
||||
return { ok: false, error: "No WHOIS server discovered for TLD" };
|
||||
}
|
||||
let res = await whoisQuery(whoisServer, domain, opts);
|
||||
if (opts?.followWhoisReferral !== false) {
|
||||
const referral = extractWhoisReferral(res.text);
|
||||
if (referral && referral.toLowerCase() !== whoisServer.toLowerCase()) {
|
||||
try {
|
||||
res = await whoisQuery(referral, domain, opts);
|
||||
} catch {
|
||||
// keep original
|
||||
}
|
||||
}
|
||||
}
|
||||
const record: DomainRecord = normalizeWhois(
|
||||
domain,
|
||||
tld,
|
||||
res.text,
|
||||
res.serverQueried,
|
||||
now,
|
||||
);
|
||||
return { ok: true, record };
|
||||
} catch (err: any) {
|
||||
return { ok: false, error: String(err?.message || err) };
|
||||
}
|
||||
}
|
83
src/normalize-rdap.test.ts
Normal file
83
src/normalize-rdap.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { normalizeRdap } from "./normalize-rdap.js";
|
||||
|
||||
test("normalizeRdap maps registrar, contacts, nameservers, events, dnssec", () => {
|
||||
const rdap = {
|
||||
ldhName: "example.com",
|
||||
unicodeName: "example.com",
|
||||
entities: [
|
||||
{
|
||||
roles: ["registrar"],
|
||||
vcardArray: [
|
||||
"vcard",
|
||||
[
|
||||
["fn", {}, "text", "Registrar LLC"],
|
||||
["email", {}, "text", "support@registrar.test"],
|
||||
["tel", {}, "text", "+1.5555555555"],
|
||||
["url", {}, "text", "https://registrar.example"],
|
||||
],
|
||||
],
|
||||
publicIds: [{ type: "IANA Registrar ID", identifier: "9999" }],
|
||||
},
|
||||
{
|
||||
roles: ["registrant"],
|
||||
vcardArray: [
|
||||
"vcard",
|
||||
[
|
||||
["fn", {}, "text", "Alice Registrant"],
|
||||
["email", {}, "text", "alice@example.com"],
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
roles: ["administrative"],
|
||||
vcardArray: ["vcard", [["fn", {}, "text", "Bob Admin"]]],
|
||||
},
|
||||
{
|
||||
roles: ["technical"],
|
||||
vcardArray: ["vcard", [["fn", {}, "text", "Carol Tech"]]],
|
||||
},
|
||||
],
|
||||
nameservers: [
|
||||
{
|
||||
ldhName: "NS1.EXAMPLE.COM",
|
||||
ipAddresses: { v4: ["192.0.2.1"], v6: ["2001:db8::1"] },
|
||||
},
|
||||
{ unicodeName: "ns2.example.com" },
|
||||
],
|
||||
secureDNS: {
|
||||
delegationSigned: true,
|
||||
dsData: [
|
||||
{ keyTag: 12345, algorithm: 13, digestType: 2, digest: "ABCDEF" },
|
||||
],
|
||||
},
|
||||
events: [
|
||||
{ eventAction: "registration", eventDate: "2020-01-02T03:04:05Z" },
|
||||
{ eventAction: "last changed", eventDate: "2021-01-02T03:04:05Z" },
|
||||
{ eventAction: "expiration", eventDate: "2030-01-02T03:04:05Z" },
|
||||
],
|
||||
status: ["clientTransferProhibited"],
|
||||
port43: "whois.example-registrar.test",
|
||||
};
|
||||
const rec = normalizeRdap(
|
||||
"example.com",
|
||||
"com",
|
||||
rdap,
|
||||
["https://rdap.example/"],
|
||||
"2025-01-01T00:00:00Z",
|
||||
);
|
||||
assert.equal(rec.domain, "example.com");
|
||||
assert.equal(rec.tld, "com");
|
||||
assert.equal(rec.registrar?.name, "Registrar LLC");
|
||||
assert.equal(rec.registrar?.ianaId, "9999");
|
||||
assert.ok(rec.contacts && rec.contacts.length >= 3);
|
||||
assert.ok(rec.nameservers && rec.nameservers.length === 2);
|
||||
assert.equal(rec.nameservers?.[0].host, "ns1.example.com");
|
||||
assert.ok(rec.dnssec?.enabled);
|
||||
assert.equal(rec.creationDate, "2020-01-02T03:04:05Z");
|
||||
assert.equal(rec.expirationDate, "2030-01-02T03:04:05Z");
|
||||
assert.equal(rec.transferLock, true);
|
||||
assert.equal(rec.whoisServer, "whois.example-registrar.test");
|
||||
assert.equal(rec.source, "rdap");
|
||||
});
|
240
src/normalize-rdap.ts
Normal file
240
src/normalize-rdap.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type {
|
||||
Contact,
|
||||
DomainRecord,
|
||||
Nameserver,
|
||||
RegistrarInfo,
|
||||
} from "./types.js";
|
||||
import { toISO, uniq } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Convert RDAP JSON into our normalized DomainRecord.
|
||||
* This function is defensive: RDAP servers vary in completeness and field naming.
|
||||
*/
|
||||
export function normalizeRdap(
|
||||
inputDomain: string,
|
||||
tld: string,
|
||||
rdap: any,
|
||||
rdapServersTried: string[],
|
||||
fetchedAtISO: string,
|
||||
): DomainRecord {
|
||||
// Safe helpers for optional fields
|
||||
const get = (obj: any, path: string[]): any =>
|
||||
path.reduce((o, k) => (o && k in o ? o[k] : undefined), obj);
|
||||
|
||||
// Prefer ldhName (punycode) and unicodeName if provided
|
||||
const ldhName: string | undefined =
|
||||
rdap?.ldhName || rdap?.handle || undefined;
|
||||
const unicodeName: string | undefined = rdap?.unicodeName || undefined;
|
||||
|
||||
// Registrar entity can be provided with role "registrar"
|
||||
const registrar: RegistrarInfo | undefined = extractRegistrar(rdap?.entities);
|
||||
|
||||
// Nameservers: normalize host + IPs
|
||||
const nameservers: Nameserver[] | undefined = Array.isArray(rdap?.nameservers)
|
||||
? rdap.nameservers
|
||||
.map((ns: any) => ({
|
||||
host: ns?.ldhName || ns?.unicodeName || "",
|
||||
ipv4: get(ns, ["ipAddresses", "v4"]) || undefined,
|
||||
ipv6: get(ns, ["ipAddresses", "v6"]) || undefined,
|
||||
}))
|
||||
.filter((n: Nameserver) => !!n.host)
|
||||
: undefined;
|
||||
|
||||
// Contacts: RDAP entities include roles like registrant, administrative, technical, billing, abuse
|
||||
const contacts: Contact[] | undefined = extractContacts(rdap?.entities);
|
||||
|
||||
// RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present.
|
||||
const statuses = Array.isArray(rdap?.status)
|
||||
? rdap.status.map((s: string) => ({ status: s, raw: s }))
|
||||
: undefined;
|
||||
|
||||
// Secure DNS info
|
||||
const secureDNS = rdap?.secureDNS;
|
||||
const dnssec = secureDNS
|
||||
? {
|
||||
enabled: !!secureDNS.delegationSigned,
|
||||
dsRecords: Array.isArray(secureDNS.dsData)
|
||||
? secureDNS.dsData.map((d: any) => ({
|
||||
keyTag: d.keyTag,
|
||||
algorithm: d.algorithm,
|
||||
digestType: d.digestType,
|
||||
digest: d.digest,
|
||||
}))
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// RDAP "events" contain timestamps for registration, last changed, expiration, deletion, etc.
|
||||
const events: any[] = Array.isArray(rdap?.events) ? rdap.events : [];
|
||||
const byAction = (action: string) =>
|
||||
events.find(
|
||||
(e) =>
|
||||
typeof e?.eventAction === "string" &&
|
||||
e.eventAction.toLowerCase().includes(action),
|
||||
);
|
||||
const creationDate = toISO(
|
||||
byAction("registration")?.eventDate || rdap?.registrationDate,
|
||||
);
|
||||
const updatedDate = toISO(
|
||||
byAction("last changed")?.eventDate || rdap?.lastChangedDate,
|
||||
);
|
||||
const expirationDate = toISO(
|
||||
byAction("expiration")?.eventDate || rdap?.expirationDate,
|
||||
);
|
||||
const deletionDate = toISO(
|
||||
byAction("deletion")?.eventDate || rdap?.deletionDate,
|
||||
);
|
||||
|
||||
// Derive a simple transfer lock flag from statuses
|
||||
const transferLock = !!statuses?.some((s: { status: string }) =>
|
||||
/transferprohibited/i.test(s.status),
|
||||
);
|
||||
|
||||
// The RDAP document may include "port43" pointer to authoritative WHOIS
|
||||
const whoisServer: string | undefined =
|
||||
typeof rdap?.port43 === "string" ? rdap.port43 : undefined;
|
||||
|
||||
const record: DomainRecord = {
|
||||
domain: unicodeName || ldhName || inputDomain,
|
||||
tld,
|
||||
isIDN: /(^|\.)xn--/i.test(ldhName || inputDomain),
|
||||
unicodeName: unicodeName || undefined,
|
||||
punycodeName: ldhName || undefined,
|
||||
registry: undefined, // RDAP rarely includes a clean registry operator name
|
||||
registrar: registrar,
|
||||
reseller: undefined,
|
||||
statuses: statuses,
|
||||
creationDate,
|
||||
updatedDate,
|
||||
expirationDate,
|
||||
deletionDate,
|
||||
transferLock,
|
||||
dnssec,
|
||||
nameservers: nameservers
|
||||
? uniq(nameservers.map((n) => ({ ...n, host: n.host.toLowerCase() })))
|
||||
: undefined,
|
||||
contacts,
|
||||
whoisServer,
|
||||
rdapServers: rdapServersTried,
|
||||
rawRdap: rdap,
|
||||
rawWhois: undefined,
|
||||
source: "rdap",
|
||||
fetchedAt: fetchedAtISO,
|
||||
warnings: undefined,
|
||||
};
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
function extractRegistrar(
|
||||
entities: any[] | undefined,
|
||||
): RegistrarInfo | undefined {
|
||||
if (!Array.isArray(entities)) return undefined;
|
||||
for (const ent of entities) {
|
||||
const roles: string[] = Array.isArray(ent?.roles) ? ent.roles : [];
|
||||
if (!roles.some((r) => /registrar/i.test(r))) continue;
|
||||
const v = parseVcard(ent?.vcardArray);
|
||||
const ianaId = Array.isArray(ent?.publicIds)
|
||||
? ent.publicIds.find((id: any) => /iana\s*registrar\s*id/i.test(id?.type))
|
||||
?.identifier
|
||||
: undefined;
|
||||
return {
|
||||
name: v.fn || v.org || ent?.handle || undefined,
|
||||
ianaId: ianaId,
|
||||
url: v.url,
|
||||
email: v.email,
|
||||
phone: v.tel,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractContacts(entities: any[] | undefined): Contact[] | undefined {
|
||||
if (!Array.isArray(entities)) return undefined;
|
||||
const out: Contact[] = [];
|
||||
for (const ent of entities) {
|
||||
const roles: string[] = Array.isArray(ent?.roles) ? ent.roles : [];
|
||||
const v = parseVcard(ent?.vcardArray);
|
||||
const type = roles.find((r) =>
|
||||
/registrant|administrative|technical|billing|abuse|reseller/i.test(r),
|
||||
);
|
||||
if (!type) continue;
|
||||
const map: Record<string, Contact["type"]> = {
|
||||
registrant: "registrant",
|
||||
administrative: "admin",
|
||||
technical: "tech",
|
||||
billing: "billing",
|
||||
abuse: "abuse",
|
||||
reseller: "reseller",
|
||||
} as const;
|
||||
const roleKey = (
|
||||
type.toLowerCase() in map ? (map as any)[type.toLowerCase()] : "unknown"
|
||||
) as Contact["type"];
|
||||
out.push({
|
||||
type: roleKey,
|
||||
name: v.fn,
|
||||
organization: v.org,
|
||||
email: v.email,
|
||||
phone: v.tel,
|
||||
fax: v.fax,
|
||||
street: v.street,
|
||||
city: v.locality,
|
||||
state: v.region,
|
||||
postalCode: v.postcode,
|
||||
country: v.country,
|
||||
countryCode: v.countryCode,
|
||||
});
|
||||
}
|
||||
return out.length ? out : undefined;
|
||||
}
|
||||
|
||||
// Parse a minimal subset of vCard 4.0 arrays as used in RDAP "vcardArray" fields
|
||||
function parseVcard(vcardArray: any): Record<string, any> {
|
||||
// vcardArray is typically ["vcard", [["version",{},"text","4.0"], ["fn",{},"text","Example"], ...]]
|
||||
if (
|
||||
!Array.isArray(vcardArray) ||
|
||||
vcardArray[0] !== "vcard" ||
|
||||
!Array.isArray(vcardArray[1])
|
||||
)
|
||||
return {};
|
||||
const entries: any[] = vcardArray[1];
|
||||
const out: Record<string, any> = {};
|
||||
for (const e of entries) {
|
||||
const key = e?.[0];
|
||||
const _valueType = e?.[2];
|
||||
const value = e?.[3];
|
||||
if (!key) continue;
|
||||
switch (String(key).toLowerCase()) {
|
||||
case "fn":
|
||||
out.fn = value;
|
||||
break;
|
||||
case "org":
|
||||
out.org = Array.isArray(value) ? value.join(" ") : value;
|
||||
break;
|
||||
case "email":
|
||||
out.email = value;
|
||||
break;
|
||||
case "tel":
|
||||
out.tel = value;
|
||||
break;
|
||||
case "url":
|
||||
out.url = value;
|
||||
break;
|
||||
case "adr": {
|
||||
// adr value is [postOfficeBox, extendedAddress, street, locality, region, postalCode, country]
|
||||
if (Array.isArray(value)) {
|
||||
out.street = value[2]
|
||||
? String(value[2]).split(/\\n|,\s*/)
|
||||
: undefined;
|
||||
out.locality = value[3];
|
||||
out.region = value[4];
|
||||
out.postcode = value[5];
|
||||
out.country = value[6];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Best effort country code from country name (often omitted). Leaving undefined unless explicitly provided.
|
||||
return out;
|
||||
}
|
141
src/normalize-whois.test.ts
Normal file
141
src/normalize-whois.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { normalizeWhois } from "./normalize-whois.js";
|
||||
|
||||
function _runCase(label: string, tld: string, text: string) {
|
||||
const rec = normalizeWhois(
|
||||
`example.${tld}`,
|
||||
tld,
|
||||
text,
|
||||
"whois.test",
|
||||
"2025-01-01T00:00:00Z",
|
||||
);
|
||||
assert.equal(rec.tld, tld, label);
|
||||
assert.equal(rec.source, "whois");
|
||||
}
|
||||
|
||||
test("WHOIS .de (DENIC-like) nserver lines", () => {
|
||||
const text = `
|
||||
Domain: example.de
|
||||
Nserver: ns1.example.net 192.0.2.1 2001:db8::1
|
||||
Nserver: ns2.example.net
|
||||
Status: connect
|
||||
Changed: 2020-01-02
|
||||
`;
|
||||
const rec = normalizeWhois(
|
||||
"example.de",
|
||||
"de",
|
||||
text,
|
||||
"whois.denic.de",
|
||||
"2025-01-01T00:00:00Z",
|
||||
);
|
||||
assert.ok(rec.nameservers && rec.nameservers.length === 2);
|
||||
assert.equal(rec.nameservers?.[0].host, "ns1.example.net");
|
||||
});
|
||||
|
||||
test("WHOIS .uk Nominet style", () => {
|
||||
const text = `
|
||||
Domain name:
|
||||
example.uk
|
||||
Data validation:
|
||||
Nominet was able to match the registrant's name and address against a 3rd party data source on 01-Jan-2020
|
||||
Registrar:
|
||||
Registrar Ltd [Tag = REGTAG]
|
||||
URL: https://registrar.example
|
||||
Registered on: 01-Jan-2020
|
||||
Expiry date: 01-Jan-2030
|
||||
Last updated: 01-Jan-2021
|
||||
Name servers:
|
||||
ns1.example.net 192.0.2.1
|
||||
ns2.example.net
|
||||
`;
|
||||
const rec = normalizeWhois(
|
||||
"example.uk",
|
||||
"uk",
|
||||
text,
|
||||
"whois.nic.uk",
|
||||
"2025-01-01T00:00:00Z",
|
||||
);
|
||||
assert.ok(rec.nameservers && rec.nameservers.length === 2);
|
||||
assert.ok(rec.creationDate);
|
||||
assert.ok(rec.expirationDate);
|
||||
});
|
||||
|
||||
test("WHOIS .jp JPRS style privacy redacted", () => {
|
||||
const text = `
|
||||
[Domain Name] EXAMPLE.JP
|
||||
[Registrant] (Not Disclosed)
|
||||
[Name Server] ns1.example.jp
|
||||
[Name Server] ns2.example.jp
|
||||
[Created on] 2020/01/02
|
||||
[Expires on] 2030/01/02
|
||||
[Status] Active
|
||||
`;
|
||||
const rec = normalizeWhois(
|
||||
"example.jp",
|
||||
"jp",
|
||||
text,
|
||||
"whois.jprs.jp",
|
||||
"2025-01-01T00:00:00Z",
|
||||
);
|
||||
assert.ok(rec.creationDate);
|
||||
assert.ok(rec.expirationDate);
|
||||
assert.ok(rec.statuses);
|
||||
});
|
||||
|
||||
test("WHOIS .io NIC.IO style", () => {
|
||||
const text = `
|
||||
Domain Name: EXAMPLE.IO
|
||||
Registry Domain ID: D000000000000-IONIC
|
||||
Registrar WHOIS Server: whois.registrar.test
|
||||
Registrar URL: http://www.registrar.test
|
||||
Updated Date: 2021-01-02T03:04:05Z
|
||||
Creation Date: 2020-01-02T03:04:05Z
|
||||
Registry Expiry Date: 2030-01-02T03:04:05Z
|
||||
Registrar: Registrar LLC
|
||||
Name Server: NS1.EXAMPLE.IO
|
||||
Name Server: NS2.EXAMPLE.IO
|
||||
DNSSEC: unsigned
|
||||
`;
|
||||
const rec = normalizeWhois(
|
||||
"example.io",
|
||||
"io",
|
||||
text,
|
||||
"whois.nic.io",
|
||||
"2025-01-01T00:00:00Z",
|
||||
);
|
||||
assert.ok(rec.creationDate);
|
||||
assert.ok(rec.expirationDate);
|
||||
assert.ok(rec.nameservers && rec.nameservers.length === 2);
|
||||
});
|
||||
|
||||
test("Privacy redacted WHOIS normalizes without contacts", () => {
|
||||
const text = `
|
||||
Domain Name: EXAMPLE.COM
|
||||
Registry Domain ID: 0000000000_DOMAIN_COM-VRSN
|
||||
Registrar WHOIS Server: whois.registrar.test
|
||||
Registrar URL: http://www.registrar.test
|
||||
Updated Date: 2021-01-02T03:04:05Z
|
||||
Creation Date: 2020-01-02T03:04:05Z
|
||||
Registry Expiry Date: 2030-01-02T03:04:05Z
|
||||
Registrar: Registrar LLC
|
||||
Registrant Organization: Privacy Protect, LLC
|
||||
Registrant State/Province: CA
|
||||
Registrant Country: US
|
||||
Registrant Email: Please query the RDDS service of the Registrar of Record identified in this output for information on how to contact the Registrant, Admin, or Tech contact of the queried domain name.
|
||||
Name Server: NS1.EXAMPLE.COM
|
||||
Name Server: NS2.EXAMPLE.COM
|
||||
DNSSEC: unsigned
|
||||
Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited
|
||||
`;
|
||||
const rec = normalizeWhois(
|
||||
"example.com",
|
||||
"com",
|
||||
text,
|
||||
"whois.verisign-grs.com",
|
||||
"2025-01-01T00:00:00Z",
|
||||
);
|
||||
assert.ok(rec.creationDate);
|
||||
assert.ok(rec.expirationDate);
|
||||
assert.equal(rec.source, "whois");
|
||||
});
|
239
src/normalize-whois.ts
Normal file
239
src/normalize-whois.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type {
|
||||
Contact,
|
||||
DomainRecord,
|
||||
Nameserver,
|
||||
RegistrarInfo,
|
||||
} from "./types.js";
|
||||
import { parseKeyValueLines, toISO, uniq } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Convert raw WHOIS text into our normalized DomainRecord.
|
||||
* Heuristics cover many gTLD and ccTLD formats; exact fields vary per registry.
|
||||
*/
|
||||
export function normalizeWhois(
|
||||
domain: string,
|
||||
tld: string,
|
||||
whoisText: string,
|
||||
whoisServer: string | undefined,
|
||||
fetchedAtISO: string,
|
||||
): DomainRecord {
|
||||
const map = parseKeyValueLines(whoisText);
|
||||
|
||||
// Date extraction across common synonyms
|
||||
const creationDate = anyValue(map, [
|
||||
"creation date",
|
||||
"created on",
|
||||
"registered on",
|
||||
"domain registration date",
|
||||
"domain create date",
|
||||
"created",
|
||||
"registered",
|
||||
]);
|
||||
const updatedDate = anyValue(map, [
|
||||
"updated date",
|
||||
"last updated",
|
||||
"last modified",
|
||||
"modified",
|
||||
]);
|
||||
const expirationDate = anyValue(map, [
|
||||
"registry expiry date",
|
||||
"expiry date",
|
||||
"expiration date",
|
||||
"paid-till",
|
||||
"expires on",
|
||||
"renewal date",
|
||||
]);
|
||||
|
||||
// Registrar info (thin registries like .com/.net require referral follow for full data)
|
||||
const registrar: RegistrarInfo | undefined = (() => {
|
||||
const name = anyValue(map, [
|
||||
"registrar",
|
||||
"sponsoring registrar",
|
||||
"registrar name",
|
||||
]);
|
||||
const ianaId = anyValue(map, ["registrar iana id", "iana id"]);
|
||||
const url = anyValue(map, [
|
||||
"registrar url",
|
||||
"url of the registrar",
|
||||
"referrer",
|
||||
]);
|
||||
const abuseEmail = anyValue(map, [
|
||||
"registrar abuse contact email",
|
||||
"abuse contact email",
|
||||
]);
|
||||
const abusePhone = anyValue(map, [
|
||||
"registrar abuse contact phone",
|
||||
"abuse contact phone",
|
||||
]);
|
||||
if (!name && !ianaId && !url && !abuseEmail && !abusePhone)
|
||||
return undefined;
|
||||
return {
|
||||
name: name || undefined,
|
||||
ianaId: ianaId || undefined,
|
||||
url: url || undefined,
|
||||
email: abuseEmail || undefined,
|
||||
phone: abusePhone || undefined,
|
||||
};
|
||||
})();
|
||||
|
||||
// Statuses: multiple entries are expected; keep raw
|
||||
const statusLines = map["domain status"] || map.status || [];
|
||||
const statuses = statusLines.length
|
||||
? statusLines.map((line) => ({ status: line.split(/\s+/)[0], raw: line }))
|
||||
: undefined;
|
||||
|
||||
// Nameservers: also appear as "nserver" on some ccTLDs (.de, .ru) and as "name server"
|
||||
const nsLines: string[] = [
|
||||
...(map["name server"] || []),
|
||||
...(map.nameserver || []),
|
||||
...(map["name servers"] || []),
|
||||
...(map.nserver || []),
|
||||
];
|
||||
const nameservers: Nameserver[] | undefined = nsLines.length
|
||||
? (uniq(
|
||||
nsLines
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
// Common formats: "ns1.example.com" or "ns1.example.com 192.0.2.1" or "ns1.example.com 2001:db8::1"
|
||||
const parts = line.split(/\s+/);
|
||||
const host = parts.shift()?.toLowerCase() || "";
|
||||
const ipv4: string[] = [];
|
||||
const ipv6: string[] = [];
|
||||
for (const p of parts) {
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(p)) ipv4.push(p);
|
||||
else if (/^[0-9a-f:]+$/i.test(p)) ipv6.push(p);
|
||||
}
|
||||
if (!host) return undefined;
|
||||
const ns: Nameserver = { host };
|
||||
if (ipv4.length) ns.ipv4 = ipv4;
|
||||
if (ipv6.length) ns.ipv6 = ipv6;
|
||||
return ns;
|
||||
})
|
||||
.filter((x): x is Nameserver => !!x),
|
||||
) as Nameserver[])
|
||||
: undefined;
|
||||
|
||||
// Contacts: best-effort parse common keys
|
||||
const contacts = collectContacts(map);
|
||||
|
||||
const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase();
|
||||
const dnssec = dnssecRaw
|
||||
? { enabled: /signed|yes|true/.test(dnssecRaw) }
|
||||
: undefined;
|
||||
|
||||
// Simple lock derivation from statuses
|
||||
const transferLock = !!statuses?.some((s) =>
|
||||
/transferprohibited/i.test(s.status),
|
||||
);
|
||||
|
||||
const record: DomainRecord = {
|
||||
domain,
|
||||
tld,
|
||||
isIDN: /(^|\.)xn--/i.test(domain),
|
||||
unicodeName: undefined,
|
||||
punycodeName: undefined,
|
||||
registry: undefined,
|
||||
registrar,
|
||||
reseller: anyValue(map, ["reseller"]) || undefined,
|
||||
statuses,
|
||||
creationDate: toISO(creationDate || undefined),
|
||||
updatedDate: toISO(updatedDate || undefined),
|
||||
expirationDate: toISO(expirationDate || undefined),
|
||||
deletionDate: undefined,
|
||||
transferLock,
|
||||
dnssec,
|
||||
nameservers,
|
||||
contacts,
|
||||
whoisServer,
|
||||
rdapServers: undefined,
|
||||
rawRdap: undefined,
|
||||
rawWhois: whoisText,
|
||||
source: "whois",
|
||||
fetchedAt: fetchedAtISO,
|
||||
warnings: undefined,
|
||||
};
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
function anyValue(
|
||||
map: Record<string, string[]>,
|
||||
keys: string[],
|
||||
): string | undefined {
|
||||
for (const k of keys) {
|
||||
const v = map[k];
|
||||
if (v?.length) return v[0];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
|
||||
const roles: Array<{ role: Contact["type"]; prefix: string }> = [
|
||||
{ role: "registrant", prefix: "registrant" },
|
||||
{ role: "admin", prefix: "admin" },
|
||||
{ role: "tech", prefix: "tech" },
|
||||
{ role: "billing", prefix: "billing" },
|
||||
{ role: "abuse", prefix: "abuse" },
|
||||
];
|
||||
const contacts: Contact[] = [];
|
||||
for (const r of roles) {
|
||||
const name = anyValue(map, [
|
||||
`${r.prefix} name`,
|
||||
`${r.prefix} contact name`,
|
||||
`${r.prefix}`,
|
||||
]);
|
||||
const org = anyValue(map, [`${r.prefix} organization`, `${r.prefix} org`]);
|
||||
const email = anyValue(map, [
|
||||
`${r.prefix} email`,
|
||||
`${r.prefix} contact email`,
|
||||
`${r.prefix} e-mail`,
|
||||
]);
|
||||
const phone = anyValue(map, [
|
||||
`${r.prefix} phone`,
|
||||
`${r.prefix} contact phone`,
|
||||
`${r.prefix} telephone`,
|
||||
]);
|
||||
const fax = anyValue(map, [`${r.prefix} fax`, `${r.prefix} facsimile`]);
|
||||
const street = multi(map, [`${r.prefix} street`, `${r.prefix} address`]);
|
||||
const city = anyValue(map, [`${r.prefix} city`]);
|
||||
const state = anyValue(map, [
|
||||
`${r.prefix} state`,
|
||||
`${r.prefix} province`,
|
||||
`${r.prefix} state/province`,
|
||||
]);
|
||||
const postalCode = anyValue(map, [
|
||||
`${r.prefix} postal code`,
|
||||
`${r.prefix} postcode`,
|
||||
`${r.prefix} zip`,
|
||||
]);
|
||||
const country = anyValue(map, [`${r.prefix} country`]);
|
||||
if (name || org || email || phone || street?.length) {
|
||||
contacts.push({
|
||||
type: r.role,
|
||||
name: name || undefined,
|
||||
organization: org || undefined,
|
||||
email: email || undefined,
|
||||
phone: phone || undefined,
|
||||
fax: fax || undefined,
|
||||
street: street,
|
||||
city: city || undefined,
|
||||
state: state || undefined,
|
||||
postalCode: postalCode || undefined,
|
||||
country: country || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return contacts.length ? contacts : undefined;
|
||||
}
|
||||
|
||||
function multi(
|
||||
map: Record<string, string[]>,
|
||||
keys: string[],
|
||||
): string[] | undefined {
|
||||
for (const k of keys) {
|
||||
const v = map[k];
|
||||
if (v?.length) return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
80
src/rdap.ts
Normal file
80
src/rdap.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { DEFAULT_TIMEOUT_MS } from "./config.js";
|
||||
import type { LookupOptions } from "./types.js";
|
||||
import { withTimeout } from "./utils.js";
|
||||
|
||||
// Use global fetch (Node 18+). For large JSON we keep it simple.
|
||||
|
||||
// RDAP bootstrap JSON format as published by IANA
|
||||
interface BootstrapData {
|
||||
version: string;
|
||||
publication: string;
|
||||
description?: string;
|
||||
// Each service entry is [[tld1, tld2, ...], [baseUrl1, baseUrl2, ...]]
|
||||
services: string[][][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve RDAP base URLs for a given TLD using IANA's bootstrap registry.
|
||||
* Returns zero or more base URLs (always suffixed with a trailing slash).
|
||||
*/
|
||||
export async function getRdapBaseUrlsForTld(
|
||||
tld: string,
|
||||
options?: LookupOptions,
|
||||
): Promise<string[]> {
|
||||
const bootstrapUrl =
|
||||
options?.customBootstrapUrl ?? "https://data.iana.org/rdap/dns.json";
|
||||
const res = await withTimeout(
|
||||
fetch(bootstrapUrl, {
|
||||
method: "GET",
|
||||
headers: { accept: "application/json" },
|
||||
signal: options?.signal,
|
||||
}),
|
||||
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
"RDAP bootstrap timeout",
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as BootstrapData;
|
||||
const target = tld.toLowerCase();
|
||||
const bases: string[] = [];
|
||||
for (const svc of data.services) {
|
||||
const tlds = svc[0];
|
||||
const urls = svc[1];
|
||||
if (tlds.map((x) => x.toLowerCase()).includes(target)) {
|
||||
for (const u of urls) {
|
||||
const base = u.endsWith("/") ? u : `${u}/`;
|
||||
bases.push(base);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(bases));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch RDAP JSON for a domain from a specific RDAP base URL.
|
||||
* Throws on HTTP >= 400 (includes RDAP error JSON payloads).
|
||||
*/
|
||||
export async function fetchRdapDomain(
|
||||
domain: string,
|
||||
baseUrl: string,
|
||||
options?: LookupOptions,
|
||||
): Promise<{ url: string; json: any }> {
|
||||
const url = new URL(
|
||||
`domain/${encodeURIComponent(domain)}`,
|
||||
baseUrl,
|
||||
).toString();
|
||||
const res = await withTimeout(
|
||||
fetch(url, {
|
||||
method: "GET",
|
||||
headers: { accept: "application/rdap+json, application/json" },
|
||||
signal: options?.signal,
|
||||
}),
|
||||
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
"RDAP lookup timeout",
|
||||
);
|
||||
if (!res.ok) {
|
||||
const bodyText = await res.text();
|
||||
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
return { url, json };
|
||||
}
|
99
src/types.d.ts
vendored
Normal file
99
src/types.d.ts
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
export type LookupSource = "rdap" | "whois";
|
||||
|
||||
export interface RegistrarInfo {
|
||||
name?: string;
|
||||
ianaId?: string;
|
||||
url?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
type:
|
||||
| "registrant"
|
||||
| "admin"
|
||||
| "tech"
|
||||
| "billing"
|
||||
| "abuse"
|
||||
| "registrar"
|
||||
| "reseller"
|
||||
| "unknown";
|
||||
name?: string;
|
||||
organization?: string;
|
||||
email?: string | string[];
|
||||
phone?: string | string[];
|
||||
fax?: string | string[];
|
||||
street?: string[];
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
export interface Nameserver {
|
||||
host: string;
|
||||
ipv4?: string[];
|
||||
ipv6?: string[];
|
||||
}
|
||||
|
||||
export interface StatusEvent {
|
||||
status: string;
|
||||
description?: string;
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
export interface DomainRecord {
|
||||
domain: string;
|
||||
tld: string;
|
||||
isIDN?: boolean;
|
||||
unicodeName?: string;
|
||||
punycodeName?: string;
|
||||
registry?: string; // Registry operator if available
|
||||
registrar?: RegistrarInfo;
|
||||
reseller?: string;
|
||||
statuses?: StatusEvent[]; // EPP statuses
|
||||
creationDate?: string; // ISO 8601
|
||||
updatedDate?: string; // ISO 8601
|
||||
expirationDate?: string; // ISO 8601
|
||||
deletionDate?: string; // ISO 8601
|
||||
transferLock?: boolean;
|
||||
dnssec?: {
|
||||
enabled: boolean;
|
||||
dsRecords?: Array<{
|
||||
keyTag?: number;
|
||||
algorithm?: number;
|
||||
digestType?: number;
|
||||
digest?: string;
|
||||
}>;
|
||||
};
|
||||
nameservers?: Nameserver[];
|
||||
contacts?: Contact[];
|
||||
whoisServer?: string; // authoritative WHOIS queried (if any)
|
||||
rdapServers?: string[]; // RDAP base URLs tried
|
||||
rawRdap?: any; // raw RDAP JSON
|
||||
rawWhois?: string; // raw WHOIS text (last authoritative)
|
||||
source: LookupSource; // which source produced data
|
||||
fetchedAt: string; // ISO 8601
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface LookupOptions {
|
||||
timeoutMs?: number; // total timeout budget
|
||||
rdapOnly?: boolean; // don't fall back to WHOIS
|
||||
whoisOnly?: boolean; // don't attempt RDAP
|
||||
followWhoisReferral?: boolean; // follow referral server (default true)
|
||||
customBootstrapUrl?: string; // override IANA bootstrap
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface LookupResult {
|
||||
ok: boolean;
|
||||
record?: DomainRecord;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type FetchLike = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
30
src/utils.test.ts
Normal file
30
src/utils.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { extractTld, isLikelyDomain, toISO } from "./utils.js";
|
||||
|
||||
test("toISO parses ISO and common whois formats", () => {
|
||||
const iso = toISO("2023-01-02T03:04:05Z");
|
||||
assert.equal(iso, "2023-01-02T03:04:05Z");
|
||||
|
||||
const noZ = toISO("2023-01-02 03:04:05");
|
||||
assert.match(noZ!, /^2023-01-02T03:04:05Z$/);
|
||||
|
||||
const slash = toISO("2023/01/02 03:04:05");
|
||||
assert.match(slash!, /^2023-01-02T03:04:05Z$/);
|
||||
|
||||
const dmy = toISO("02-Jan-2023");
|
||||
assert.equal(dmy, "2023-01-02T00:00:00Z");
|
||||
|
||||
const mdy = toISO("Jan 02 2023");
|
||||
assert.equal(mdy, "2023-01-02T00:00:00Z");
|
||||
});
|
||||
|
||||
test("extractTld basic", () => {
|
||||
assert.equal(extractTld("example.com"), "com");
|
||||
assert.equal(extractTld("sub.example.co.uk"), "uk");
|
||||
});
|
||||
|
||||
test("isLikelyDomain", () => {
|
||||
assert.equal(isLikelyDomain("example.com"), true);
|
||||
assert.equal(isLikelyDomain("not a domain"), false);
|
||||
});
|
193
src/utils.ts
Normal file
193
src/utils.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// Lightweight date parsing helpers to avoid external dependencies.
|
||||
// We aim to parse common RDAP and WHOIS date representations and return a UTC ISO string.
|
||||
export function toISO(
|
||||
dateLike: string | number | Date | undefined | null,
|
||||
): string | undefined {
|
||||
if (dateLike == null) return undefined;
|
||||
if (dateLike instanceof Date) return toIsoFromDate(dateLike);
|
||||
if (typeof dateLike === "number") return toIsoFromDate(new Date(dateLike));
|
||||
const raw = String(dateLike).trim();
|
||||
if (!raw) return undefined;
|
||||
// Try several structured formats seen in WHOIS outputs (treat as UTC when no TZ provided)
|
||||
const tryFormats = [
|
||||
// 2023-01-02 03:04:05Z or without Z
|
||||
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z)?$/,
|
||||
// 2023/01/02 03:04:05
|
||||
/^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/,
|
||||
// 02-Jan-2023
|
||||
/^(\d{2})-([A-Za-z]{3})-(\d{4})$/,
|
||||
// Jan 02 2023
|
||||
/^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/,
|
||||
];
|
||||
for (const re of tryFormats) {
|
||||
const m = raw.match(re);
|
||||
if (!m) continue;
|
||||
const d = parseWithRegex(m, re);
|
||||
if (d) return toIsoFromDate(d);
|
||||
}
|
||||
// Fallback to native Date parsing (handles ISO and RFC2822 with TZ)
|
||||
const native = new Date(raw);
|
||||
if (!Number.isNaN(native.getTime())) return toIsoFromDate(native);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toIsoFromDate(d: Date): string | undefined {
|
||||
try {
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
d.getUTCFullYear(),
|
||||
d.getUTCMonth(),
|
||||
d.getUTCDate(),
|
||||
d.getUTCHours(),
|
||||
d.getUTCMinutes(),
|
||||
d.getUTCSeconds(),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.toISOString()
|
||||
.replace(/\.\d{3}Z$/, "Z");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseWithRegex(m: RegExpMatchArray, _re: RegExp): Date | undefined {
|
||||
const monthMap: Record<string, number> = {
|
||||
jan: 0,
|
||||
feb: 1,
|
||||
mar: 2,
|
||||
apr: 3,
|
||||
may: 4,
|
||||
jun: 5,
|
||||
jul: 6,
|
||||
aug: 7,
|
||||
sep: 8,
|
||||
oct: 9,
|
||||
nov: 10,
|
||||
dec: 11,
|
||||
};
|
||||
try {
|
||||
// If the matched string contains time components, parse as Y-M-D H:M:S
|
||||
if (m[0].includes(":")) {
|
||||
const [_, y, mo, d, hh, mm, ss] = m;
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
Number(y),
|
||||
Number(mo) - 1,
|
||||
Number(d),
|
||||
Number(hh),
|
||||
Number(mm),
|
||||
Number(ss),
|
||||
),
|
||||
);
|
||||
}
|
||||
// If the matched string contains hyphens, treat as DD-MMM-YYYY
|
||||
if (m[0].includes("-")) {
|
||||
const [_, dd, monStr, yyyy] = m;
|
||||
const mon = monthMap[monStr.toLowerCase()];
|
||||
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
|
||||
}
|
||||
// Otherwise treat as MMM DD YYYY
|
||||
const [_, monStr, dd, yyyy] = m;
|
||||
const mon = monthMap[monStr.toLowerCase()];
|
||||
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function uniq<T>(arr: T[] | undefined | null): T[] | undefined {
|
||||
if (!arr) return undefined;
|
||||
return Array.from(new Set(arr));
|
||||
}
|
||||
|
||||
export function parseKeyValueLines(text: string): Record<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
const lines = text.split(/\r?\n/);
|
||||
let lastKey: string | undefined;
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\s+$/, "");
|
||||
if (!line.trim()) continue;
|
||||
// Bracketed form: [Key] value (common in .jp and some ccTLDs)
|
||||
const bracket = line.match(/^\s*\[([^\]]+)\]\s*(.*)$/);
|
||||
if (bracket) {
|
||||
const key = bracket[1].trim().toLowerCase();
|
||||
const value = bracket[2].trim();
|
||||
const list = map.get(key) ?? [];
|
||||
if (value) list.push(value);
|
||||
map.set(key, list);
|
||||
lastKey = key;
|
||||
continue;
|
||||
}
|
||||
// Colon form: Key: value
|
||||
const idx = line.indexOf(":");
|
||||
if (idx !== -1) {
|
||||
const key = line.slice(0, idx).trim().toLowerCase();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
if (!key) {
|
||||
lastKey = undefined;
|
||||
continue;
|
||||
}
|
||||
const list = map.get(key) ?? [];
|
||||
if (value) list.push(value);
|
||||
map.set(key, list);
|
||||
lastKey = key;
|
||||
continue;
|
||||
}
|
||||
// Continuation line: starts with indentation after a key appeared
|
||||
if (lastKey && /^\s+/.test(line)) {
|
||||
const value = line.trim();
|
||||
if (value) {
|
||||
const list = map.get(lastKey) ?? [];
|
||||
list.push(value);
|
||||
map.set(lastKey, list);
|
||||
}
|
||||
}
|
||||
// Otherwise ignore non key-value lines
|
||||
}
|
||||
return Object.fromEntries(map);
|
||||
}
|
||||
|
||||
export function parseCsv(value: string | undefined): string[] | undefined {
|
||||
if (!value) return undefined;
|
||||
return value
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function punyToUnicode(domain: string): string {
|
||||
try {
|
||||
return domain.normalize("NFC");
|
||||
} catch {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
|
||||
export function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
reason = "Timeout",
|
||||
): Promise<T> {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
|
||||
let timer: any;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(reason)), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise.finally(() => clearTimeout(timer)), timeout]);
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function extractTld(domain: string): string {
|
||||
const lower = domain.trim().toLowerCase();
|
||||
const parts = lower.split(".").filter(Boolean);
|
||||
return parts[parts.length - 1] ?? lower;
|
||||
}
|
||||
|
||||
export function isLikelyDomain(input: string): boolean {
|
||||
return /^[a-z0-9.-]+$/i.test(input) && input.includes(".");
|
||||
}
|
108
src/whois.ts
Normal file
108
src/whois.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createConnection } from "node:net";
|
||||
import { DEFAULT_TIMEOUT_MS } from "./config.js";
|
||||
import type { LookupOptions } from "./types.js";
|
||||
import { withTimeout } from "./utils.js";
|
||||
|
||||
export interface WhoisQueryResult {
|
||||
serverQueried: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a WHOIS query against an RFC 3912 server over TCP 43.
|
||||
* Returns the raw text and the server used.
|
||||
*/
|
||||
export async function whoisQuery(
|
||||
server: string,
|
||||
query: string,
|
||||
options?: LookupOptions,
|
||||
): Promise<WhoisQueryResult> {
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const port = 43;
|
||||
const host = server.replace(/^whois:\/\//i, "");
|
||||
const text = await withTimeout(
|
||||
queryTcp(host, port, query, options),
|
||||
timeoutMs,
|
||||
"WHOIS timeout",
|
||||
);
|
||||
return { serverQueried: server, text };
|
||||
}
|
||||
|
||||
// Low-level WHOIS TCP client. Some registries require CRLF after the domain query.
|
||||
function queryTcp(
|
||||
host: string,
|
||||
port: number,
|
||||
query: string,
|
||||
options?: LookupOptions,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createConnection({ host, port });
|
||||
let data = "";
|
||||
let done = false;
|
||||
const cleanup = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
socket.destroy();
|
||||
};
|
||||
socket.setTimeout((options?.timeoutMs ?? DEFAULT_TIMEOUT_MS) - 1000, () => {
|
||||
cleanup();
|
||||
reject(new Error("WHOIS socket timeout"));
|
||||
});
|
||||
socket.on("error", (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
socket.on("data", (chunk) => {
|
||||
data += chunk.toString("utf8");
|
||||
});
|
||||
socket.on("end", () => {
|
||||
cleanup();
|
||||
resolve(data);
|
||||
});
|
||||
socket.on("connect", () => {
|
||||
socket.write(`${query}\r\n`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort discovery of the authoritative WHOIS server for a TLD via IANA root DB.
|
||||
*/
|
||||
export async function ianaWhoisServerForTld(
|
||||
tld: string,
|
||||
options?: LookupOptions,
|
||||
): Promise<string | undefined> {
|
||||
const url = `https://www.iana.org/domains/root/db/${encodeURIComponent(tld)}.html`;
|
||||
try {
|
||||
const res = await withTimeout(
|
||||
fetch(url, { method: "GET" }),
|
||||
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
if (!res.ok) return undefined;
|
||||
const html = await res.text();
|
||||
const m =
|
||||
html.match(/Whois Server:\s*<a[^>]*>([^<]+)<\/a>/i) ||
|
||||
html.match(/Whois Server:\s*([^<\n]+)/i);
|
||||
const server = m?.[1]?.trim();
|
||||
if (!server) return undefined;
|
||||
return server.replace(/^https?:\/\//i, "").replace(/\/$/, "");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract registrar referral WHOIS server from a WHOIS response, if present.
|
||||
*/
|
||||
export function extractWhoisReferral(text: string): string | undefined {
|
||||
const patterns = [
|
||||
/^Registrar WHOIS Server:\s*(.+)$/im,
|
||||
/^Whois Server:\s*(.+)$/im,
|
||||
/^ReferralServer:\s*whois:\/\/(.+)$/im,
|
||||
];
|
||||
for (const re of patterns) {
|
||||
const m = text.match(re);
|
||||
if (m?.[1]) return m[1].trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/config.ts","./src/index.ts","./src/lookup.smoke.test.ts","./src/lookup.ts","./src/normalize-rdap.test.ts","./src/normalize-rdap.ts","./src/normalize-whois.test.ts","./src/normalize-whois.ts","./src/rdap.ts","./src/types.d.ts","./src/utils.test.ts","./src/utils.ts","./src/whois.ts"],"version":"5.9.2"}
|
Reference in New Issue
Block a user