Bug fix (agreed pre-existing bug #2 of 3): /api/compare called fetchRPKIPerPrefix(asn, prefix), never defined anywhere in the codebase -- threw ReferenceError whenever either ASN had >=1 sample prefix, caught by the outer try/catch, returning 500. Replaced both call sites with validateRPKIWithCache(asn, prefix), the existing single-prefix RPKI check used everywhere else in the codebase -- its return shape ({status, ...}) is exactly what the surrounding code already expects. Verified via node --check, eslint no-undef (only 1 known bug remains: webhooks POST's `secret`), and smoke-test harness (28/28 match). The compare fix itself isn't exercised by the smoke test's fixed test ASNs (both return empty prefix samples locally), but the fix is a direct, same-shape substitution of an already-proven function. server.js: 1736 -> 989 lines.
176 lines
8.2 KiB
JavaScript
176 lines
8.2 KiB
JavaScript
const localDb = require("../../local-db-client");
|
|
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
|
const { fetchPeeringDB } = require("../services/peeringdb");
|
|
const { validateRPKIWithCache } = require("../services/rpki");
|
|
const { cacheGet, cacheSet, CACHE_TTL_DEFAULT } = require("../caches/response-caches");
|
|
|
|
// Compare endpoint: /api/compare?asn1=X&asn2=Y
|
|
async function handleCompare(req, res, url) {
|
|
const asn1 = (url.searchParams.get("asn1") || "").replace(/[^0-9]/g, "");
|
|
const asn2 = (url.searchParams.get("asn2") || "").replace(/[^0-9]/g, "");
|
|
if (!asn1 || !asn2) {
|
|
res.writeHead(400);
|
|
return res.end(JSON.stringify({ error: "Need asn1 and asn2 parameters" }));
|
|
}
|
|
|
|
const compareCacheKey = "compare:" + asn1 + ":" + asn2;
|
|
const compareCached = cacheGet(compareCacheKey);
|
|
if (compareCached) {
|
|
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
|
return res.end(JSON.stringify(compareCached));
|
|
}
|
|
const start = Date.now();
|
|
try {
|
|
// ALL calls in parallel — single batch
|
|
// Phase 1: Get PDB net objects + RIPE data
|
|
const [pdb1, pdb2, nb1Data, nb2Data, pfx1Data, pfx2Data] = await Promise.all([
|
|
fetchPeeringDB("/net?asn=" + asn1),
|
|
fetchPeeringDB("/net?asn=" + asn2),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn1, { timeout: 8000 }),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2, { timeout: 8000 }),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1, { timeout: 8000 }),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2, { timeout: 8000 }),
|
|
]);
|
|
|
|
const net1 = pdb1?.data?.[0] || {};
|
|
const net2 = pdb2?.data?.[0] || {};
|
|
const netId1 = net1.id;
|
|
const netId2 = net2.id;
|
|
|
|
// Phase 2: IX + Facility using net_id (Bug 1 fix: netfac requires net_id, not asn)
|
|
const ixFacPromises = [];
|
|
ixFacPromises.push(netId1 ? fetchPeeringDB("/netixlan?net_id=" + netId1) : Promise.resolve(null));
|
|
ixFacPromises.push(netId2 ? fetchPeeringDB("/netixlan?net_id=" + netId2) : Promise.resolve(null));
|
|
ixFacPromises.push(netId1 ? fetchPeeringDB("/netfac?net_id=" + netId1) : Promise.resolve(null));
|
|
ixFacPromises.push(netId2 ? fetchPeeringDB("/netfac?net_id=" + netId2) : Promise.resolve(null));
|
|
const [ix1Data, ix2Data, fac1Data, fac2Data] = await Promise.all(ixFacPromises);
|
|
|
|
const ix1Set = new Set((ix1Data?.data || []).map((ix) => ix.ix_id));
|
|
const ix2Set = new Set((ix2Data?.data || []).map((ix) => ix.ix_id));
|
|
const ix1Names = {};
|
|
(ix1Data?.data || []).forEach((ix) => (ix1Names[ix.ix_id] = ix.name));
|
|
const ix2Names = {};
|
|
(ix2Data?.data || []).forEach((ix) => (ix2Names[ix.ix_id] = ix.name));
|
|
|
|
const commonIX = [...ix1Set].filter((id) => ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || ix2Names[id] || "" }));
|
|
const only1IX = [...ix1Set].filter((id) => !ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || "" }));
|
|
const only2IX = [...ix2Set].filter((id) => !ix1Set.has(id)).map((id) => ({ ix_id: id, name: ix2Names[id] || "" }));
|
|
|
|
const fac1Set = new Set((fac1Data?.data || []).map((f) => f.fac_id));
|
|
const fac2Set = new Set((fac2Data?.data || []).map((f) => f.fac_id));
|
|
const fac1Names = {};
|
|
(fac1Data?.data || []).forEach((f) => (fac1Names[f.fac_id] = f.name));
|
|
const fac2Names = {};
|
|
(fac2Data?.data || []).forEach((f) => (fac2Names[f.fac_id] = f.name));
|
|
|
|
const commonFac = [...fac1Set].filter((id) => fac2Set.has(id)).map((id) => ({ fac_id: id, name: fac1Names[id] || fac2Names[id] || "" }));
|
|
|
|
const nb1 = (nb1Data?.data?.neighbours || []).filter((n) => n.type === "left");
|
|
const nb2 = (nb2Data?.data?.neighbours || []).filter((n) => n.type === "left");
|
|
const up1Set = new Set(nb1.map((n) => n.asn));
|
|
const up2Set = new Set(nb2.map((n) => n.asn));
|
|
const nb1Map = {};
|
|
nb1.forEach((n) => (nb1Map[n.asn] = n.as_name || ""));
|
|
const nb2Map = {};
|
|
nb2.forEach((n) => (nb2Map[n.asn] = n.as_name || ""));
|
|
|
|
const commonUpstreams = [...up1Set]
|
|
.filter((a) => up2Set.has(a))
|
|
.map((a) => ({ asn: a, name: nb1Map[a] || nb2Map[a] || "" }));
|
|
|
|
// ---- Threat Intelligence Enrichment for Common Upstreams ----
|
|
const threatMap = {};
|
|
const threatPromises = commonUpstreams.slice(0, 50).map(async (n) => {
|
|
try {
|
|
const asNum = String(n.asn);
|
|
const threat = await localDb.getThreatIntel(asNum);
|
|
if (threat) {
|
|
threatMap[n.asn] = {
|
|
threat_level: threat.threat_level,
|
|
confidence_score: threat.confidence_score,
|
|
source: threat.source,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Compare Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
|
|
}
|
|
});
|
|
await Promise.race([
|
|
Promise.all(threatPromises),
|
|
new Promise(r => setTimeout(r, 2000)),
|
|
]);
|
|
|
|
// Attach threat status to upstream objects
|
|
commonUpstreams.forEach((n) => {
|
|
if (threatMap[n.asn]) {
|
|
n.threat_level = threatMap[n.asn].threat_level;
|
|
n.threat_confidence = threatMap[n.asn].confidence_score;
|
|
n.threat_source = threatMap[n.asn].source;
|
|
}
|
|
});
|
|
|
|
// Resolve names + RPKI sample (max 3+3 prefixes) all in parallel with 5s timeout
|
|
const pfx1 = (pfx1Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
|
|
const pfx2 = (pfx2Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
|
|
const [, rpki1Results, rpki2Results] = await Promise.race([
|
|
Promise.all([
|
|
commonUpstreams.length > 0 ? Promise.all(commonUpstreams.map(n =>
|
|
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
|
|
.then(r => { if (r?.data?.holder) n.name = r.data.holder; })
|
|
.catch(() => {})
|
|
)) : Promise.resolve([]),
|
|
Promise.all(pfx1.map((p) => validateRPKIWithCache(asn1, p))),
|
|
Promise.all(pfx2.map((p) => validateRPKIWithCache(asn2, p))),
|
|
]),
|
|
new Promise(r => setTimeout(() => r([[], [], []]), 5000)),
|
|
]);
|
|
|
|
const rpki1Valid = rpki1Results.filter((r) => r.status === "valid").length;
|
|
const rpki2Valid = rpki2Results.filter((r) => r.status === "valid").length;
|
|
const rpki1Pct = rpki1Results.length > 0 ? Math.round((rpki1Valid / rpki1Results.length) * 100) : 0;
|
|
const rpki2Pct = rpki2Results.length > 0 ? Math.round((rpki2Valid / rpki2Results.length) * 100) : 0;
|
|
|
|
const duration = Date.now() - start;
|
|
const compareResult = {
|
|
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
|
asn1: {
|
|
asn: parseInt(asn1),
|
|
name: net1.name || "Unknown",
|
|
ix_count: ix1Set.size,
|
|
fac_count: fac1Set.size,
|
|
upstream_count: up1Set.size,
|
|
rpki_coverage: rpki1Pct,
|
|
},
|
|
asn2: {
|
|
asn: parseInt(asn2),
|
|
name: net2.name || "Unknown",
|
|
ix_count: ix2Set.size,
|
|
fac_count: fac2Set.size,
|
|
upstream_count: up2Set.size,
|
|
rpki_coverage: rpki2Pct,
|
|
},
|
|
common_ixps: commonIX,
|
|
only_asn1_ixps: only1IX,
|
|
only_asn2_ixps: only2IX,
|
|
common_facilities: commonFac,
|
|
common_upstreams: commonUpstreams,
|
|
rpki_comparison: {
|
|
asn1_coverage: rpki1Pct,
|
|
asn2_coverage: rpki2Pct,
|
|
asn1_checked: rpki1Results.length,
|
|
asn2_checked: rpki2Results.length,
|
|
better: rpki1Pct > rpki2Pct ? "AS" + asn1 : rpki2Pct > rpki1Pct ? "AS" + asn2 : "equal",
|
|
},
|
|
};
|
|
cacheSet(compareCacheKey, compareResult, CACHE_TTL_DEFAULT);
|
|
res.end(JSON.stringify(compareResult, null, 2));
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
res.end(JSON.stringify({ error: "Compare failed", message: err.message }));
|
|
}
|
|
return;
|
|
|
|
}
|
|
|
|
module.exports = { handleCompare };
|