feat: RPKI-based ASPA detection via Cloudflare feed (1455 objects), collapsible lists, sorted ASNs, Route Views
This commit is contained in:
parent
bd90113285
commit
1120d81dfc
@ -483,9 +483,9 @@ a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var(
|
||||
<a href="https://bgp.he.net" target="_blank">bgp.he.net</a> ·
|
||||
<a href="https://bgproutes.io" target="_blank">bgproutes.io</a> ·
|
||||
<a href="https://www.ripe.net/manage-ips-and-asns/db" target="_blank">RIPE DB</a> ·
|
||||
<a href="https://rpki-validator.ripe.net" target="_blank">RPKI</a>
|
||||
<a href="https://rpki.cloudflare.com" target="_blank">Cloudflare RPKI</a>
|
||||
</div>
|
||||
PeerCortex v0.4.0 — Open Source — MIT License<br>
|
||||
PeerCortex v0.5.0 — Open Source — MIT License<br>
|
||||
<a href="https://github.com/renefichtmueller/PaperCortex" target="_blank">PaperCortex</a> ·
|
||||
<a href="https://github.com/renefichtmueller/PeerCortex" target="_blank">PeerCortex GitHub</a>
|
||||
</footer>
|
||||
@ -653,11 +653,16 @@ function renderAspa(d) {
|
||||
h += '<div style="display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:1rem">';
|
||||
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">ASPA Object</div>';
|
||||
if (d.aspa_object_exists) {
|
||||
h += '<div class="status-yes">Found in RIPE DB</div>';
|
||||
h += '<div class="status-yes">Found in RPKI</div>';
|
||||
} else {
|
||||
h += '<div class="status-no">Not Found</div>';
|
||||
}
|
||||
h += '</div>';
|
||||
if (d.aspa_object_exists && d.aspa_declared_providers && d.aspa_declared_providers.length > 0) {
|
||||
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">RPKI-Declared Providers</div>';
|
||||
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--green)">' + d.aspa_declared_count + '</div>';
|
||||
h += '</div>';
|
||||
}
|
||||
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">Detected Providers</div>';
|
||||
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--blue)">' + d.provider_count + '</div>';
|
||||
h += '</div>';
|
||||
@ -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 += '<div style="font-size:.8rem;font-weight:600;color:var(--orange);margin:.75rem 0 .4rem">Detected Upstream Providers (' + provList.length + ')</div>';
|
||||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.25rem">';
|
||||
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 += '<div style="font-size:.8rem;font-weight:600;color:var(--green);margin:.75rem 0 .4rem">RPKI-Declared Providers (' + declaredList.length + ')</div>';
|
||||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.25rem">';
|
||||
declaredList.forEach(function(p) {
|
||||
var label = p.asn === 0 ? 'AS0 (Tier-1 / No Provider)' : asnLink(p.asn);
|
||||
h += '<span class="badge badge-green">' + label + '</span>';
|
||||
});
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
// Recommended ASPA template (scrollable, max 200px)
|
||||
if (d.recommended_aspa) {
|
||||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--cyan);margin:.75rem 0 .4rem">Recommended ASPA Object</div>';
|
||||
@ -1268,7 +1285,7 @@ function renderAspaDeep(d) {
|
||||
if (d.detected_providers && d.detected_providers.length > 0) {
|
||||
h += '<div style="margin-top:1.25rem"><div style="font-size:.85rem;font-weight:600;color:var(--blue);margin-bottom:.5rem">Detected Providers (by frequency)</div>';
|
||||
h += '<div class="scroll-wrap" style="max-height:200px"><table class="tbl"><thead><tr><th>Provider</th><th>Name</th><th>Seen in Paths</th><th>Frequency</th></tr></thead><tbody>';
|
||||
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 += '<tr><td>' + asnLink(p.asn) + '</td>';
|
||||
var provName = (p.name && p.name !== 'AS' + p.asn) ? escHtml(p.name) : '';
|
||||
|
||||
169
deploy/server.js
169
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<provider_asn>
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user