PeerCortex/server/routes/relationships.js
Rene Fichtmueller 49aa46fef3 refactor: extract relationships/compare/quick-ix/peers-find/prefix-detail/ix-detail/topology/whois/enrich routes
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.
2026-07-17 07:57:04 +02:00

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