Verified via node --check, eslint no-undef (2 known bugs remain: compare, webhooks), and smoke-test harness (28/28 match).
134 lines
5.6 KiB
JavaScript
134 lines
5.6 KiB
JavaScript
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
|
const { resolveASNames } = require("../resolve-as-names");
|
|
const { ensureAspaCache, lookupAspaFromRpki } = require("../services/rpki");
|
|
const { aspaResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches");
|
|
|
|
// ASPA Check endpoint: /api/aspa?asn=X (existing, kept for compat)
|
|
async function handleAspaLegacy(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 cachedAspa = resultCacheGet(aspaResultCache, rawAsn);
|
|
if (cachedAspa !== undefined) {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify(cachedAspa));
|
|
}
|
|
const start = Date.now();
|
|
let _aspaDone = false;
|
|
const _aspaTimer = setTimeout(() => {
|
|
if (!_aspaDone) {
|
|
_aspaDone = true;
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) }));
|
|
}
|
|
}, 12000);
|
|
try {
|
|
const [lgData, neighbourData] = await Promise.all([
|
|
fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 3000 }).catch(() => null),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 4000 }),
|
|
]);
|
|
|
|
const rrcs = lgData?.data?.rrcs || [];
|
|
const asPaths = [];
|
|
|
|
rrcs.forEach((rrc) => {
|
|
const peers = rrc.peers || [];
|
|
peers.forEach((peer) => {
|
|
const path = peer.as_path || "";
|
|
const pathArr = path.split(" ").map(Number).filter(Boolean);
|
|
if (pathArr.length > 1) {
|
|
asPaths.push({ rrc: rrc.rrc, path: pathArr, prefix: peer.prefix || "" });
|
|
}
|
|
});
|
|
});
|
|
|
|
// Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
|
|
const neighbours = neighbourData?.data?.neighbours || [];
|
|
const leftNeighbours = neighbours.filter((n) => n.type === "left");
|
|
const upstreamSet = new Set();
|
|
leftNeighbours.forEach((n) => upstreamSet.add(n.asn));
|
|
|
|
// Classify left neighbours: high-power = likely upstream, low-power = likely peer
|
|
const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1);
|
|
const detectedProviders = [...upstreamSet].map((asn) => {
|
|
const nb = leftNeighbours.find((n) => n.asn === asn);
|
|
const power = nb ? (nb.power || 0) : 0;
|
|
const powerPct = Math.round((power / maxPower) * 100);
|
|
const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer";
|
|
return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification };
|
|
});
|
|
|
|
await resolveASNames(detectedProviders);
|
|
|
|
// Check Cloudflare RPKI feed for ASPA object
|
|
await ensureAspaCache();
|
|
const aspaLookup = lookupAspaFromRpki(rawAsn);
|
|
const aspaObjectExists = aspaLookup.exists;
|
|
const aspaDeclaredProviders = aspaLookup.providers;
|
|
|
|
const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", ");
|
|
let recommendedAspa =
|
|
"aut-num: AS" + rawAsn + "\n" +
|
|
"# Recommended ASPA object:\n" +
|
|
"# customer: AS" + rawAsn + "\n" +
|
|
"# provider-set: " + providerList + "\n" +
|
|
"# AFI: ipv4, ipv6\n" +
|
|
"#\n" +
|
|
"# Detected providers from BGP path analysis:\n" +
|
|
detectedProviders.map((p) => "# AS" + p.asn + (p.name ? " (" + p.name + ")" : "")).join("\n");
|
|
|
|
// If ASPA object exists, show RPKI-declared providers
|
|
if (aspaObjectExists && aspaDeclaredProviders.length > 0) {
|
|
recommendedAspa += "\n#\n# RPKI-declared providers (from Cloudflare RPKI feed):\n" +
|
|
aspaDeclaredProviders.map((a) => "# AS" + a).join("\n");
|
|
}
|
|
|
|
const samplePaths = asPaths.slice(0, 10).map((p) => {
|
|
const pathStr = p.path.map((a) => "AS" + a).join(" -> ");
|
|
const idx = p.path.indexOf(parseInt(rawAsn));
|
|
const provider = idx > 0 ? p.path[idx - 1] : null;
|
|
return {
|
|
rrc: p.rrc,
|
|
prefix: p.prefix,
|
|
path: pathStr,
|
|
detected_provider: provider ? "AS" + provider : null,
|
|
provider_in_set: provider ? upstreamSet.has(provider) : false,
|
|
};
|
|
});
|
|
|
|
if (_aspaDone) return; // hard timeout already responded
|
|
_aspaDone = true;
|
|
clearTimeout(_aspaTimer);
|
|
const duration = Date.now() - start;
|
|
const aspaResult = {
|
|
meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() },
|
|
asn: parseInt(rawAsn),
|
|
detected_providers: detectedProviders,
|
|
provider_count: detectedProviders.length,
|
|
aspa_object_exists: aspaObjectExists,
|
|
aspa_feed_healthy: aspaLookup.feedLoaded,
|
|
aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })),
|
|
aspa_declared_count: aspaDeclaredProviders.length,
|
|
recommended_aspa: recommendedAspa,
|
|
path_analysis: {
|
|
total_paths_seen: asPaths.length,
|
|
sample_paths: samplePaths,
|
|
},
|
|
};
|
|
resultCacheSet(aspaResultCache, rawAsn, aspaResult);
|
|
return res.end(JSON.stringify(aspaResult, null, 2));
|
|
} catch (err) {
|
|
if (!_aspaDone) {
|
|
_aspaDone = true;
|
|
clearTimeout(_aspaTimer);
|
|
res.writeHead(500);
|
|
return res.end(JSON.stringify({ error: "ASPA check failed", message: err.message }));
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
module.exports = { handleAspaLegacy };
|