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).
338 lines
16 KiB
JavaScript
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 };
|