mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 01:25:31 -04:00
Enhance project structure: add AGENTS.md for repository guidelines, update .gitignore to exclude TypeScript build info, and refine package.json scripts for improved build and publish processes.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { normalizeWhois } from "../normalize.js";
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Centralized WHOIS data catalog.
|
||||
// - tldExceptions: curated non-standard authoritative WHOIS servers by TLD
|
||||
// - centralnicZones: known CentralNic-operated second-level public suffix zones
|
||||
|
||||
export const WHOIS_CATALOG = {
|
||||
tldExceptions: {
|
||||
// gTLDs
|
||||
com: "whois.verisign-grs.com",
|
||||
net: "whois.verisign-grs.com",
|
||||
org: "whois.pir.org",
|
||||
info: "whois.afilias.net",
|
||||
biz: "whois.nic.biz",
|
||||
edu: "whois.educause.edu",
|
||||
gov: "whois.dotgov.gov",
|
||||
|
||||
// ccTLDs and others
|
||||
de: "whois.denic.de",
|
||||
jp: "whois.jprs.jp",
|
||||
fr: "whois.nic.fr",
|
||||
it: "whois.nic.it",
|
||||
pl: "whois.dns.pl",
|
||||
nl: "whois.domain-registry.nl",
|
||||
be: "whois.dns.be",
|
||||
se: "whois.iis.se",
|
||||
no: "whois.norid.no",
|
||||
fi: "whois.fi",
|
||||
cz: "whois.nic.cz",
|
||||
es: "whois.nic.es",
|
||||
br: "whois.registro.br",
|
||||
ca: "whois.cira.ca",
|
||||
dk: "whois.dk-hostmaster.dk",
|
||||
hk: "whois.hkirc.hk",
|
||||
sg: "whois.sgnic.sg",
|
||||
in: "whois.registry.in",
|
||||
nz: "whois.srs.net.nz",
|
||||
ch: "whois.nic.ch",
|
||||
li: "whois.nic.li",
|
||||
io: "whois.nic.io",
|
||||
ai: "whois.nic.ai",
|
||||
ru: "whois.tcinet.ru",
|
||||
su: "whois.tcinet.ru",
|
||||
"xn--p1ai": "whois.tcinet.ru", // .рф
|
||||
|
||||
// CentralNic-operated second-level zones (treat as exceptions here for simplicity)
|
||||
"uk.com": "whois.centralnic.com",
|
||||
"uk.net": "whois.centralnic.com",
|
||||
"gb.com": "whois.centralnic.com",
|
||||
"gb.net": "whois.centralnic.com",
|
||||
"eu.com": "whois.centralnic.com",
|
||||
"us.com": "whois.centralnic.com",
|
||||
"se.com": "whois.centralnic.com",
|
||||
"de.com": "whois.centralnic.com",
|
||||
"br.com": "whois.centralnic.com",
|
||||
"ru.com": "whois.centralnic.com",
|
||||
"cn.com": "whois.centralnic.com",
|
||||
"sa.com": "whois.centralnic.com",
|
||||
} as Record<string, string>,
|
||||
} as const;
|
||||
|
||||
export const WHOIS_TLD_EXCEPTIONS = WHOIS_CATALOG.tldExceptions;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createConnection } from "node:net";
|
||||
import { DEFAULT_TIMEOUT_MS } from "../config.js";
|
||||
import { withTimeout } from "../lib/async.js";
|
||||
import type { LookupOptions } from "../types.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`);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { LookupOptions } from "../types.js";
|
||||
import { WHOIS_TLD_EXCEPTIONS } from "./catalog.js";
|
||||
import { whoisQuery } from "./client.js";
|
||||
|
||||
/**
|
||||
* 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 key = tld.toLowerCase();
|
||||
// 1) Explicit hint override
|
||||
const hint = options?.whoisHints?.[key];
|
||||
if (hint) return normalizeServer(hint);
|
||||
|
||||
// 2) IANA WHOIS authoritative discovery over TCP 43
|
||||
try {
|
||||
const res = await whoisQuery("whois.iana.org", key, options);
|
||||
const txt = res.text;
|
||||
const m =
|
||||
txt.match(/^whois:\s*(\S+)/im) ||
|
||||
txt.match(/^refer:\s*(\S+)/im) ||
|
||||
txt.match(/^whois server:\s*(\S+)/im);
|
||||
const server = m?.[1];
|
||||
if (server) return normalizeServer(server);
|
||||
} catch {
|
||||
// fallthrough to exceptions/guess
|
||||
}
|
||||
|
||||
// 3) Curated exceptions
|
||||
const exception = WHOIS_TLD_EXCEPTIONS[key];
|
||||
if (exception) return normalizeServer(exception);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function normalizeServer(server: string): string {
|
||||
return server.replace(/^whois:\/\//i, "").replace(/\/$/, "");
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { toISO } from "../lib/dates.js";
|
||||
import { parseKeyValueLines, uniq } from "../lib/text.js";
|
||||
import type {
|
||||
Contact,
|
||||
DomainRecord,
|
||||
Nameserver,
|
||||
RegistrarInfo,
|
||||
} from "../types.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;
|
||||
}
|
||||
Reference in New Issue
Block a user