Rene Fichtmueller cba7680de0 refactor: decompose /api/lookup into server/routes/lookup/{index,facilities,enrich-neighbours,rir-country,routing-info,cross-check}.js
The 662-line handler (the largest in the file) split into one function
per data-source/merge stage: facility+IX-location coordinate resolution
(facilities.js), neighbour name-resolution + threat-intel enrichment
(enrich-neighbours.js), RIR/country derivation cascade (rir-country.js),
visibility/prefix-size computation (routing-info.js), multi-source
cross-checks + data-quality scoring (cross-check.js), and a slim
orchestrator (index.js) running Phase 0/1 fetch and final result assembly.

Verified via node --check, eslint no-undef (2 known bugs remain: compare,
webhooks), and smoke-test harness (28/28 match, including a byte-for-byte
match on /api/lookup's own large response body -- the strongest possible
confirmation this, the most complex handler in the file, was decomposed
without changing behavior).

server.js: 2917 -> 1734 lines (6838 originally -- 75% reduction so far).
2026-07-17 07:52:22 +02:00

338 lines
16 KiB
JavaScript

const localDb = require("../../../local-db-client");
const { fetchJSON } = require("../../services/http-helpers");
const { fetchPeeringDB, fetchPeeringDBWithRetry } = require("../../services/peeringdb");
const { ensureAspaCache, validateRPKIWithCache } = require("../../services/rpki");
const { computeResilienceScore } = require("../../resilience-score");
const { computeRouteLeakDetection } = require("../../route-leak");
const { pdbSourceCache } = require("../../caches/pdb-source-cache");
const {
cacheGet, cacheSet, CACHE_TTL_LOOKUP, CACHE_TTL_LOOKUP_DEGRADED,
rdapCacheGet, rdapCacheSet,
} = require("../../caches/response-caches");
const { resolveFacilitiesAndIxLocations } = require("./facilities");
const { enrichNeighbours } = require("./enrich-neighbours");
const { deriveRirAndCountry } = require("./rir-country");
const { computeRoutingInfo } = require("./routing-info");
const { computeCrossChecks } = require("./cross-check");
// Main lookup endpoint: /api/lookup?asn=X
async function handleLookup(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 asn = rawAsn;
const cacheKey = "lookup:" + asn;
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 {
// Phase 0: Get PDB net first — check L2 cache, then API with retry
let pdbNet = pdbSourceCache.get("net", asn);
if (!pdbNet) {
pdbNet = await fetchPeeringDBWithRetry("/net?asn=" + asn);
if (pdbNet) pdbSourceCache.set("net", asn, pdbNet);
}
const net = pdbNet?.data?.[0] || {};
const netId = net.id;
// Phase 1: ALL calls in parallel — RIPE Stat (cached+throttled) + PDB IX/Fac (cached) + Atlas + bgp.he.net
const ixQuery = netId
? "/netixlan?net_id=" + netId + "&limit=1000"
: "/netixlan?asn=" + asn + "&limit=1000";
const ixCacheKey = netId ? String(netId) : "asn:" + asn;
// Check PDB source cache for IX/Fac data
let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey);
let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null;
// Per-source timing tracking — 9s hard cap per source to prevent long-tail blocking
const sourceTiming = {};
function timedFetch(name, promise) {
const ts = Date.now();
return Promise.race([
Promise.resolve(promise),
new Promise(function(r) { setTimeout(function() { r(null); }, 9000); }),
])
.then(function(r) { sourceTiming[name] = Date.now() - ts; return r; })
.catch(function() { sourceTiming[name] = null; return null; });
}
const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null;
// RDAP: check module-level cache first, only hit RIR endpoints on cache miss
const rdapCached = rdapCacheGet(asn);
const rdapPromise = rdapCached !== undefined
? Promise.resolve(rdapCached)
: Promise.race([
...["https://rdap.db.ripe.net/autnum/"+asn, "https://rdap.arin.net/registry/autnum/"+asn,
"https://rdap.apnic.net/autnum/"+asn, "https://rdap.lacnic.net/rdap/autnum/"+asn,
"https://rdap.afrinic.net/rdap/autnum/"+asn].map(url =>
fetchJSON(url, { timeout: 4000 })
.then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {}))
.catch(() => new Promise(() => {}))
),
new Promise(resolve => setTimeout(() => resolve(null), 5000)),
]).then(d => { rdapCacheSet(asn, d); return d; });
const promises = [
timedFetch("RIPE Stat Prefixes", localDb ? localDb.getRipeStatAnnouncedPrefixes(asn) : Promise.resolve(null)),
timedFetch("RIPE Stat Neighbours", localDb ? localDb.getRipeStatAsnNeighbours(asn) : Promise.resolve(null)),
timedFetch("RIPE Stat Overview", localDb ? localDb.getRipeStatAsOverview(asn) : Promise.resolve(null)),
timedFetch("RIPE Stat RIR", Promise.resolve(null)),
timedFetch("RIPE Atlas", Promise.resolve(null)),
timedFetch("bgp.he.net", Promise.resolve(null)),
timedFetch("RIPE Stat Visibility", localDb ? localDb.getRipeStatVisibility(asn) : Promise.resolve(null)),
timedFetch("RIPE Stat PrefixSize", localDb ? localDb.getRipeStatPrefixSizeDistribution(asn) : Promise.resolve(null)),
timedFetch("PeeringDB IXLan", cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery)),
timedFetch("PeeringDB Facilities", cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))),
timedFetch("PeeringDB Contacts", pocQuery ? fetchPeeringDB(pocQuery).catch(() => null) : Promise.resolve(null)),
timedFetch("RDAP Registration", rdapPromise),
];
const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData, pocData, rdapData] = await Promise.all(promises);
// Store PDB results in L2 source cache for future lookups
if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData);
if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData);
// local-db-client's getRipeStat* functions return status:"error" (not "ok") when
// the local Postgres DB query itself failed -- track which sources actually
// failed so we don't cache a DB hiccup's fake-empty result as if it were a
// genuine "this ASN has 0 prefixes" answer for the full 5-minute TTL.
const degradedSources = [
["RIPE Stat Prefixes", prefixData], ["RIPE Stat Neighbours", neighbourData],
["RIPE Stat Overview", overviewData], ["RIPE Stat Visibility", visibilityData],
["RIPE Stat PrefixSize", prefixSizeData],
].filter(([, v]) => v && v.status === "error").map(([name]) => name);
const prefixes = prefixData?.data?.prefixes || [];
const neighbours = neighbourData?.data?.neighbours || [];
const overview = overviewData?.data || {};
const rirEntries = rirData?.data?.located_resources || rirData?.data?.rir_stats || [];
// Bug 6 fix: Atlas probe status uses status.name (object), not status_name (flat)
const atlasProbes = atlasProbeData?.results || [];
const atlasConnected = atlasProbes.filter(p => {
const sName = (p.status_name || (p.status && p.status.name) || "").toLowerCase();
return sName === "connected";
});
const atlasAnchors = atlasProbes.filter(p => p.is_anchor === true);
// RPKI: validate ALL prefixes using local Cloudflare RPKI data (all 5 RIRs, instant)
await ensureAspaCache();
const allPrefixes = prefixes.map((p) => p.prefix);
const rpkiAllResults = await Promise.all(allPrefixes.map((pfx) => validateRPKIWithCache(asn, pfx)));
const ixConnections = (ixlanData?.data || [])
.map((ix) => ({
ix_name: ix.name || "",
ix_id: ix.ix_id,
speed_mbps: ix.speed || 0,
ipv4: ix.ipaddr4 || null,
ipv6: ix.ipaddr6 || null,
city: ix.city || "",
is_rs_peer: ix.is_rs_peer === true,
}))
.sort((a, b) => b.speed_mbps - a.speed_mbps);
const facilitiesRaw = (facData?.data || []).map((f) => ({
fac_id: f.fac_id,
name: f.name || "",
city: f.city || "",
country: f.country || "",
}));
const { facilities, ixLocations } = await resolveFacilitiesAndIxLocations(facilitiesRaw, ixConnections);
const rpkiStatuses = rpkiAllResults;
const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length;
const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length;
const rpkiUnavailable = rpkiStatuses.filter((r) => r.status === "unavailable").length;
const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid" && r.status !== "unavailable").length;
// Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
// must not lower a network's displayed RPKI coverage percentage.
const rpkiTotal = rpkiStatuses.length - rpkiUnavailable;
const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0;
let upstreams = neighbours
.filter((n) => n.type === "left")
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
.sort((a, b) => b.power - a.power);
let downstreams = neighbours
.filter((n) => n.type === "right")
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
.sort((a, b) => b.power - a.power);
let peers = neighbours
.filter((n) => n.type === "uncertain" || n.type === "peer")
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
.sort((a, b) => b.power - a.power);
const enriched = await enrichNeighbours(upstreams, downstreams, peers);
upstreams = enriched.upstreams;
downstreams = enriched.downstreams;
peers = enriched.peers;
const { rir, country } = deriveRirAndCountry(rirEntries, rirData, rdapData, bgpHeData);
const duration = Date.now() - start;
const routingInfo = await computeRoutingInfo(prefixes, visibilityData, prefixSizeData);
const { rpkiCrossCheck, prefixCrossCheck, neighbourCrossCheck, dataQuality } =
await computeCrossChecks(asn, allPrefixes, rpkiStatuses, prefixes, neighbours, bgpHeData);
const result = {
meta: {
service: "PeerCortex",
version: "0.6.9",
query: "AS" + asn,
duration_ms: duration,
sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"],
timestamp: new Date().toISOString(),
rpki_prefixes_checked: rpkiTotal,
total_prefixes: prefixes.length,
degraded_sources: degradedSources,
},
network: {
asn: parseInt(asn),
name: net.name || overview?.holder || (bgpHeData && bgpHeData.name_from_title) || "Unknown",
aka: net.aka || "",
org_name: (net.org && net.org.name) ? net.org.name : "",
website: net.website || "",
type: net.info_type || "",
policy: net.policy_general || "",
traffic: net.info_traffic || "",
ratio: net.info_ratio || "",
scope: net.info_scope || "",
notes: net.notes ? net.notes.substring(0, 500) : "",
peeringdb_id: netId || null,
rir: rir,
country: country,
city: net.city || "",
latitude: (net.latitude != null) ? net.latitude : null,
longitude: (net.longitude != null) ? net.longitude : null,
looking_glass: net.looking_glass || "",
route_server: net.route_server || "",
info_prefixes4: net.info_prefixes4 || 0,
info_prefixes6: net.info_prefixes6 || 0,
status: net.status || "",
peeringdb_created: net.created ? net.created.slice(0, 10) : "",
peeringdb_updated: net.updated ? net.updated.slice(0, 10) : "",
},
prefixes: {
total: prefixes.length,
ipv4: prefixes.filter((p) => !p.prefix.includes(":")).length,
ipv6: prefixes.filter((p) => p.prefix.includes(":")).length,
list: prefixes.map((p) => p.prefix),
cross_check: prefixCrossCheck,
},
rpki: {
coverage_percent: rpkiCoverage,
valid: rpkiValid,
invalid: rpkiInvalid,
not_found: rpkiNotFound,
unavailable: rpkiUnavailable,
checked: rpkiTotal,
details: rpkiStatuses,
cross_check: rpkiCrossCheck,
},
neighbours: {
total: neighbours.length,
upstream_count: upstreams.length,
downstream_count: downstreams.length,
peer_count: peers.length,
upstreams: upstreams.slice(0, 20),
downstreams: downstreams.slice(0, 20),
peers: peers.slice(0, 20),
cross_check: neighbourCrossCheck,
},
ix_presence: {
total_connections: ixConnections.length,
unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length,
connections: ixConnections,
},
ix_locations: ixLocations,
facilities: {
total: facilities.length,
list: facilities,
},
routing: routingInfo,
resilience_score: computeResilienceScore(upstreams, peers, ixConnections, prefixes),
route_leak: computeRouteLeakDetection(upstreams, downstreams, peers),
bgp_he_net: bgpHeData || null,
atlas: {
total_probes: atlasProbes.length,
connected: atlasConnected.length,
disconnected: atlasProbes.length - atlasConnected.length,
anchors: atlasAnchors.length,
probes: atlasProbes.slice(0, 100).map(p => ({
id: p.id,
status: p.status_name || p.status || "Unknown",
is_anchor: p.is_anchor || false,
country: p.country_code || "",
prefix_v4: p.prefix_v4 || "",
prefix_v6: p.prefix_v6 || "",
description: p.description || "",
})),
},
data_quality: dataQuality,
source_timing: sourceTiming,
contacts: (() => {
const pocs = (pocData && pocData.data) ? pocData.data : [];
return pocs.slice(0, 20).map(p => ({
role: p.role || "",
name: p.name || "",
email: p.email || "",
url: p.url || "",
visible: p.visible || "",
}));
})(),
registration: (() => {
const events = (rdapData && rdapData.events) ? rdapData.events : [];
const created = (events.find(e => e.eventAction === "registration") || {}).eventDate || "";
const lastChg = (events.find(e => e.eventAction === "last changed") || {}).eventDate || "";
return {
created: created ? created.slice(0, 10) : "",
last_modified: lastChg ? lastChg.slice(0, 10) : "",
rir: rir || "",
handle: (rdapData && rdapData.handle) ? rdapData.handle : ("AS" + asn),
rdap_source: (rdapData && rdapData.port43) ? rdapData.port43 : "",
};
})(),
_provenance: {
prefixes: { source: "RIPE Stat announced-prefixes", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net prefix count daily" },
neighbours: { source: "RIPE Stat asn-neighbours", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net peer count daily" },
rpki: { source: "Cloudflare RPKI + RIPE Validator", validation: "cross-validated", confidence: "high", note: "Two independent RPKI sources compared" },
ix_presence: { source: "PeeringDB netixlan (local mirror)", validation: "cross-validated", confidence: "high", note: "PeeringDB mirror refreshed daily" },
facilities: { source: "PeeringDB netfac (local mirror)", validation: "single-source", confidence: "medium" },
bgp_he_net: { source: "bgp.he.net HTML scrape", validation: "single-source", confidence: "medium", note: "HTML scrape, no official API — may have parsing drift" },
atlas: { source: "RIPE Atlas API", validation: "single-source", confidence: "medium", note: "Probe availability varies by region" },
resilience_score: { source: "Computed from RIPE Stat + PeeringDB", validation: "computed", confidence: "high", note: "All inputs cross-validated daily" },
route_leak: { source: "RIPE Stat asn-neighbours heuristic", validation: "heuristic", confidence: "medium", note: "Pattern-based, not real-time — false positives possible" },
registration: { source: "RDAP (RIR registry)", validation: "single-source", confidence: "high" },
contacts: { source: "PeeringDB POC API", validation: "single-source", confidence: "medium", note: "Subject to PeeringDB rate limiting" },
},
};
// Update duration to include cross-check time
result.meta.duration_ms = Date.now() - start;
// A DB hiccup shouldn't get cached as this ASN's answer for the full 5min --
// retry soon instead of repeating a degraded result to every visitor.
cacheSet(cacheKey, result, degradedSources.length > 0 ? CACHE_TTL_LOOKUP_DEGRADED : CACHE_TTL_LOOKUP);
res.end(JSON.stringify(result, null, 2));
} catch (err) {
const duration = Date.now() - start;
res.writeHead(500);
res.end(JSON.stringify({ error: "Lookup failed", message: err.message, duration_ms: duration }));
}
return;
}
module.exports = { handleLookup };