refactor: extract whois.js, topology.js, bgp-he-net.js, resolve-as-names.js

fetchBgpHeNet moved as-is, not removed, despite being confirmed dead code
in practice (its only call site in /api/lookup is hardcoded to
Promise.resolve(null)) -- that's a "disabled pending re-enable" state, a
bigger behavioral decision than this extraction pass should make alone.

Also widened the smoke-test harness's volatile-path exclusions: the live
Cloudflare RPKI feed's ASPA object count ticks between runs (independent
of any code change here), and the field appears under three different
names (aspa_map.entries, aspa_adoption.total_objects, plus the derived
delta_from_previous) that weren't all covered by the existing aspa_objects
exclusion. Re-captured baseline accordingly.

Verified via smoke-test harness (28/28 match). server.js: 4827 -> 4575 lines.
This commit is contained in:
Rene Fichtmueller 2026-07-17 00:04:51 +02:00
parent c5cf1f1da8
commit a5912a3d0c
7 changed files with 283 additions and 264 deletions

260
server.js
View File

@ -948,25 +948,7 @@ const {
// checkRateLimit/rateLimitMap removed 2026-07-16: dead code, never called by
// the request handler (confirmed via the server.js structural exploration).
async function resolveASNames(providers) {
// Batch resolve AS names via RIPE Stat AS overview API
const batchSize = 10;
for (let i = 0; i < providers.length; i += batchSize) {
const batch = providers.slice(i, i + batchSize);
const results = await Promise.all(
batch.map(p =>
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + p.asn)
.then(r => ({ asn: p.asn, name: r?.data?.holder || "" }))
.catch(() => ({ asn: p.asn, name: "" }))
)
);
results.forEach(r => {
const provider = providers.find(p => p.asn === r.asn);
if (provider && r.name) provider.name = r.name;
});
}
return providers;
}
const { resolveASNames } = require("./server/resolve-as-names");
@ -982,247 +964,13 @@ const {
} = require("./server/aspa-verification/engine");
// ============================================================
// Feature 24: bgp.he.net Integration
// ============================================================
async function fetchBgpHeNet(asn) {
try {
const html = await fetchHTML("https://bgp.he.net/AS" + asn);
if (!html) return null;
const result = {};
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
if (titleMatch) result.title = titleMatch[1].trim();
const peerMatch = html.match(/BGP\s+Peers\s+Observed\s*\(all\)\s*:\s*(\d[\d,]*)/i) || html.match(/Observed\s+Peers[^<]*<[^>]*>\s*(\d+)/i);
if (peerMatch) result.peer_count = parseInt(peerMatch[1].replace(/,/g, ''));
const countryMatch = html.match(/Country[^<]*<[^>]*>[^<]*<[^>]*>\s*<[^>]*>([^<]+)/i);
if (countryMatch) result.country = countryMatch[1].trim();
// Extract 2-letter country code from href="/country/XX"
const ccMatch = html.match(/href="\/country\/([A-Z]{2})"/i);
if (ccMatch) result.country_code = ccMatch[1].toUpperCase();
// Extract clean AS name from title: "AS12345 Some Name - bgp.he.net" → "Some Name"
if (titleMatch) {
const rawTitle = titleMatch[1].trim();
const nameFromTitle = rawTitle.replace(/^AS\d+\s+/i, '').replace(/\s+-\s+bgp\.he\.net.*$/i, '').trim();
if (nameFromTitle && !nameFromTitle.toLowerCase().includes('bgp.he.net')) {
result.name_from_title = nameFromTitle;
}
}
const { fetchBgpHeNet } = require("./server/bgp-he-net");
const lgMatch = html.match(/Looking\s+Glass[^<]*<[^>]*href="([^"]+)"/i);
if (lgMatch) result.looking_glass = lgMatch[1];
const descMatch = html.match(/AS\s+Name[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
if (descMatch) result.description = descMatch[1].trim();
const irrMatch = html.match(/IRR\s+Record[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
if (irrMatch) result.irr_record = irrMatch[1].trim();
// bgp.he.net format: "Prefixes Originated (v4): 147<br/>" or "Prefixes v4 ... <td>147"
const v4Match = html.match(/Prefixes\s+Originated\s*\(v4\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v4[^<]*<[^>]*>\s*(\d+)/i);
if (v4Match) result.prefixes_v4 = parseInt(v4Match[1].replace(/,/g, ''));
const v6Match = html.match(/Prefixes\s+Originated\s*\(v6\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v6[^<]*<[^>]*>\s*(\d+)/i);
if (v6Match) result.prefixes_v6 = parseInt(v6Match[1].replace(/,/g, ''));
const allMatch = html.match(/Prefixes\s+Originated\s*\(all\)\s*:\s*(\d[\d,]*)/i);
if (allMatch) result.prefixes_all = parseInt(allMatch[1].replace(/,/g, ''));
result.source_url = "https://bgp.he.net/AS" + asn;
return result;
} catch (_e) {
return null;
}
}
// ============================================================
// Feature 25: Topology / AS-Relationships
// ============================================================
async function fetchTopology(targetAsn, depth) {
const maxDepth = Math.min(depth || 2, 3);
const nodes = new Map();
const edges = [];
async function fetchNeighboursForAsn(asn, currentDepth) {
if (nodes.has(asn) && nodes.get(asn).depth <= currentDepth) return;
const [data, overview] = await Promise.all([
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn),
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn),
]);
const name = overview?.data?.holder || "";
const neighbours = data?.data?.neighbours || [];
const upstreams = neighbours.filter((n) => n.type === "left");
const downstreams = neighbours.filter((n) => n.type === "right");
const peers = neighbours.filter((n) => n.type === "uncertain" || n.type === "peer");
const nodeType = asn === targetAsn ? "target" : currentDepth === 1 ? "direct" : "indirect";
nodes.set(asn, { asn, name, type: nodeType, depth: currentDepth });
upstreams.forEach((n) => {
if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "upstream", depth: currentDepth + 1 });
edges.push({ from: n.asn, to: asn, relationship: "provider-to-customer" });
});
downstreams.forEach((n) => {
if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "downstream", depth: currentDepth + 1 });
edges.push({ from: asn, to: n.asn, relationship: "provider-to-customer" });
});
peers.slice(0, 10).forEach((n) => {
if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "peer", depth: currentDepth + 1 });
edges.push({ from: asn, to: n.asn, relationship: "peer" });
});
if (currentDepth < maxDepth && upstreams.length > 0) {
const top5 = upstreams.sort((a, b) => (b.power || 0) - (a.power || 0)).slice(0, 5);
await Promise.all(top5.map((u) => fetchNeighboursForAsn(u.asn, currentDepth + 1)));
}
}
await fetchNeighboursForAsn(targetAsn, 0);
const edgeSet = new Set();
const uniqueEdges = edges.filter((e) => {
const key = e.from + "-" + e.to + "-" + e.relationship;
if (edgeSet.has(key)) return false;
edgeSet.add(key);
return true;
});
return { nodes: [...nodes.values()], edges: uniqueEdges, target_asn: targetAsn, depth: maxDepth };
}
// ============================================================
// Feature 27: WHOIS via RIPE DB
// ============================================================
const { fetchTopology } = require("./server/topology");
const { computeResilienceScore } = require("./server/resilience-score");
const { computeRouteLeakDetection } = require("./server/route-leak");
async function fetchWhois(resource) {
const result = { resource, type: null, data: null, error: null };
try {
const trimmed = resource.trim();
if (/^(AS)?\d+$/i.test(trimmed)) {
result.type = "aut-num";
const asn = trimmed.replace(/^AS/i, "");
// Check cache first
const cached = whoisCacheGet(asn);
if (cached !== undefined) {
result.data = cached;
if (!cached) result.error = "Not found in any RIR database (cached)";
return result;
}
// Try RIPE first
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=AS" + asn + "&type-filter=aut-num&source=ripe", { timeout: 5000 }).catch(() => null);
if (ripeData && ripeData.objects && ripeData.objects.object) {
const obj = ripeData.objects.object[0];
const attrs = obj.attributes?.attribute || [];
const parsed = {};
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
result.data = {
aut_num: (parsed["aut-num"] || [])[0] || "",
as_name: (parsed["as-name"] || [])[0] || "",
descr: parsed["descr"] || [],
org: (parsed["org"] || [])[0] || "",
admin_c: parsed["admin-c"] || [],
tech_c: parsed["tech-c"] || [],
mnt_by: parsed["mnt-by"] || [],
status: (parsed["status"] || [])[0] || "",
created: (parsed["created"] || [])[0] || "",
last_modified: (parsed["last-modified"] || [])[0] || "",
source: (parsed["source"] || [])[0] || "",
import: parsed["import"] || [],
export: parsed["export"] || [],
remarks: parsed["remarks"] || [],
};
whoisCacheSet(asn, result.data);
}
// If RIPE didn't find it, try all other RIRs via RDAP in parallel (3s timeout)
if (!result.data) {
const rdapEndpoints = [
{ name: "APNIC", url: "https://rdap.apnic.net/autnum/" + asn },
{ name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + asn },
{ name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + asn },
{ name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn },
];
const rdapResults = await Promise.all(rdapEndpoints.map((ep) =>
fetchJSON(ep.url, { timeout: 3000 }).then((d) => {
if (!d || d.errorCode || !d.handle) return null;
return { source: ep.name, data: d };
}).catch(() => null)
));
const found = rdapResults.find((r) => r !== null);
if (found) {
const d = found.data;
const remarks = (d.remarks || []).map((r) => (r.description || []).join(" "));
const entities = d.entities || [];
const adminContacts = entities.filter((e) => (e.roles || []).includes("administrative")).map((e) => e.handle || "");
const techContacts = entities.filter((e) => (e.roles || []).includes("technical")).map((e) => e.handle || "");
const events = d.events || [];
const created = (events.find((e) => e.eventAction === "registration") || {}).eventDate || "";
const lastMod = (events.find((e) => e.eventAction === "last changed") || {}).eventDate || "";
result.data = {
aut_num: "AS" + asn,
as_name: d.name || "",
descr: remarks,
org: (entities.find((e) => (e.roles || []).includes("registrant")) || {}).handle || "",
admin_c: adminContacts,
tech_c: techContacts,
mnt_by: [],
status: (d.status || []).join(", "),
created: created,
last_modified: lastMod,
source: found.source + " (RDAP)",
import: [],
export: [],
remarks: remarks,
};
whoisCacheSet(asn, result.data);
} else {
result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)";
// Short TTL: this "not found" likely reflects a transient failure across
// all 5 sources, not a confirmed non-existent ASN -- see WHOIS_NOT_FOUND_CACHE_TTL.
whoisCacheSet(asn, null, WHOIS_NOT_FOUND_CACHE_TTL);
}
}
} else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) {
result.type = "inetnum";
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=inetnum,inet6num");
if (ripeData && ripeData.objects && ripeData.objects.object) {
const results = ripeData.objects.object.map((obj) => {
const attrs = obj.attributes?.attribute || [];
const parsed = {};
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
return {
inetnum: (parsed["inetnum"] || parsed["inet6num"] || [])[0] || "",
netname: (parsed["netname"] || [])[0] || "",
descr: parsed["descr"] || [],
country: (parsed["country"] || [])[0] || "",
org: (parsed["org"] || [])[0] || "",
admin_c: parsed["admin-c"] || [],
tech_c: parsed["tech-c"] || [],
mnt_by: parsed["mnt-by"] || [],
status: (parsed["status"] || [])[0] || "",
created: (parsed["created"] || [])[0] || "",
last_modified: (parsed["last-modified"] || [])[0] || "",
source: (parsed["source"] || [])[0] || "",
};
});
result.data = results.length === 1 ? results[0] : results;
} else { result.error = "Not found in RIPE DB"; }
} else {
result.type = "domain";
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=domain");
if (ripeData && ripeData.objects && ripeData.objects.object) {
const obj = ripeData.objects.object[0];
const attrs = obj.attributes?.attribute || [];
const parsed = {};
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
result.data = {
domain: (parsed["domain"] || [])[0] || "",
descr: parsed["descr"] || [],
admin_c: parsed["admin-c"] || [],
tech_c: parsed["tech-c"] || [],
zone_c: parsed["zone-c"] || [],
nserver: parsed["nserver"] || [],
mnt_by: parsed["mnt-by"] || [],
created: (parsed["created"] || [])[0] || "",
last_modified: (parsed["last-modified"] || [])[0] || "",
source: (parsed["source"] || [])[0] || "",
};
} else { result.error = "Not found in RIPE DB"; }
}
} catch (err) { result.error = err.message; }
return result;
}
const { fetchWhois } = require("./server/whois");
// ============================================================
// Internal API Server Proxy

