diff --git a/functions/api/whois.js b/functions/api/whois.js
index e936d7e..d167b0a 100644
--- a/functions/api/whois.js
+++ b/functions/api/whois.js
@@ -1,6 +1,14 @@
// functions/api/whois.js — RDAP proxy (avoids CORS). No user input is ever logged.
+//
+// Lookup order for domains:
+// 1. RDAP via rdap.org (plus a few registries missing from the IANA
+// bootstrap — see RDAP_BOOTSTRAP_OVERRIDES in lib/parse.mjs).
+// 2. If the TLD has no RDAP at all, classic whois over TCP 43: ask
+// whois.iana.org for the TLD's referral server, then query it and
+// return the raw text.
-import { parseRdapDomain, parseRdapIP, isBlockedHost } from '../../lib/parse.mjs';
+import { connect } from 'cloudflare:sockets';
+import { parseRdapDomain, parseRdapIP, isBlockedHost, rdapTarget, rdapFailure, parseWhoisReferral } from '../../lib/parse.mjs';
const CORS = {
'Access-Control-Allow-Origin': '*',
@@ -16,6 +24,66 @@ function json(body, status = 200) {
}
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
+// Strict enough to be safe to write into a TCP whois query verbatim.
+const WHOIS_SAFE = /^[a-z0-9.-]{1,253}$/i;
+const WHOIS_MAX_BYTES = 65536;
+
+async function whoisQuery(server, query, timeoutMs = 10000) {
+ const socket = connect({ hostname: server, port: 43 });
+ let timer;
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error('whois timeout')), timeoutMs);
+ });
+ const work = (async () => {
+ const writer = socket.writable.getWriter();
+ await writer.write(new TextEncoder().encode(query + '\r\n'));
+ // Don't close the writable side — workerd tears down the whole socket on
+ // FIN. Whois servers reply after CRLF and close the connection themselves.
+ writer.releaseLock();
+ const reader = socket.readable.getReader();
+ const chunks = [];
+ let total = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ chunks.push(value);
+ total += value.length;
+ if (total >= WHOIS_MAX_BYTES) break;
+ }
+ const buf = new Uint8Array(Math.min(total, WHOIS_MAX_BYTES));
+ let off = 0;
+ for (const c of chunks) {
+ const n = Math.min(c.length, buf.length - off);
+ buf.set(c.subarray(0, n), off);
+ off += n;
+ if (off >= buf.length) break;
+ }
+ return new TextDecoder('utf-8', { fatal: false }).decode(buf);
+ })();
+ try {
+ return await Promise.race([work, timeout]);
+ } finally {
+ clearTimeout(timer);
+ try { socket.close(); } catch (e) { /* already closed */ }
+ }
+}
+
+// Classic whois for TLDs with no RDAP. Returns { raw, source } or null.
+async function whoisFallback(q) {
+ if (!WHOIS_SAFE.test(q)) return null;
+ const tld = q.toLowerCase().replace(/\.$/, '').split('.').pop();
+ try {
+ const server = parseWhoisReferral(await whoisQuery('whois.iana.org', tld));
+ if (!server) return null;
+ const raw = (await whoisQuery(server, q)).trim();
+ if (!raw) return null;
+ return { raw, source: server };
+ } catch (e) {
+ // No user data in log.
+ console.error('whois fallback failed.');
+ return null;
+ }
+}
export async function onRequest(context) {
const { request } = context;
@@ -27,12 +95,9 @@ export async function onRequest(context) {
const m4 = IPV4.exec(q);
const isIP = (m4 && m4.slice(1).every((o) => Number(o) <= 255)) || q.includes(':');
if (isIP && isBlockedHost(q)) return json({ error: 'Private or reserved addresses (RFC1918) are not publicly registered.' }, 400);
- const target = isIP
- ? `https://rdap.org/ip/${encodeURIComponent(q)}`
- : `https://rdap.org/domain/${encodeURIComponent(q)}`;
try {
- const res = await fetch(target, {
+ const res = await fetch(rdapTarget(q, isIP), {
redirect: 'follow',
headers: {
Accept: 'application/rdap+json, application/json',
@@ -42,12 +107,22 @@ export async function onRequest(context) {
if (!res.ok) {
// No user data in log.
console.error('RDAP fetch failed:', res.status);
- return json({ error: `Registry returned ${res.status}. Some registries limit RDAP access.` }, 502);
+ // 404 straight from rdap.org (no redirect happened) means the TLD has
+ // no RDAP service in the IANA bootstrap — try classic whois instead.
+ let bootstrapMiss = false;
+ try { bootstrapMiss = new URL(res.url).hostname === 'rdap.org'; } catch (e) { /* ignore */ }
+ if (!isIP && res.status === 404 && bootstrapMiss) {
+ const fallback = await whoisFallback(q);
+ if (fallback) return json(fallback);
+ }
+ const fail = rdapFailure(res.status, res.url, q, isIP);
+ return json({ error: fail.error }, fail.status);
}
const data = await res.json();
return json(isIP ? parseRdapIP(data) : parseRdapDomain(data));
} catch (e) {
console.error('RDAP request error.');
- return json({ error: 'Could not reach the RDAP registry. Try again shortly.' }, 502);
+ // 424, not 502 — Cloudflare swallows 502/504 bodies (see rdapFailure).
+ return json({ error: 'Could not reach the RDAP registry. Try again shortly.' }, 424);
}
}
diff --git a/js/whois.js b/js/whois.js
index 7fc0905..753aa46 100644
--- a/js/whois.js
+++ b/js/whois.js
@@ -15,6 +15,14 @@ async function runWhois(query, panel) {
const d = await res.json();
if (d.error) throw new Error(d.error);
+ // TLDs without RDAP fall back to the registry's classic whois — raw text.
+ if (d.raw) {
+ panel.innerHTML =
+ window.card(`Whois — ${q}`, `
${window.escapeHtml(d.raw)}`, d.raw) +
+ `This registry doesn't publish structured RDAP data, so this is the raw whois record from ${window.escapeHtml(d.source || 'the registry')}.
`;
+ return;
+ }
+
const isIP = window.isIP(q);
let rows;
if (isIP) {
@@ -45,7 +53,11 @@ async function runWhois(query, panel) {
window.card(`Whois — ${q}`, ``) +
`Data from RDAP. If a field is missing, some registries withhold it — try
lookup.icann.org.
`;
} catch (e) {
- window.showError(panel, e.message || 'Whois lookup failed.');
+ // Error plus a fallback link — many ccTLD registries (.de, .ch, .io, …)
+ // don't publish RDAP, so point at ICANN's lookup instead of a dead end.
+ panel.innerHTML =
+ `${window.escapeHtml(e.message || 'Whois lookup failed.')}
` +
+ ``;
}
}
diff --git a/lib/parse.mjs b/lib/parse.mjs
index c39599e..8a17347 100644
--- a/lib/parse.mjs
+++ b/lib/parse.mjs
@@ -142,6 +142,55 @@ export function parseTenantDomains(xml, cap = 200) {
return out.sort();
}
+// ---------- RDAP target selection (whois.js) ----------
+// Registries that run public RDAP but aren't in the IANA bootstrap registry
+// rdap.org relies on, so rdap.org 404s them. Verified working July 2026.
+// (.eu, .at, .be, .nz, .dk, .se offer no public RDAP at all — those still get
+// the "registry does not publish RDAP" error.)
+export const RDAP_BOOTSTRAP_OVERRIDES = {
+ de: 'https://rdap.denic.de/domain/',
+ ch: 'https://rdap.nic.ch/domain/',
+ li: 'https://rdap.nic.li/domain/',
+ io: 'https://rdap.identitydigital.services/rdap/domain/',
+ sh: 'https://rdap.identitydigital.services/rdap/domain/',
+ ac: 'https://rdap.identitydigital.services/rdap/domain/',
+};
+
+export function rdapTarget(q, isIP) {
+ if (isIP) return `https://rdap.org/ip/${encodeURIComponent(q)}`;
+ const tld = String(q).toLowerCase().replace(/\.$/, '').split('.').pop();
+ const base = RDAP_BOOTSTRAP_OVERRIDES[tld];
+ return base ? base + encodeURIComponent(q) : `https://rdap.org/domain/${encodeURIComponent(q)}`;
+}
+
+// Extract the referral whois server from an IANA "whois.iana.org" TLD answer
+// (a line like "whois: whois.eu"). Returns '' when the TLD has none.
+export function parseWhoisReferral(text) {
+ const m = /^whois:\s+(\S+)\s*$/im.exec(String(text || ''));
+ const server = m ? m[1].toLowerCase() : '';
+ return /^[a-z0-9.-]{1,253}$/.test(server) ? server : '';
+}
+
+// ---------- RDAP upstream failure shaping (whois.js) ----------
+// rdap.org 404s in two distinct ways: when the TLD has no RDAP service in the
+// IANA bootstrap registry it answers 404 itself (no redirect), otherwise it
+// redirects to the registry, whose 404 means the name isn't registered.
+// Never map failures to 502/504: Cloudflare replaces those with its own
+// plain-text error page, so the browser would never see the JSON message.
+export function rdapFailure(status, finalUrl, query, isIP) {
+ if (status === 404) {
+ let atBootstrap = false;
+ try { atBootstrap = new URL(finalUrl || '').hostname === 'rdap.org'; } catch (e) { /* ignore */ }
+ if (!isIP && atBootstrap) {
+ const tld = String(query || '').split('.').pop().toLowerCase();
+ return { status: 404, error: `The .${tld} registry publishes no RDAP data, and no public whois answer could be retrieved either.` };
+ }
+ return { status: 404, error: isIP ? 'No registration found for this IP address.' : 'Domain not found — it may be unregistered.' };
+ }
+ if (status === 429) return { status: 429, error: 'The registry is rate-limiting lookups. Try again shortly.' };
+ return { status: 424, error: `Registry returned ${status}. Some registries limit RDAP access.` };
+}
+
// ---------- SSRF host guard (shared with headers.js) ----------
export function isBlockedHost(host) {
host = (host || '').toLowerCase().trim();
diff --git a/tests/e2e.mjs b/tests/e2e.mjs
index d8b92db..15990cd 100644
--- a/tests/e2e.mjs
+++ b/tests/e2e.mjs
@@ -68,6 +68,18 @@ async function main() {
okUpstream('whois domain 200', status, status === 200, `status ${status}`);
okUpstream('whois domain has field', status, json && (json.registrar !== undefined || json.domain), JSON.stringify(json));
}
+ // whois — RDAP override for a registry missing from the IANA bootstrap (.de)
+ {
+ const { status, json } = await getJson('/api/whois?q=heise.de');
+ okUpstream('whois .de override 200', status, status === 200, `status ${status}`);
+ okUpstream('whois .de has domain field', status, json && json.domain, JSON.stringify(json));
+ }
+ // whois — classic port-43 fallback for a TLD with no RDAP at all (.eu)
+ {
+ const { status, json } = await getJson('/api/whois?q=europa.eu');
+ okUpstream('whois .eu raw fallback 200', status, status === 200, `status ${status}`);
+ okUpstream('whois .eu returns raw text', status, json && json.raw && json.source, JSON.stringify(json).slice(0, 120));
+ }
// whois missing param — this is OUR validation, always checked
{
const { status, json } = await getJson('/api/whois');
diff --git a/tests/smoke.mjs b/tests/smoke.mjs
index d301155..f594c6b 100644
--- a/tests/smoke.mjs
+++ b/tests/smoke.mjs
@@ -179,6 +179,30 @@ eq('rdap ip org', rdapIP.org, 'Cloudflare, Inc.');
eq('rdap ip nested abuse', rdapIP.abuse, 'abuse@cloudflare.com');
eq('rdap ip cidr range', rdapIP.cidr, '104.16.0.0 – 104.31.255.255');
+// ================= lib/parse.mjs: RDAP target + failure shaping =================
+eq('rdap target com via rdap.org', parse.rdapTarget('example.com', false), 'https://rdap.org/domain/example.com');
+eq('rdap target ip', parse.rdapTarget('1.1.1.1', true), 'https://rdap.org/ip/1.1.1.1');
+eq('rdap target de override', parse.rdapTarget('heise.de', false), 'https://rdap.denic.de/domain/heise.de');
+eq('rdap target io override', parse.rdapTarget('github.io', false), 'https://rdap.identitydigital.services/rdap/domain/github.io');
+eq('rdap target trailing dot + case', parse.rdapTarget('Heise.DE.', false), 'https://rdap.denic.de/domain/Heise.DE.');
+
+const noRdapTld = parse.rdapFailure(404, 'https://rdap.org/domain/example.eu', 'example.eu', false);
+eq('rdap failure bootstrap miss is 404', noRdapTld.status, 404);
+check('rdap failure bootstrap miss names tld', noRdapTld.error.includes('.eu'), noRdapTld.error);
+const notFound = parse.rdapFailure(404, 'https://rdap.verisign.com/com/v1/domain/nope.com', 'nope.com', false);
+eq('rdap failure registry 404 is not-found', notFound.error, 'Domain not found — it may be unregistered.');
+eq('rdap failure ip 404', parse.rdapFailure(404, 'https://rdap.arin.net/ip/x', '203.0.113.9', true).error, 'No registration found for this IP address.');
+eq('rdap failure 429 passthrough', parse.rdapFailure(429, 'https://rdap.org/domain/x.com', 'x.com', false).status, 429);
+// 5xx must be remapped — Cloudflare replaces 502/504 bodies with its own page.
+eq('rdap failure 502 remapped to 424', parse.rdapFailure(502, 'https://rdap.org/domain/x.com', 'x.com', false).status, 424);
+
+// ================= lib/parse.mjs: whois referral =================
+eq('whois referral parsed', parse.parseWhoisReferral('% IANA WHOIS server\nrefer: whois.eu\nwhois: whois.eu\nstatus: ACTIVE'), 'whois.eu');
+eq('whois referral case-insensitive', parse.parseWhoisReferral('WHOIS: WHOIS.NIC.AT\n'), 'whois.nic.at');
+eq('whois referral absent', parse.parseWhoisReferral('% IANA WHOIS server\nstatus: ACTIVE'), '');
+eq('whois referral garbage rejected', parse.parseWhoisReferral('whois: not a host!\n'), '');
+eq('whois referral empty input', parse.parseWhoisReferral(''), '');
+
// ================= lib/parse.mjs: bgpview shaping =================
const asnFromIp = parse.shapeAsnFromIp({
data: { ip: '1.1.1.1', prefixes: [{ prefix: '1.1.1.0/24', name: 'APNIC-LABS', asn: { asn: 13335, name: 'CLOUDFLARENET', description: 'Cloudflare', country_code: 'US' } }] },