From 1120d81dfc0d194d07cc2f0501b1369e86b35e5f Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Thu, 26 Mar 2026 16:02:14 +1300 Subject: [PATCH] feat: RPKI-based ASPA detection via Cloudflare feed (1455 objects), collapsible lists, sorted ASNs, Route Views --- deploy/public/index.html | 27 +++++-- deploy/server.js | 171 ++++++++++++++++++++++++++------------- 2 files changed, 139 insertions(+), 59 deletions(-) diff --git a/deploy/public/index.html b/deploy/public/index.html index 80b53a7..2756306 100644 --- a/deploy/public/index.html +++ b/deploy/public/index.html @@ -483,9 +483,9 @@ a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var( bgp.he.net · bgproutes.io · RIPE DB · - RPKI + Cloudflare RPKI - PeerCortex v0.4.0 — Open Source — MIT License
+ PeerCortex v0.5.0 — Open Source — MIT License
PaperCortex · PeerCortex GitHub @@ -653,11 +653,16 @@ function renderAspa(d) { h += '
'; h += '
ASPA Object
'; if (d.aspa_object_exists) { - h += '
Found in RIPE DB
'; + h += '
Found in RPKI
'; } else { h += '
Not Found
'; } h += '
'; + if (d.aspa_object_exists && d.aspa_declared_providers && d.aspa_declared_providers.length > 0) { + h += '
RPKI-Declared Providers
'; + h += '
' + d.aspa_declared_count + '
'; + h += '
'; + } h += '
Detected Providers
'; h += '
' + d.provider_count + '
'; h += '
'; @@ -669,7 +674,7 @@ function renderAspa(d) { // Detected providers (collapsible after 10) if (d.detected_providers && d.detected_providers.length > 0) { var provLimit = 10; - var provList = d.detected_providers; + var provList = d.detected_providers.slice().sort(function(a, b) { return a.asn - b.asn; }); h += '
Detected Upstream Providers (' + provList.length + ')
'; h += '
'; provList.slice(0, provLimit).forEach(function(p) { @@ -689,6 +694,18 @@ function renderAspa(d) { } } + // RPKI-declared providers (when ASPA object exists) + if (d.aspa_object_exists && d.aspa_declared_providers && d.aspa_declared_providers.length > 0) { + var declaredList = d.aspa_declared_providers.slice().sort(function(a, b) { return a.asn - b.asn; }); + h += '
RPKI-Declared Providers (' + declaredList.length + ')
'; + h += '
'; + declaredList.forEach(function(p) { + var label = p.asn === 0 ? 'AS0 (Tier-1 / No Provider)' : asnLink(p.asn); + h += '' + label + ''; + }); + h += '
'; + } + // Recommended ASPA template (scrollable, max 200px) if (d.recommended_aspa) { h += '
Recommended ASPA Object
'; @@ -1268,7 +1285,7 @@ function renderAspaDeep(d) { if (d.detected_providers && d.detected_providers.length > 0) { h += '
Detected Providers (by frequency)
'; h += '
'; - var sortedProviders = (d.detected_providers || []).slice().sort(function(a, b) { return (b.frequency || 0) - (a.frequency || 0); }); + var sortedProviders = (d.detected_providers || []).slice().sort(function(a, b) { return (b.frequency || 0) - (a.frequency || 0) || a.asn - b.asn; }); sortedProviders.forEach(function(p) { h += ''; var provName = (p.name && p.name !== 'AS' + p.asn) ? escHtml(p.name) : ''; diff --git a/deploy/server.js b/deploy/server.js index 7af18f0..4b79e9c 100644 --- a/deploy/server.js +++ b/deploy/server.js @@ -23,7 +23,7 @@ try { const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || ""; const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1"; -const UA = "PeerCortex/0.4.0 (https://github.com/renefichtmueller/PeerCortex)"; +const UA = "PeerCortex/0.5.0 (https://github.com/renefichtmueller/PeerCortex)"; // ============================================================ // Task 6: In-memory cache with TTL + Rate Limiting @@ -56,6 +56,70 @@ const CACHE_TTL_ASPA = 10 * 60 * 1000; // 10 minutes const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 minutes +// ============================================================ +// RPKI ASPA Cache from Cloudflare RPKI JSON feed +// ============================================================ +const rpkiAspaMap = new Map(); // customer_asid -> Set +let rpkiAspaLastFetch = 0; +let rpkiAspaFetching = false; + +function fetchRpkiAspaFeed() { + if (rpkiAspaFetching) return Promise.resolve(); + rpkiAspaFetching = true; + console.log("[RPKI-ASPA] Fetching Cloudflare RPKI feed..."); + return new Promise((resolve) => { + const options = { + headers: { "User-Agent": UA }, + timeout: 30000, + }; + https.get("https://rpki.cloudflare.com/rpki.json", options, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + try { + const parsed = JSON.parse(data); + const aspas = parsed.aspas || []; + rpkiAspaMap.clear(); + aspas.forEach((a) => { + const customerAsid = Number(a.customer_asid); + const providers = (a.providers || []).map(Number); + rpkiAspaMap.set(customerAsid, new Set(providers)); + }); + rpkiAspaLastFetch = Date.now(); + console.log("[RPKI-ASPA] Loaded " + rpkiAspaMap.size + " ASPA objects from Cloudflare RPKI feed"); + } catch (e) { + console.error("[RPKI-ASPA] Failed to parse RPKI feed:", e.message); + } + rpkiAspaFetching = false; + resolve(); + }); + }).on("error", (e) => { + console.error("[RPKI-ASPA] Fetch failed:", e.message); + rpkiAspaFetching = false; + resolve(); + }); + }); +} + +// Ensure ASPA cache is fresh (fetch if older than 10 minutes) +async function ensureAspaCache() { + if (Date.now() - rpkiAspaLastFetch > 10 * 60 * 1000) { + await fetchRpkiAspaFeed(); + } +} + +// Lookup ASPA object for a given ASN from the RPKI feed cache +function lookupAspaFromRpki(asn) { + const asnNum = Number(asn); + if (rpkiAspaMap.has(asnNum)) { + const providers = rpkiAspaMap.get(asnNum); + return { exists: true, providers: [...providers].sort((a, b) => a - b) }; + } + return { exists: false, providers: [] }; +} + + + // Rate limiting: max 60 requests per minute per IP const rateLimitMap = new Map(); const RATE_LIMIT_WINDOW = 60 * 1000; @@ -602,7 +666,7 @@ const server = http.createServer(async (req, res) => { JSON.stringify({ status: "ok", service: "PeerCortex", - version: "0.3.0", + version: "0.5.0", timestamp: new Date().toISOString(), uptime_seconds: Math.floor(process.uptime()), bgproutes_configured: !!BGPROUTES_API_KEY, @@ -690,36 +754,29 @@ const server = http.createServer(async (req, res) => { } }); - // Check RIPE DB for ASPA object - let aspaObjectExists = false; - let aspaDeclaredProviders = []; - try { - const ripeDbInfo = await fetchJSON( - "https://rest.db.ripe.net/search.json?query-string=AS" + - rawAsn + - "&type-filter=aut-num&source=ripe" - ); - const objects = ripeDbInfo?.objects?.object || []; - objects.forEach((obj) => { - const attrs = obj.attributes?.attribute || []; - attrs.forEach((attr) => { - if (attr.name === "remarks" && attr.value && attr.value.toLowerCase().includes("aspa")) { - aspaObjectExists = true; - } - if (attr.name === "import" || attr.name === "mp-import") { - const match = (attr.value || "").match(/from\s+AS(\d+)/i); - if (match) { - aspaDeclaredProviders.push(parseInt(match[1])); - } - } - }); - }); - } catch (_e) { - // RIPE DB query failed - } + // Check Cloudflare RPKI feed for ASPA object + await ensureAspaCache(); + const aspaLookup = lookupAspaFromRpki(targetAsn); + const aspaObjectExists = aspaLookup.exists; + const aspaDeclaredProviders = aspaLookup.providers; - // Build ASPA store and run verification - const aspaStore = buildAspaStore(detectedProviders, targetAsn); + // Build ASPA store from RPKI feed data (real ASPA objects) + const aspaStore = new Map(); + // Add the target ASN's RPKI-declared providers + if (aspaObjectExists) { + aspaStore.set(targetAsn, new Set(aspaDeclaredProviders)); + } else { + // Fallback: use detected providers for path verification + const providerSet = new Set(detectedProviders.map((p) => p.asn)); + aspaStore.set(targetAsn, providerSet); + } + // Also populate store with all known ASPA objects from the RPKI feed + // for providers that have their own ASPA objects (enables full path verification) + for (const [cas, provSet] of rpkiAspaMap) { + if (!aspaStore.has(cas)) { + aspaStore.set(cas, provSet); + } + } // Also add reverse relationships for providers we know about // (each provider has the target as customer) @@ -913,26 +970,14 @@ const server = http.createServer(async (req, res) => { await resolveASNames(detectedProviders); - let aspaObjectExists = false; - try { - const ripeDbInfo = await fetchJSON( - "https://rest.db.ripe.net/search.json?query-string=AS" + - rawAsn + - "&type-filter=aut-num&source=ripe" - ); - const objects = ripeDbInfo?.objects?.object || []; - objects.forEach((obj) => { - const attrs = obj.attributes?.attribute || []; - attrs.forEach((attr) => { - if (attr.name === "remarks" && attr.value && attr.value.toLowerCase().includes("aspa")) { - aspaObjectExists = true; - } - }); - }); - } catch (_e) {} + // 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(", "); - const recommendedAspa = + let recommendedAspa = "aut-num: AS" + rawAsn + "\n" + "# Recommended ASPA object:\n" + "# customer: AS" + rawAsn + "\n" + @@ -942,6 +987,12 @@ const server = http.createServer(async (req, res) => { "# 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)); @@ -964,6 +1015,8 @@ const server = http.createServer(async (req, res) => { detected_providers: detectedProviders, provider_count: detectedProviders.length, aspa_object_exists: aspaObjectExists, + aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })), + aspa_declared_count: aspaDeclaredProviders.length, recommended_aspa: recommendedAspa, path_analysis: { total_paths_seen: asPaths.length, @@ -1597,10 +1650,10 @@ const server = http.createServer(async (req, res) => { const result = { meta: { service: "PeerCortex", - version: "0.3.0", + version: "0.5.0", query: "AS" + asn, duration_ms: duration, - sources: ["PeeringDB", "RIPE Stat", "bgp.he.net"], + sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "Route Views"], timestamp: new Date().toISOString(), rpki_prefixes_checked: rpkiTotal, total_prefixes: prefixes.length, @@ -2081,7 +2134,17 @@ const server = http.createServer(async (req, res) => { }); const PORT = process.env.PORT || 3101; -server.listen(PORT, "0.0.0.0", () => { - console.log("PeerCortex v0.3.0 running on http://0.0.0.0:" + PORT); - console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured")); + +// Fetch RPKI ASPA feed at startup and refresh every 10 minutes +fetchRpkiAspaFeed().then(() => { + server.listen(PORT, "0.0.0.0", () => { + console.log("PeerCortex v0.4.0 running on http://0.0.0.0:" + PORT); + console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured")); + console.log("RPKI ASPA objects loaded: " + rpkiAspaMap.size); + }); }); + +// Refresh RPKI ASPA cache every 10 minutes +setInterval(() => { + fetchRpkiAspaFeed(); +}, 10 * 60 * 1000);
ProviderNameSeen in PathsFrequency
' + asnLink(p.asn) + '