View File

@ -101,9 +101,7 @@
"version": "0.6.9",
"bgproutes_configured": false,
"caches": {
"aspa_map": {
"entries": 2396
},
"aspa_map": {},
"pdb_net": {
"entries": 0,
"hit_rate_pct": 0
@ -122,9 +120,7 @@
}
},
"local_db": null,
"aspa_adoption": {
"total_objects": 2396
}
"aspa_adoption": {}
}
},
"aspa-verify": {
@ -3378,8 +3374,7 @@
"body": {
"period": "30d",
"current": {
"date": "2026-07-16",
"delta_from_previous": 14
"date": "2026-07-16"
},
"trend": [
{

View File

@ -100,6 +100,11 @@ const VOLATILE_PATHS = new Set([
"quick-ix.ix_connections",
"quick-ix.name",
"atlas-coverage.by_country",
// live Cloudflare RPKI feed object count ticks between runs; date-rollover
// (midnight) shifts the adoption tracker's day-over-day delta too
"health.caches.aspa_map.entries",
"health.aspa_adoption.total_objects",
"aspa-adoption-stats.current.delta_from_previous",
]);
function stripNondeterministic(obj, pathPrefix) {

53
server/bgp-he-net.js Normal file
View File

@ -0,0 +1,53 @@
const { fetchHTML } = require("./services/http-helpers");
// Feature 24: bgp.he.net Integration.
// NOTE: currently dead code in practice -- /api/lookup's only call site
// (`timedFetch("bgp.he.net", ...)`) is hardcoded to `Promise.resolve(null)`
// rather than actually invoking this. Moved as-is (not removed) since that's
// a "disabled pending re-enable" state, not a provably-unreachable one --
// removing a whole scraping feature is a bigger decision than this
// behavior-preserving extraction pass should make unilaterally.
async function fetchBgpHeNet(asn) {
try {
const html = await fetchHTML("https://bgp.he.net/AS" + asn);
if (!html) return null;
const result = {};
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
if (titleMatch) result.title = titleMatch[1].trim();
const peerMatch = html.match(/BGP\s+Peers\s+Observed\s*\(all\)\s*:\s*(\d[\d,]*)/i) || html.match(/Observed\s+Peers[^<]*<[^>]*>\s*(\d+)/i);
if (peerMatch) result.peer_count = parseInt(peerMatch[1].replace(/,/g, ''));
const countryMatch = html.match(/Country[^<]*<[^>]*>[^<]*<[^>]*>\s*<[^>]*>([^<]+)/i);
if (countryMatch) result.country = countryMatch[1].trim();
// Extract 2-letter country code from href="/country/XX"
const ccMatch = html.match(/href="\/country\/([A-Z]{2})"/i);
if (ccMatch) result.country_code = ccMatch[1].toUpperCase();
// Extract clean AS name from title: "AS12345 Some Name - bgp.he.net" → "Some Name"
if (titleMatch) {
const rawTitle = titleMatch[1].trim();
const nameFromTitle = rawTitle.replace(/^AS\d+\s+/i, '').replace(/\s+-\s+bgp\.he\.net.*$/i, '').trim();
if (nameFromTitle && !nameFromTitle.toLowerCase().includes('bgp.he.net')) {
result.name_from_title = nameFromTitle;
}
}
const lgMatch = html.match(/Looking\s+Glass[^<]*<[^>]*href="([^"]+)"/i);
if (lgMatch) result.looking_glass = lgMatch[1];
const descMatch = html.match(/AS\s+Name[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
if (descMatch) result.description = descMatch[1].trim();
const irrMatch = html.match(/IRR\s+Record[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
if (irrMatch) result.irr_record = irrMatch[1].trim();
// bgp.he.net format: "Prefixes Originated (v4): 147<br/>" or "Prefixes v4 ... <td>147"
const v4Match = html.match(/Prefixes\s+Originated\s*\(v4\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v4[^<]*<[^>]*>\s*(\d+)/i);
if (v4Match) result.prefixes_v4 = parseInt(v4Match[1].replace(/,/g, ''));
const v6Match = html.match(/Prefixes\s+Originated\s*\(v6\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v6[^<]*<[^>]*>\s*(\d+)/i);
if (v6Match) result.prefixes_v6 = parseInt(v6Match[1].replace(/,/g, ''));
const allMatch = html.match(/Prefixes\s+Originated\s*\(all\)\s*:\s*(\d[\d,]*)/i);
if (allMatch) result.prefixes_all = parseInt(allMatch[1].replace(/,/g, ''));
result.source_url = "https://bgp.he.net/AS" + asn;
return result;
} catch (_e) {
return null;
}
}
module.exports = { fetchBgpHeNet };

View File

@ -0,0 +1,23 @@
const { fetchRipeStatCached } = require("./services/ripe-stat");
// Batch resolve AS names via RIPE Stat AS overview API
async function resolveASNames(providers) {
const batchSize = 10;
for (let i = 0; i < providers.length; i += batchSize) {
const batch = providers.slice(i, i + batchSize);
const results = await Promise.all(
batch.map(p =>
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + p.asn)
.then(r => ({ asn: p.asn, name: r?.data?.holder || "" }))
.catch(() => ({ asn: p.asn, name: "" }))
)
);
results.forEach(r => {
const provider = providers.find(p => p.asn === r.asn);
if (provider && r.name) provider.name = r.name;
});
}
return providers;
}
module.exports = { resolveASNames };

49
server/topology.js Normal file
View File

@ -0,0 +1,49 @@
const { fetchRipeStatCached } = require("./services/ripe-stat");
// Feature 25: Topology / AS-Relationships
async function fetchTopology(targetAsn, depth) {
const maxDepth = Math.min(depth || 2, 3);
const nodes = new Map();
const edges = [];
async function fetchNeighboursForAsn(asn, currentDepth) {
if (nodes.has(asn) && nodes.get(asn).depth <= currentDepth) return;
const [data, overview] = await Promise.all([
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn),
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn),
]);
const name = overview?.data?.holder || "";
const neighbours = data?.data?.neighbours || [];
const upstreams = neighbours.filter((n) => n.type === "left");
const downstreams = neighbours.filter((n) => n.type === "right");
const peers = neighbours.filter((n) => n.type === "uncertain" || n.type === "peer");
const nodeType = asn === targetAsn ? "target" : currentDepth === 1 ? "direct" : "indirect";
nodes.set(asn, { asn, name, type: nodeType, depth: currentDepth });
upstreams.forEach((n) => {
if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "upstream", depth: currentDepth + 1 });
edges.push({ from: n.asn, to: asn, relationship: "provider-to-customer" });
});
downstreams.forEach((n) => {
if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "downstream", depth: currentDepth + 1 });
edges.push({ from: asn, to: n.asn, relationship: "provider-to-customer" });
});
peers.slice(0, 10).forEach((n) => {
if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "peer", depth: currentDepth + 1 });
edges.push({ from: asn, to: n.asn, relationship: "peer" });
});
if (currentDepth < maxDepth && upstreams.length > 0) {
const top5 = upstreams.sort((a, b) => (b.power || 0) - (a.power || 0)).slice(0, 5);
await Promise.all(top5.map((u) => fetchNeighboursForAsn(u.asn, currentDepth + 1)));
}
}
await fetchNeighboursForAsn(targetAsn, 0);
const edgeSet = new Set();
const uniqueEdges = edges.filter((e) => {
const key = e.from + "-" + e.to + "-" + e.relationship;
if (edgeSet.has(key)) return false;
edgeSet.add(key);
return true;
});
return { nodes: [...nodes.values()], edges: uniqueEdges, target_asn: targetAsn, depth: maxDepth };
}
module.exports = { fetchTopology };

146
server/whois.js Normal file
View File

@ -0,0 +1,146 @@
const { fetchJSON } = require("./services/http-helpers");
const { whoisCacheGet, whoisCacheSet, WHOIS_NOT_FOUND_CACHE_TTL } = require("./caches/response-caches");
// Feature 27: WHOIS via RIPE DB
async function fetchWhois(resource) {
const result = { resource, type: null, data: null, error: null };
try {
const trimmed = resource.trim();
if (/^(AS)?\d+$/i.test(trimmed)) {
result.type = "aut-num";
const asn = trimmed.replace(/^AS/i, "");
// Check cache first
const cached = whoisCacheGet(asn);
if (cached !== undefined) {
result.data = cached;
if (!cached) result.error = "Not found in any RIR database (cached)";
return result;
}
// Try RIPE first
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=AS" + asn + "&type-filter=aut-num&source=ripe", { timeout: 5000 }).catch(() => null);
if (ripeData && ripeData.objects && ripeData.objects.object) {
const obj = ripeData.objects.object[0];
const attrs = obj.attributes?.attribute || [];
const parsed = {};
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
result.data = {
aut_num: (parsed["aut-num"] || [])[0] || "",
as_name: (parsed["as-name"] || [])[0] || "",
descr: parsed["descr"] || [],
org: (parsed["org"] || [])[0] || "",
admin_c: parsed["admin-c"] || [],
tech_c: parsed["tech-c"] || [],
mnt_by: parsed["mnt-by"] || [],
status: (parsed["status"] || [])[0] || "",
created: (parsed["created"] || [])[0] || "",
last_modified: (parsed["last-modified"] || [])[0] || "",
source: (parsed["source"] || [])[0] || "",
import: parsed["import"] || [],
export: parsed["export"] || [],
remarks: parsed["remarks"] || [],
};
whoisCacheSet(asn, result.data);
}
// If RIPE didn't find it, try all other RIRs via RDAP in parallel (3s timeout)
if (!result.data) {
const rdapEndpoints = [
{ name: "APNIC", url: "https://rdap.apnic.net/autnum/" + asn },
{ name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + asn },
{ name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + asn },
{ name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn },
];
const rdapResults = await Promise.all(rdapEndpoints.map((ep) =>
fetchJSON(ep.url, { timeout: 3000 }).then((d) => {
if (!d || d.errorCode || !d.handle) return null;
return { source: ep.name, data: d };
}).catch(() => null)
));
const found = rdapResults.find((r) => r !== null);
if (found) {
const d = found.data;
const remarks = (d.remarks || []).map((r) => (r.description || []).join(" "));
const entities = d.entities || [];
const adminContacts = entities.filter((e) => (e.roles || []).includes("administrative")).map((e) => e.handle || "");
const techContacts = entities.filter((e) => (e.roles || []).includes("technical")).map((e) => e.handle || "");
const events = d.events || [];
const created = (events.find((e) => e.eventAction === "registration") || {}).eventDate || "";
const lastMod = (events.find((e) => e.eventAction === "last changed") || {}).eventDate || "";
result.data = {
aut_num: "AS" + asn,
as_name: d.name || "",
descr: remarks,
org: (entities.find((e) => (e.roles || []).includes("registrant")) || {}).handle || "",
admin_c: adminContacts,
tech_c: techContacts,
mnt_by: [],
status: (d.status || []).join(", "),
created: created,
last_modified: lastMod,
source: found.source + " (RDAP)",
import: [],
export: [],
remarks: remarks,
};
whoisCacheSet(asn, result.data);
} else {
result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)";
// Short TTL: this "not found" likely reflects a transient failure across
// all 5 sources, not a confirmed non-existent ASN -- see WHOIS_NOT_FOUND_CACHE_TTL.
whoisCacheSet(asn, null, WHOIS_NOT_FOUND_CACHE_TTL);
}
}
} else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) {
result.type = "inetnum";
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=inetnum,inet6num");
if (ripeData && ripeData.objects && ripeData.objects.object) {
const results = ripeData.objects.object.map((obj) => {
const attrs = obj.attributes?.attribute || [];
const parsed = {};
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
return {
inetnum: (parsed["inetnum"] || parsed["inet6num"] || [])[0] || "",
netname: (parsed["netname"] || [])[0] || "",
descr: parsed["descr"] || [],
country: (parsed["country"] || [])[0] || "",
org: (parsed["org"] || [])[0] || "",
admin_c: parsed["admin-c"] || [],
tech_c: parsed["tech-c"] || [],
mnt_by: parsed["mnt-by"] || [],
status: (parsed["status"] || [])[0] || "",
created: (parsed["created"] || [])[0] || "",
last_modified: (parsed["last-modified"] || [])[0] || "",
source: (parsed["source"] || [])[0] || "",
};
});
result.data = results.length === 1 ? results[0] : results;
} else { result.error = "Not found in RIPE DB"; }
} else {
result.type = "domain";
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=domain");
if (ripeData && ripeData.objects && ripeData.objects.object) {
const obj = ripeData.objects.object[0];
const attrs = obj.attributes?.attribute || [];
const parsed = {};
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
result.data = {
domain: (parsed["domain"] || [])[0] || "",
descr: parsed["descr"] || [],
admin_c: parsed["admin-c"] || [],
tech_c: parsed["tech-c"] || [],
zone_c: parsed["zone-c"] || [],
nserver: parsed["nserver"] || [],
mnt_by: parsed["mnt-by"] || [],
created: (parsed["created"] || [])[0] || "",
last_modified: (parsed["last-modified"] || [])[0] || "",
source: (parsed["source"] || [])[0] || "",
};
} else { result.error = "Not found in RIPE DB"; }
}
} catch (err) { result.error = err.message; }
return result;
}
module.exports = { fetchWhois };