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:
parent
c5cf1f1da8
commit
a5912a3d0c
260
server.js
260
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>([^<]+)<\/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
|
||||
|
||||
@ -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": [
|
||||
{
|
||||
|
||||
@ -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
53
server/bgp-he-net.js
Normal 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 };
|
||||
23
server/resolve-as-names.js
Normal file
23
server/resolve-as-names.js
Normal 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
49
server/topology.js
Normal 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
146
server/whois.js
Normal 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 };
|
||||
Loading…
x
Reference in New Issue
Block a user