mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-10-26 08:26:22 -04:00
fix: look up ac.uk names at Jisc instead of Nominet
IANA's bootstrap lists only uk, so ac.uk names fell back to Nominet's RDAP server, which answers 404 for them: ox.ac.uk and cam.ac.uk came back as not registered. Nominet's WHOIS is no better, answering with the ac.uk delegation itself. Jisc runs ac.uk and publishes WHOIS (no RDAP) at whois.ja.net, so route the suffix there as a sub-registry, which may now leave out either server.
Parse Jisc's tab-indented layout: "Servers:" nameservers with glue, "Entry created/updated:", "Registered By:", "Registered For:"/"Domain Owner:" as the registrant's organization, "Registrant Contact:", dates with a leading weekday ("Wednesday 26th Jul 2028"), and "No such domain" as unregistered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
9 files changed
+86
-17
No files matched your search
+2
-2
@@ -32,9 +32,9 @@ const FORMATS: Array<[RegExp, (m: RegExpMatchArray) => number | undefined]> = [
|
||||
/^(\d{4})(\d{2})(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2}))?$/,
|
||||
(m) => utc(m[1], m[2], m[3], m[4], m[5], m[6]),
|
||||
],
|
||||
// 28th December 2018 [at 05:54:43[.861]] (used by .gg/.je)
|
||||
// [Wednesday] 28th December 2018 [at 05:54:43[.861]] (used by .gg/.je; the weekday by .ac.uk)
|
||||
[
|
||||
/^(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]{3})[A-Za-z]*\s+(\d{4})(?:\s+at\s+(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?)?$/,
|
||||
/^(?:[A-Za-z]+day,?\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]{3})[A-Za-z]*\s+(\d{4})(?:\s+at\s+(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?)?$/,
|
||||
(m) => utc(m[3], monthOf(m[2]), m[1], m[4], m[5], m[6]),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ test("getSubRegistrySuffix matches names registered under a sub-registry", () =>
|
||||
expect(getSubRegistrySuffix("google.uk.com")).toBe("uk.com");
|
||||
expect(getSubRegistrySuffix("Apple.COM.de.")).toBe("com.de");
|
||||
expect(getSubRegistrySuffix("www.google.us.com")).toBe("us.com");
|
||||
expect(getSubRegistrySuffix("ox.ac.uk")).toBe("ac.uk");
|
||||
});
|
||||
|
||||
test("getSubRegistrySuffix ignores the suffix itself and ordinary domains", () => {
|
||||
@@ -21,3 +22,7 @@ test("SUB_REGISTRIES points each suffix at its own CentralNic RDAP path", () =>
|
||||
whois: "whois.centralnic.com",
|
||||
});
|
||||
});
|
||||
|
||||
test("SUB_REGISTRIES sends ac.uk to Jisc's WHOIS, with no RDAP", () => {
|
||||
expect(SUB_REGISTRIES["ac.uk"]).toEqual({ whois: "whois.ja.net" });
|
||||
});
|
||||
+15
-10
@@ -1,9 +1,9 @@
|
||||
/** Where to look up names registered under a sub-registry suffix. */
|
||||
export interface SubRegistry {
|
||||
/** RDAP base URL (with trailing slash) */
|
||||
rdap: string;
|
||||
/** WHOIS server */
|
||||
whois: string;
|
||||
/** RDAP base URL (with trailing slash), if the operator publishes RDAP */
|
||||
rdap?: string;
|
||||
/** WHOIS server, if the operator publishes WHOIS */
|
||||
whois?: string;
|
||||
}
|
||||
|
||||
// CentralNic's entries in the Public Suffix List (plus gr.com and co.com, which it also serves),
|
||||
@@ -37,12 +37,17 @@ const CENTRALNIC_SUFFIXES = [
|
||||
* Second-level suffixes run as registries of their own: a name like google.uk.com is registered
|
||||
* with the suffix's operator, not the TLD registry, and the IANA bootstrap doesn't list them.
|
||||
*/
|
||||
export const SUB_REGISTRIES: Readonly<Record<string, SubRegistry>> = Object.fromEntries(
|
||||
CENTRALNIC_SUFFIXES.map((suffix) => [
|
||||
suffix,
|
||||
{ rdap: `https://rdap.centralnic.com/${suffix}/`, whois: "whois.centralnic.com" },
|
||||
]),
|
||||
);
|
||||
export const SUB_REGISTRIES: Readonly<Record<string, SubRegistry>> = {
|
||||
...Object.fromEntries(
|
||||
CENTRALNIC_SUFFIXES.map((suffix) => [
|
||||
suffix,
|
||||
{ rdap: `https://rdap.centralnic.com/${suffix}/`, whois: "whois.centralnic.com" },
|
||||
]),
|
||||
),
|
||||
// Jisc runs ac.uk. Nominet's RDAP answers 404 for its names (which reads as "not registered"),
|
||||
// and Nominet's WHOIS answers with the ac.uk delegation itself.
|
||||
"ac.uk": { whois: "whois.ja.net" },
|
||||
};
|
||||
|
||||
/** The sub-registry suffix a domain falls under (e.g. "uk.com" for google.uk.com), if any. */
|
||||
export function getSubRegistrySuffix(domain: string): string | undefined {
|
||||
|
||||
@@ -488,4 +488,11 @@ describe("getRdapBaseUrlsForPublicSuffix", () => {
|
||||
]);
|
||||
expect(customFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns no RDAP bases for a sub-registry without RDAP, not its TLD's server", async () => {
|
||||
// Nominet's RDAP (listed for uk) answers 404 for ac.uk names, which would read as unregistered
|
||||
const customFetch = okFetch();
|
||||
expect(await getRdapBaseUrlsForPublicSuffix("ac.uk", { customFetch })).toEqual([]);
|
||||
expect(customFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -130,9 +130,10 @@ export async function getRdapBaseUrlsForTld(
|
||||
* Like {@link getRdapBaseUrlsForTld}, for a public suffix that may be multi-label.
|
||||
*
|
||||
* Sub-registry suffixes (`uk.com`) are answered from {@link SUB_REGISTRIES} without loading the
|
||||
* bootstrap. Otherwise IANA lists registry TLDs (`uk`, `br`), not public suffixes (`co.uk`,
|
||||
* `com.br`), so the suffix usually misses and the last label is tried next. The bootstrap data
|
||||
* is loaded once and reused for both lookups.
|
||||
* bootstrap, with none for an operator that publishes no RDAP (`ac.uk`). Otherwise IANA lists
|
||||
* registry TLDs (`uk`, `br`), not public suffixes (`co.uk`, `com.br`), so the suffix usually
|
||||
* misses and the last label is tried next. The bootstrap data is loaded once and reused for both
|
||||
* lookups.
|
||||
*/
|
||||
export async function getRdapBaseUrlsForPublicSuffix(
|
||||
publicSuffix: string,
|
||||
@@ -140,7 +141,7 @@ export async function getRdapBaseUrlsForPublicSuffix(
|
||||
ctx?: LookupContext,
|
||||
): Promise<string[]> {
|
||||
const subRegistry = SUB_REGISTRIES[publicSuffix.toLowerCase()];
|
||||
if (subRegistry) return [subRegistry.rdap];
|
||||
if (subRegistry) return subRegistry.rdap ? [subRegistry.rdap] : [];
|
||||
const data = await loadBootstrapData(options, ctx);
|
||||
if (!data) return [];
|
||||
const bases = matchBases(data, publicSuffix);
|
||||
|
||||
@@ -18,6 +18,9 @@ describe("discoverWhoisServer for sub-registry suffixes", () => {
|
||||
it("uses the operator's server without asking IANA", async () => {
|
||||
expect(await discoverWhoisServer("uk.com")).toEqual({ server: "whois.centralnic.com" });
|
||||
expect(whoisQuery).not.toHaveBeenCalled();
|
||||
// Nominet's whois.nic.uk answers ac.uk names with the ac.uk delegation itself
|
||||
expect(await discoverWhoisServer("ac.uk")).toEqual({ server: "whois.ja.net" });
|
||||
expect(whoisQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still lets whoisHints override it", async () => {
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function discoverWhoisServer(
|
||||
|
||||
// 1b) Sub-registry suffixes (e.g. uk.com), which IANA knows nothing about
|
||||
const subRegistry = SUB_REGISTRIES[key];
|
||||
if (subRegistry) return { server: subRegistry.whois };
|
||||
if (subRegistry) return subRegistry.whois ? { server: subRegistry.whois } : {};
|
||||
|
||||
// 2) IANA WHOIS authoritative discovery over TCP 43
|
||||
const out: WhoisDiscovery = {};
|
||||
|
||||
@@ -688,3 +688,44 @@ test("WHOIS local times from registries in WHOIS_TIME_ZONES match their RDAP val
|
||||
.creationDate,
|
||||
).toBe("2008-05-22T14:00:00Z");
|
||||
});
|
||||
|
||||
test("WHOIS .ac.uk (Jisc) tab-indented sections", () => {
|
||||
const text = [
|
||||
"",
|
||||
"Domain:\n\tox.ac.uk",
|
||||
"Registered For:\n\tUniversity of Oxford",
|
||||
"Domain Owner:\n\tUniversity of Oxford",
|
||||
"Registered By:\n\tUniversity of Oxford",
|
||||
"Servers:\n\tdns0.ox.ac.uk\t129.67.1.190\n\tauth4.dns.ox.ac.uk\t45.33.127.156\n\tauth4.dns.ox.ac.uk\t2600:3c00:e000:19::1",
|
||||
"Registrant Contact:\n\tDomain Registration",
|
||||
"Registrant Address:\n\tIT Services, University of Oxford\n\t7-19 Banbury Road\n\tOxford\n\tOxfordshire\n\tOX2 6NN\n\tUnited Kingdom\n\t+44 1865 273619 (Phone)\n\tdomain-registration@it.ox.ac.uk",
|
||||
"Renewal date:\n\tWednesday 26th Jul 2028",
|
||||
"Entry updated:\n\tSunday 26th April 2026",
|
||||
"Entry created:\n\tWednesday 17th September 2003",
|
||||
"",
|
||||
].join("\n\n");
|
||||
const rec = normalizeWhois("ox.ac.uk", "ac.uk", text, "whois.ja.net");
|
||||
expect(rec.isRegistered).toBe(true);
|
||||
expect(rec.creationDate).toBe("2003-09-17T00:00:00Z");
|
||||
expect(rec.updatedDate).toBe("2026-04-26T00:00:00Z");
|
||||
expect(rec.expirationDate).toBe("2028-07-26T00:00:00Z");
|
||||
expect(rec.registrar?.name).toBe("University of Oxford");
|
||||
expect(rec.nameservers).toEqual([
|
||||
{ host: "dns0.ox.ac.uk", ipv4: ["129.67.1.190"] },
|
||||
{ host: "auth4.dns.ox.ac.uk", ipv4: ["45.33.127.156"], ipv6: ["2600:3c00:e000:19::1"] },
|
||||
]);
|
||||
expect(rec.contacts?.find((c) => c.type === "registrant")).toMatchObject({
|
||||
name: "Domain Registration",
|
||||
organization: "University of Oxford",
|
||||
});
|
||||
});
|
||||
|
||||
test("WHOIS .ac.uk (Jisc) 'No such domain' reply is available", () => {
|
||||
const rec = normalizeWhois(
|
||||
"nonexistent.ac.uk",
|
||||
"ac.uk",
|
||||
"\nNo such domain nonexistent.ac.uk\n\n",
|
||||
"whois.ja.net",
|
||||
);
|
||||
expect(rec.isRegistered).toBe(false);
|
||||
});
|
||||
@@ -33,6 +33,7 @@ const WHOIS_AVAILABLE_PHRASES: RegExp[] = [
|
||||
/\bobject_not_found\b/i,
|
||||
/\bno se encuentra registrado\b/i, // Spanish: "not found registered"
|
||||
/\bnicht gefunden\b/i, // German: "not found"
|
||||
/\bno such domain\b/i, // .ac.uk
|
||||
];
|
||||
|
||||
// Statuses that mark a name unregistered even though the record still carries data, as a name
|
||||
@@ -103,6 +104,7 @@ export function normalizeWhois(
|
||||
"registered date", // .co.jp, .kr
|
||||
"created date", // .th
|
||||
"assigned", // .il
|
||||
"entry created", // .ac.uk
|
||||
]) ??
|
||||
relevantDate(/^registered on\s+/i) ??
|
||||
recordDate(/^[ \t]*Record created on[ \t]+(.+?)\.?$/im);
|
||||
@@ -122,6 +124,7 @@ export function normalizeWhois(
|
||||
"last updated date", // .kr
|
||||
"update date", // .by
|
||||
"domain record last updated", // .edu
|
||||
"entry updated", // .ac.uk
|
||||
],
|
||||
true,
|
||||
) ?? recordDate(/^[ \t]*Record last updated on[ \t]+(.+?)\.?$/im);
|
||||
@@ -163,6 +166,7 @@ export function normalizeWhois(
|
||||
"sponsoring registrar",
|
||||
"record maintained by",
|
||||
"registration service provider", // .tw
|
||||
"registered by", // .ac.uk
|
||||
]);
|
||||
const ianaId = anyValue(map, ["registrar iana id", "sponsoring registrar iana id", "iana id"]);
|
||||
const url = anyValue(map, [
|
||||
@@ -290,6 +294,7 @@ const NAMESERVER_KEYS = [
|
||||
"domain servers in listed order", // .ly
|
||||
"domain servers", // .tr
|
||||
"name servers dns", // .mx
|
||||
"servers", // .ac.uk
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -440,6 +445,7 @@ function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
|
||||
nameKeys.push(`${prefix} name`, `${prefix} contact name`, `${prefix}`);
|
||||
if (prefix === "registrant") {
|
||||
nameKeys.push("registrant person"); // .ua
|
||||
nameKeys.push("registrant contact"); // .ac.uk
|
||||
}
|
||||
if (prefix === "owner") {
|
||||
nameKeys.push("owner name"); // .tm
|
||||
@@ -449,6 +455,7 @@ function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
|
||||
if (prefix === "registrant") {
|
||||
orgKeys.push("trading as"); // .uk, .co.uk
|
||||
orgKeys.push("org"); // .ru
|
||||
orgKeys.push("registered for", "domain owner"); // .ac.uk
|
||||
}
|
||||
if (prefix === "owner") {
|
||||
orgKeys.push("owner orgname"); // .tm
|
||||
|
||||
Reference in new issue
Block a user