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.
111 lines
4.9 KiB
JavaScript
111 lines
4.9 KiB
JavaScript
const localDb = require("../../local-db-client");
|
|
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
|
const { cacheGet, cacheSet } = require("../caches/response-caches");
|
|
|
|
// AS Relationships endpoint: /api/relationships?asn=X
|
|
// Returns upstream providers, downstream customers, and peers with resolved
|
|
// names. Based on RIPE Stat asn-neighbours.
|
|
async function handleRelationships(req, res, url) {
|
|
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
|
if (!rawAsn) {
|
|
res.writeHead(400);
|
|
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
|
|
}
|
|
const cacheKey = "relationships:" + rawAsn;
|
|
const cached = cacheGet(cacheKey);
|
|
if (cached) {
|
|
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
|
return res.end(JSON.stringify(cached));
|
|
}
|
|
const start = Date.now();
|
|
try {
|
|
const neighbourData = await fetchRipeStatCached(
|
|
"https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn + "&lod=1",
|
|
{ timeout: 8000 }
|
|
);
|
|
const neighbours = (neighbourData && neighbourData.data && neighbourData.data.neighbours) || [];
|
|
const counts = (neighbourData && neighbourData.data && neighbourData.data.neighbour_counts) || {};
|
|
|
|
const upstreams = neighbours.filter(n => n.type === "left").sort((a,b) => (b.power||0)-(a.power||0));
|
|
const downstreams = neighbours.filter(n => n.type === "right").sort((a,b) => (b.power||0)-(a.power||0));
|
|
const peers = neighbours.filter(n => n.type === "uncertain").sort((a,b) => (b.power||0)-(a.power||0));
|
|
|
|
// Resolve AS names for top entries (upstreams + downstreams all, top 20 peers)
|
|
const toResolve = [...upstreams, ...downstreams, ...peers.slice(0, 20)];
|
|
const resolvedNames = {};
|
|
await Promise.race([
|
|
Promise.all(toResolve.map(n =>
|
|
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
|
|
.then(r => { if (r && r.data && r.data.holder) resolvedNames[n.asn] = r.data.holder; })
|
|
.catch(() => {})
|
|
)),
|
|
new Promise(r => setTimeout(r, 5000)),
|
|
]);
|
|
|
|
// ---- Threat Intelligence Enrichment ----
|
|
// Enrich neighbors with threat status from local threat_intel table
|
|
const threatMap = {};
|
|
const allNeighborsForThreat = [...upstreams, ...downstreams, ...peers];
|
|
const threatPromises = allNeighborsForThreat.slice(0, 100).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(`[Relationships Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
|
|
}
|
|
});
|
|
await Promise.race([
|
|
Promise.all(threatPromises),
|
|
new Promise(r => setTimeout(r, 3000)),
|
|
]);
|
|
|
|
const fmt = n => ({
|
|
asn: n.asn,
|
|
name: resolvedNames[n.asn] || "",
|
|
power: n.power || 0,
|
|
v4_peers: n.v4_peers || 0,
|
|
v6_peers: n.v6_peers || 0,
|
|
threat_level: threatMap[n.asn]?.threat_level || null,
|
|
threat_confidence: threatMap[n.asn]?.confidence_score || null,
|
|
threat_source: threatMap[n.asn]?.source || null,
|
|
});
|
|
|
|
const result = {
|
|
asn: parseInt(rawAsn),
|
|
query_time: new Date().toISOString(),
|
|
duration_ms: Date.now() - start,
|
|
counts: {
|
|
upstreams: counts.left || upstreams.length,
|
|
downstreams: counts.right || downstreams.length,
|
|
peers_total: counts.unique || peers.length,
|
|
uncertain: counts.uncertain || peers.length,
|
|
},
|
|
upstreams: upstreams.map(fmt),
|
|
downstreams: downstreams.map(fmt),
|
|
peers: peers.slice(0, 50).map(fmt),
|
|
methodology: "RIPE Stat asn-neighbours API. left=upstream providers (carry your traffic), right=downstream customers (you carry their traffic), uncertain=lateral peers. Sorted by power score (number of prefixes seen via this relationship).",
|
|
source_url: "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn,
|
|
};
|
|
|
|
// A neighbourData fetch failure (not a genuine zero-neighbour ASN) would
|
|
// otherwise get cached as "0 upstreams/downstreams/peers" for the full 10min --
|
|
// match the existing /api/validate precedent (90s TTL for suspicious zero counts).
|
|
cacheSet(cacheKey, result, neighbourData ? 10 * 60 * 1000 : 90 * 1000);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify(result, null, 2));
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
return res.end(JSON.stringify({ error: "Relationships lookup failed", message: err.message }));
|
|
}
|
|
|
|
}
|
|
|
|
module.exports = { handleRelationships };
|