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 };