diff --git a/server.js b/server.js index 73fc846..b59b44f 100644 --- a/server.js +++ b/server.js @@ -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>/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 diff --git a/server/__smoke__/baseline/baseline.json b/server/__smoke__/baseline/baseline.json index 23aeaf9..057242d 100644 --- a/server/__smoke__/baseline/baseline.json +++ b/server/__smoke__/baseline/baseline.json @@ -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": [ { diff --git a/server/__smoke__/compare-responses.js b/server/__smoke__/compare-responses.js index 882bf9f..c2e3823 100644 --- a/server/__smoke__/compare-responses.js +++ b/server/__smoke__/compare-responses.js @@ -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) { diff --git a/server/bgp-he-net.js b/server/bgp-he-net.js new file mode 100644 index 0000000..709c5de --- /dev/null +++ b/server/bgp-he-net.js @@ -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 }; diff --git a/server/resolve-as-names.js b/server/resolve-as-names.js new file mode 100644 index 0000000..226a692 --- /dev/null +++ b/server/resolve-as-names.js @@ -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 }; diff --git a/server/topology.js b/server/topology.js new file mode 100644 index 0000000..d9cbd39 --- /dev/null +++ b/server/topology.js @@ -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 }; diff --git a/server/whois.js b/server/whois.js new file mode 100644 index 0000000..d211453 --- /dev/null +++ b/server/whois.js @@ -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 };