diff --git a/server.js b/server.js
index eab9c42..24c1431 100644
--- a/server.js
+++ b/server.js
@@ -409,9 +409,10 @@ function decodeCommunities(communityList) {
// FEATURE 1: BGP Hijack Alerting + Webhooks
// ============================================================
const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : __dirname);
-const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json';
-const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json';
-const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.json';
+const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json';
+const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json';
+const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.json';
+const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json';
function loadHijackSubs() { try { return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE,'utf8')); } catch(_){ return []; } }
function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALERTS_FILE,'utf8')); } catch(_){ return []; } }
@@ -551,7 +552,291 @@ async function runHijackCheck() {
// Run hijack check every 30 minutes
setInterval(runHijackCheck, 30 * 60 * 1000);
+// ============================================================
+// FEATURE 3: ASPA Adoption Tracker
+// ============================================================
+/**
+ * Load persisted ASPA adoption history from disk.
+ * Returns array of snapshot objects (up to 365 entries).
+ */
+function loadAspaAdoptionHistory() {
+ try {
+ const raw = JSON.parse(fs.readFileSync(ASPA_ADOPTION_FILE, 'utf8'));
+ return Array.isArray(raw) ? raw : [];
+ } catch(_) { return []; }
+}
+
+/**
+ * Save ASPA adoption history to disk (keep last 365 entries).
+ */
+function saveAspaAdoptionHistory(history) {
+ try {
+ const trimmed = history.slice(-365);
+ fs.writeFileSync(ASPA_ADOPTION_FILE, JSON.stringify(trimmed, null, 2), 'utf8');
+ } catch(e) {
+ console.error('[ASPA-ADOPT] Save failed:', e.message);
+ }
+}
+
+/** In-memory adoption history (persisted on each new snapshot) */
+let aspaAdoptionDailyHistory = loadAspaAdoptionHistory();
+
+/**
+ * Take a new adoption snapshot and persist it.
+ * Called whenever the RPKI feed is refreshed.
+ * @param {number} aspaCount — total ASPA objects in rpkiAspaMap
+ * @param {number} roaCount — total ROAs in roaStore
+ */
+function recordAspaAdoptionSnapshot(aspaCount, roaCount) {
+ const now = new Date();
+ const date = now.toISOString().slice(0, 10); // "YYYY-MM-DD"
+
+ // Compute how many Atlas-visible ASNs have ASPA
+ let atlasTotal = 0;
+ let atlasWithAspa = 0;
+ if (typeof atlasProbeCache !== 'undefined' && atlasProbeCache?.asns_with_probes) {
+ atlasTotal = atlasProbeCache.asns_with_probes.length;
+ // rpkiAspaMap is keyed by integer ASN
+ atlasWithAspa = atlasProbeCache.asns_with_probes.filter(a => rpkiAspaMap.has(Number(a))).length;
+ }
+
+ const coveragePct = atlasTotal > 0
+ ? Math.round((atlasWithAspa / atlasTotal) * 10000) / 100 // 2 decimal places
+ : 0;
+
+ // Compute delta from previous snapshot
+ const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
+ const delta = prev ? aspaCount - prev.aspa_objects : 0;
+
+ const snapshot = {
+ date,
+ ts: now.getTime(),
+ aspa_objects: aspaCount,
+ roa_count: roaCount,
+ atlas_asns_total: atlasTotal,
+ atlas_asns_with_aspa: atlasWithAspa,
+ coverage_pct_atlas: coveragePct,
+ aspa_delta: delta,
+ method: 'rpki_cloudflare_feed',
+ };
+
+ // Only store one snapshot per date (overwrite if same day)
+ const existingIdx = aspaAdoptionDailyHistory.findIndex(s => s.date === date);
+ if (existingIdx >= 0) {
+ aspaAdoptionDailyHistory[existingIdx] = snapshot;
+ } else {
+ aspaAdoptionDailyHistory.push(snapshot);
+ }
+ saveAspaAdoptionHistory(aspaAdoptionDailyHistory);
+ console.log(`[ASPA-ADOPT] Snapshot ${date}: ${aspaCount} objects, ${coveragePct}% Atlas coverage (${atlasWithAspa}/${atlasTotal})`);
+ return snapshot;
+}
+
+/**
+ * Get adoption trend for a given period string ('7d', '30d', '90d', '1y').
+ */
+function getAdoptionTrend(period) {
+ const days = period === '7d' ? 7 : period === '30d' ? 30 : period === '90d' ? 90 : 365;
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
+ return aspaAdoptionDailyHistory.filter(s => s.ts >= cutoff);
+}
+
+/**
+ * Build the ASPA adoption dashboard HTML.
+ */
+function buildAspaAdoptionDashboard() {
+ const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
+ const trend30 = getAdoptionTrend('30d');
+ const trend7 = getAdoptionTrend('7d');
+
+ // Forecast: simple linear regression over last 30 days
+ function linearForecast(data, daysAhead) {
+ if (data.length < 2) return null;
+ const n = data.length;
+ const xs = data.map((_, i) => i);
+ const ys = data.map(d => d.coverage_pct_atlas);
+ const xMean = xs.reduce((a, b) => a + b, 0) / n;
+ const yMean = ys.reduce((a, b) => a + b, 0) / n;
+ const num = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i] - yMean), 0);
+ const den = xs.reduce((s, x) => s + (x - xMean) ** 2, 0);
+ if (den === 0) return yMean;
+ const slope = num / den;
+ return Math.max(0, Math.min(100, yMean + slope * (n - 1 + daysAhead)));
+ }
+
+ const forecast90d = linearForecast(trend30, 90);
+ const change7d = trend7.length >= 2
+ ? (trend7[trend7.length-1].aspa_objects - trend7[0].aspa_objects)
+ : 0;
+ const trendLabels = trend30.map(d => d.date);
+ const trendValues = trend30.map(d => d.coverage_pct_atlas);
+ const asnValues = trend30.map(d => d.aspa_objects);
+
+ const coverageNow = latest?.coverage_pct_atlas ?? 0;
+ const aspaNow = latest?.aspa_objects ?? 0;
+ const atlasTotal = latest?.atlas_asns_total ?? 0;
+
+ return `
+
+
+
+
+ ASPA Adoption Tracker · PeerCortex
+
+
+
+
+
+
+
ASPA Adoption Tracker
+
Global ASPA deployment trends · Powered by RPKI Cloudflare feed · Updated every ~4 hours
+
+
+
+
Atlas Coverage
+
${coverageNow.toFixed(1)}%
+
of Atlas-visible ASNs
+
+
+
ASPA Objects
+
${aspaNow.toLocaleString()}
+
total in RPKI
+
+
+
Atlas ASNs
+
${atlasTotal.toLocaleString()}
+
sampled denominator
+
+
+
7-Day Delta
+
${change7d > 0 ? '+' : ''}${change7d}
+
new ASPA objects
+
+
+
Data Points
+
${aspaAdoptionDailyHistory.length}
+
history snapshots
+
+
+
+
+
+
+
+
Atlas Coverage % (last 30 days)
+
+
+
+
90-Day Forecast
+
${forecast90d !== null ? forecast90d.toFixed(1) + '%' : '—'}
+
linear projection from 30-day trend
+
+ Based on ${trend30.length} data points.
+ Current: ${coverageNow.toFixed(1)}%
+
+
+
+
+
+
Total ASPA Objects in RPKI (last 30 days)
+
+
+
+
+
+
+
+
+`;
+}
const UA = "PeerCortex/0.5.0 (+https://peercortex.org)";
@@ -1430,9 +1715,6 @@ const roaStore = {
},
};
-// ASPA adoption tracking — store last 30 snapshots for trend analysis
-const aspaAdoptionHistory = [];
-
// ============================================================
// PeeringDB Source Cache (L2) — net/netixlan/netfac per ASN
// Eliminates redundant PDB API calls under load
@@ -1670,14 +1952,8 @@ function fetchRpkiAspaFeed() {
roaStore.build(roas);
roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
- // Track ASPA adoption
- const adoptionSnapshot = {
- ts: Date.now(),
- aspa_count: rpkiAspaMap.size,
- roa_count: roaStore.count,
- };
- aspaAdoptionHistory.push(adoptionSnapshot);
- if (aspaAdoptionHistory.length > 30) aspaAdoptionHistory.shift();
+ // Track ASPA adoption — persist to disk + update in-memory trend
+ recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count);
rpkiAspaLastFetch = Date.now();
console.log("[RPKI] Loaded " + rpkiAspaMap.size + " ASPA objects + " + roaStore.count + " ROAs from Cloudflare feed");
@@ -2944,9 +3220,9 @@ const server = http.createServer(async (req, res) => {
aspa_adoption: {
total_objects: rpkiAspaMap.size,
roa_count: roaStore.count,
- history_samples: aspaAdoptionHistory.length,
- delta_last: aspaAdoptionHistory.length >= 2
- ? aspaAdoptionHistory[aspaAdoptionHistory.length - 1].aspa_count - aspaAdoptionHistory[aspaAdoptionHistory.length - 2].aspa_count
+ history_samples: aspaAdoptionDailyHistory.length,
+ delta_last: aspaAdoptionDailyHistory.length >= 2
+ ? aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1].aspa_objects - aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2].aspa_objects
: 0,
},
})
@@ -6032,11 +6308,105 @@ ${html}
return res.end();
}
+ // ============================================================
+ // FEATURE 3: ASPA Adoption Tracker — API + Dashboard
+ // GET /aspa-adoption → dashboard HTML
+ // GET /api/aspa-adoption-stats?period=7d|30d|1y → JSON trend
+ // GET /api/aspa-adoption-stats/export?format=csv|json&period=30d
+ // ============================================================
+
+ if (reqPath === '/aspa-adoption') {
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.writeHead(200);
+ return res.end(buildAspaAdoptionDashboard());
+ }
+
+ if (reqPath === '/api/aspa-adoption-stats' && req.method === 'GET') {
+ const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
+ ? url.searchParams.get('period') : '30d';
+ const trend = getAdoptionTrend(period);
+ const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
+ const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2];
+
+ // Linear regression forecast
+ function forecast(data, daysAhead) {
+ if (data.length < 2) return null;
+ const n = data.length;
+ const xs = data.map((_, i) => i);
+ const ys = data.map(d => d.coverage_pct_atlas);
+ const xMean = xs.reduce((a,b)=>a+b,0)/n;
+ const yMean = ys.reduce((a,b)=>a+b,0)/n;
+ const num = xs.reduce((s,x,i)=>s+(x-xMean)*(ys[i]-yMean),0);
+ const den = xs.reduce((s,x)=>s+(x-xMean)**2,0);
+ if (den===0) return yMean;
+ const slope = num/den;
+ return Math.max(0, Math.min(100, Math.round((yMean + slope*(n-1+daysAhead))*100)/100));
+ }
+
+ const trend30 = getAdoptionTrend('30d');
+ const result = {
+ period,
+ current: {
+ date: latest?.date,
+ aspa_objects: latest?.aspa_objects ?? rpkiAspaMap.size,
+ roa_count: latest?.roa_count,
+ atlas_asns_total: latest?.atlas_asns_total ?? 0,
+ atlas_asns_with_aspa: latest?.atlas_asns_with_aspa ?? 0,
+ coverage_pct_atlas: latest?.coverage_pct_atlas ?? 0,
+ delta_from_previous: prev ? (latest?.aspa_objects ?? 0) - prev.aspa_objects : 0,
+ },
+ trend: trend.map(s => ({
+ date: s.date,
+ aspa_objects: s.aspa_objects,
+ coverage_pct_atlas: s.coverage_pct_atlas,
+ atlas_asns_total: s.atlas_asns_total,
+ })),
+ forecast: {
+ predicted_90d: forecast(trend30, 90),
+ confidence: trend30.length >= 10 ? 0.75 : 0.5,
+ method: 'linear_regression',
+ based_on_samples: trend30.length,
+ },
+ meta: {
+ total_snapshots: aspaAdoptionDailyHistory.length,
+ last_updated: latest?.date ?? null,
+ denominator: 'atlas_visible_asns',
+ source: 'rpki_cloudflare_feed',
+ }
+ };
+ res.setHeader('Content-Type', 'application/json');
+ res.writeHead(200);
+ return res.end(JSON.stringify(result, null, 2));
+ }
+
+ if (reqPath === '/api/aspa-adoption-stats/export' && req.method === 'GET') {
+ const fmt = url.searchParams.get('format') === 'csv' ? 'csv' : 'json';
+ const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
+ ? url.searchParams.get('period') : '30d';
+ const data = getAdoptionTrend(period);
+
+ if (fmt === 'csv') {
+ const header = 'date,aspa_objects,roa_count,atlas_asns_total,atlas_asns_with_aspa,coverage_pct_atlas,aspa_delta\n';
+ const rows = data.map(s =>
+ `${s.date},${s.aspa_objects},${s.roa_count},${s.atlas_asns_total},${s.atlas_asns_with_aspa},${s.coverage_pct_atlas},${s.aspa_delta??0}`
+ ).join('\n');
+ res.setHeader('Content-Type', 'text/csv');
+ res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.csv"`);
+ res.writeHead(200);
+ return res.end(header + rows);
+ }
+
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.json"`);
+ res.writeHead(200);
+ return res.end(JSON.stringify({ period, count: data.length, data }, null, 2));
+ }
+
// 404
res.writeHead(404);
res.end(
JSON.stringify({
- error: "Not found. Endpoints: /api/health, /api/validate?asn=X, /api/lookup?asn=X, /api/aspa?asn=X, /api/aspa/verify?asn=X, /api/bgproutes?asn=X, /api/compare?asn1=X&asn2=Y, /api/peers/find?ix=NAME, /api/prefix/detail?prefix=X, /api/ix/detail?ix_id=X, /api/export/pdf?asn=X&format=report|executive|technical, /api/export/pdf/compare?asn1=X&asn2=Y",
+ error: "Not found. Endpoints: /api/health, /api/validate?asn=X, /api/lookup?asn=X, /api/aspa?asn=X, /api/aspa/verify?asn=X, /api/bgproutes?asn=X, /api/compare?asn1=X&asn2=Y, /api/peers/find?ix=NAME, /api/prefix/detail?prefix=X, /api/ix/detail?ix_id=X, /api/export/pdf?asn=X&format=report|executive|technical, /api/export/pdf/compare?asn1=X&asn2=Y, /api/aspa-adoption-stats?period=30d",
})
);
});
@@ -6198,6 +6568,9 @@ loadRipeStatCacheFromDisk("/opt/peercortex-app/.ripe-stat-cache.json");
// Phase 1: Fetch fresh RPKI feed (ASPA + ROA) + Atlas probes + PDB org countries + MANRS participants
ensureManrsCache(); // fire-and-forget, 24h cache
Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => {
+ // Re-record ASPA adoption snapshot now that Atlas data is fully loaded
+ recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count);
+
server.listen(PORT, "0.0.0.0", () => {
console.log("PeerCortex v0.6.1 running on http://0.0.0.0:" + PORT);
console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured"));