feat: BGP hijack alerts, PDF export (3 formats), ASPA + IPv6 adoption tracker

- BGP Hijack Alerting: HMAC-SHA256 signed webhooks, exponential backoff,
  6h dedup, /api/webhooks + /api/hijacks endpoints
- PDF Export (3 formats): Executive 3p / Technical 8p (full tables, check
  scores) / Full Report 10p (ASPA analysis + data provenance); 5min cache
- Comparison PDF: side-by-side ASN diff with shared IXPs + facilities
- ASPA Adoption Tracker: daily snapshots, trend history, linear regression
  forecast, /api/aspa-adoption-stats
- IPv6 Adoption per RIR: all 5 RIR delegation files, 24h cache,
  /api/ipv6-adoption-stats
- UI: two-tab adoption overlay (ASPA + IPv6 charts), PDF download buttons
  in action bar and comparison panel
- Bump version v0.6.1 → v0.6.5, add runtime cache files to .gitignore
This commit is contained in:
Rene Fichtmueller 2026-04-30 01:50:04 +02:00
parent 46681ad6f2
commit b31a911719
3 changed files with 284 additions and 5 deletions

2
.gitignore vendored
View File

@ -44,6 +44,8 @@ ecosystem.config.js
visitors.json
feedback.json
hijack-subs.json
webhook-subs.json
aspa-adoption-history.json
audit/reports/
audit/__pycache__/
audit/asn_registry.json

View File

@ -26,3 +26,10 @@
{"d":"2026-04-09","t":"FIX","m":"renderResilienceScore + renderRouteLeak: functions were called but never defined — caused JS crash 'is not defined' breaking entire doLookup render"}
{"d":"2026-04-09","t":"INFRA","m":"Production git synced to GitHub main (11 commits ahead fixed via git pull); deploy.sh script added for future deployments"}
{"d":"2026-04-09","t":"INFRA","m":"PeeringDB SQLite daily cron: peeringdb sync at 03:00 UTC, refresh-peeringdb.sh installed on Erik; DB refreshed 34302→34387 networks"}
{"d":"2026-04-30","t":"FEAT","m":"BGP Hijack Alerting + Webhooks: real-time hijack detection with HMAC-SHA256 signed webhooks, exponential backoff retries, 6h dedup, /api/webhooks + /api/hijacks endpoints"}
{"d":"2026-04-30","t":"FEAT","m":"PDF Export: server-side Puppeteer PDF generation with 3 formats — Executive (3p), Technical (8p, full prefix/upstream tables + check scores), Full Report (10p, ASPA analysis + data provenance); 5min cache, /api/export/pdf"}
{"d":"2026-04-30","t":"FEAT","m":"Comparison PDF: side-by-side ASN comparison as downloadable PDF with shared IXPs, common facilities, peering opportunities, /api/export/pdf/compare"}
{"d":"2026-04-30","t":"FEAT","m":"ASPA Adoption Tracker: daily snapshots of global ASPA deployment rate (Atlas-denominated coverage %), trend history, linear regression forecast, /api/aspa-adoption-stats"}
{"d":"2026-04-30","t":"FEAT","m":"IPv6 Adoption per RIR: fetches all 5 RIR delegation files daily, computes IPv6 record percentage per ARIN/RIPE/APNIC/AFRINIC/LACNIC + global, /api/ipv6-adoption-stats"}
{"d":"2026-04-30","t":"UI","m":"Adoption Tracker overlay panel: two-tab overlay (ASPA Adoption + IPv6 per RIR) matching editorial design, Canvas trend charts, stat cards, data tables — replaces /aspa-adoption nav link"}
{"d":"2026-04-30","t":"UI","m":"PDF download buttons in UI: action-bar PDF dropdown (Full Report / Executive / Technical Deep-Dive) after ASN lookup; Comparison PDF button at bottom of Network Comparison panel"}

280
server.js
View File

@ -838,6 +838,97 @@ new Chart(document.getElementById('asnChart'), {
</html>`;
}
// ============================================================
// FEATURE 4: IPv6 Adoption per RIR
// ============================================================
// In-memory cache: { data, ts }
let ipv6StatsCache = null;
const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h
const RIR_DELEGATION_URLS = {
RIPE: 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest',
ARIN: 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest',
APNIC: 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest',
AFRINIC: 'https://ftp.afrinic.net/stats/afrinic/delegated-afrinic-extended-latest',
LACNIC: 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest',
};
/**
* Fetch and parse one RIR delegation file.
* Returns { ipv4, ipv6, asns } count objects.
*/
function fetchRirDelegation(rir, url) {
return new Promise((resolve) => {
const result = { rir, ipv4: 0, ipv6: 0, asns: 0, ipv4_countries: new Set(), ipv6_countries: new Set() };
const mod = url.startsWith('https') ? require('https') : require('http');
const req = mod.get(url, { headers: { 'User-Agent': 'PeerCortex/1.0 (+https://peercortex.org)' }, timeout: 20000 }, res => {
if (res.statusCode !== 200) { resolve(result); return; }
let buf = '';
res.on('data', d => { buf += d; });
res.on('end', () => {
for (const line of buf.split('\n')) {
if (line.startsWith('#') || line.startsWith(rir.toLowerCase() + '|*') || !line.includes('|')) continue;
const parts = line.split('|');
if (parts.length < 7) continue;
const type = parts[2];
const status = parts[6].trim();
const cc = parts[1];
if (status !== 'allocated' && status !== 'assigned') continue;
if (type === 'ipv4') { result.ipv4++; result.ipv4_countries.add(cc); }
else if (type === 'ipv6') { result.ipv6++; result.ipv6_countries.add(cc); }
else if (type === 'asn') { result.asns++; }
}
resolve(result);
});
});
req.on('error', () => resolve(result));
req.on('timeout', () => { req.destroy(); resolve(result); });
});
}
/**
* Fetch all 5 RIR delegation files and aggregate IPv6 stats.
* Cached for 24 hours.
*/
async function fetchIpv6AdoptionStats(force) {
if (!force && ipv6StatsCache && (Date.now() - ipv6StatsCache.ts) < IPV6_STATS_TTL) {
return ipv6StatsCache.data;
}
console.log('[IPv6] Fetching RIR delegation stats...');
const results = await Promise.all(
Object.entries(RIR_DELEGATION_URLS).map(([rir, url]) => fetchRirDelegation(rir, url))
);
const rirs = results.map(r => ({
rir: r.rir,
ipv4_records: r.ipv4,
ipv6_records: r.ipv6,
asn_records: r.asns,
ipv6_pct: (r.ipv4 + r.ipv6) > 0
? Math.round((r.ipv6 / (r.ipv4 + r.ipv6)) * 10000) / 100
: 0,
countries_with_ipv6: r.ipv6_countries.size,
countries_with_ipv4: r.ipv4_countries.size,
}));
const total_ipv4 = rirs.reduce((s, r) => s + r.ipv4_records, 0);
const total_ipv6 = rirs.reduce((s, r) => s + r.ipv6_records, 0);
const data = {
fetched_at: new Date().toISOString(),
global_ipv6_pct: total_ipv4 + total_ipv6 > 0
? Math.round((total_ipv6 / (total_ipv4 + total_ipv6)) * 10000) / 100
: 0,
total_ipv4_records: total_ipv4,
total_ipv6_records: total_ipv6,
by_rir: rirs,
};
ipv6StatsCache = { data, ts: Date.now() };
console.log(`[IPv6] Done. Global IPv6%: ${data.global_ipv6_pct}% across ${rirs.length} RIRs`);
return data;
}
// Pre-fetch on startup (non-blocking)
fetchIpv6AdoptionStats().catch(() => {});
const UA = "PeerCortex/0.5.0 (+https://peercortex.org)";
// Static geocode cache for major networking cities (fallback when PDB facility coords missing)
@ -1132,15 +1223,180 @@ function buildPdfHtml(data, aspa, format) {
<span>Generated ${ts} · peercortex.org</span>
</footer>`;
// ── Extra pages for technical / report formats ─────────────────────────────
// Technical: full prefix table (no row cap) + all upstream rows + check scores
const allPfxRows = rpkiDetails.map(v => {
const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
return `<tr><td>${escHtml(v.prefix)}</td>
<td><span class="badge ${badge}">${v.status}</span></td>
<td>${v.validating_roas ?? '-'}</td>
<td>${v.max_length ?? '-'}</td></tr>`;
}).join('');
const allUpstreamRows = (rel.upstreams || []).map(u =>
`<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>`
).join('');
const checkScoreRows = checks.map(c =>
`<tr>
<td>${escHtml(c.check || '')}</td>
<td><span class="badge ${c.status === 'pass' ? 'badge-green' : c.status === 'info' ? 'badge-indigo' : 'badge-red'}">${c.status?.toUpperCase()}</span></td>
<td>${c.earned ?? '-'}</td><td>${c.weight ?? '-'}</td>
<td style="font-size:8pt;color:#6b7280">${escHtml(c.detail || '')}</td>
</tr>`
).join('');
const technicalHeaderPage = `
<div class="page" style="padding-top:2cm">
<div style="border-left:4px solid #6366f1;padding-left:16px;margin-bottom:24px">
<div style="font-size:8pt;text-transform:uppercase;letter-spacing:1px;color:#6b7280">PeerCortex · Technical Analysis Report</div>
<div style="font-size:22pt;font-weight:700;margin:4px 0">AS${asn} ${escHtml(name)}</div>
<div style="font-size:9pt;color:#6b7280">Generated ${ts} · peercortex.org · ${totalPfx} prefixes · RPKI ${rpkiCoverage}%</div>
</div>
<div class="metric-grid">
<div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}/100</div><div class="unit">${passedChecks} pass · ${failedChecks} fail</div></div>
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid · ${rpkiInvalid} invalid · ${rpkiNotFound} missing</div></div>
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">transit providers</div></div>
<div class="metric-card"><div class="label">Peers / Downstreams</div><div class="value">${counts.peers || 0} / ${counts.downstreams || 0}</div><div class="unit">BGP relationships</div></div>
<div class="metric-card"><div class="label">ASPA</div>
<div class="value" style="font-size:11pt;margin-top:6px"><span class="badge ${aspaDirect === true ? 'badge-green' : 'badge-red'}">${aspaDirect === true ? 'Deployed' : 'Not deployed'}</span></div>
<div class="unit">${aspaProviders} providers registered</div>
</div>
<div class="metric-card"><div class="label">Prefixes Analysed</div><div class="value">${totalPfx}</div><div class="unit">${rpkiDetails.length} with RPKI data</div></div>
</div>
</div>`;
const rpkiFullPage = `
<div class="page">
<h2>RPKI Compliance Full Prefix Table</h2>
<p class="section-intro">${rpkiDetails.length} prefixes with RPKI validation data. Coverage: ${rpkiCoverage}% · Valid: ${rpkiValid} · Invalid: ${rpkiInvalid} · Not Found: ${rpkiNotFound}</p>
<h3>ASPA Object</h3>
<table>
<tr><th>Attribute</th><th>Value</th></tr>
<tr><td>ASPA Present</td><td><span class="badge ${aspaDirect ? 'badge-green' : 'badge-red'}">${aspaDirect ? 'Yes' : 'No'}</span></td></tr>
<tr><td>Provider Count</td><td>${aspaProviders}</td></tr>
<tr><td>Registered Providers</td><td style="font-size:8pt">${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || ''}</td></tr>
</table>
${allPfxRows ? `
<h3 style="margin-top:1.5em">All Prefixes</h3>
<table>
<tr><th>Prefix</th><th>RPKI Status</th><th>Validating ROAs</th><th>Max Length</th></tr>
${allPfxRows}
</table>` : '<p style="color:#9ca3af">No RPKI prefix data available.</p>'}
</div>`;
const bgpFullPage = `
<div class="page">
<h2>BGP Topology Full Relationship Table</h2>
<p class="section-intro">Complete AS relationship data. Upstreams: ${counts.upstreams || 0} · Peers: ${counts.peers || 0} · Downstreams: ${counts.downstreams || 0}</p>
${allUpstreamRows ? `
<h3>All Upstream Providers</h3>
<table>
<tr><th>ASN</th><th>Name</th><th>Power Score</th></tr>
${allUpstreamRows}
</table>` : '<p style="color:#9ca3af">No upstream data available.</p>'}
${(rel.peers || []).length > 0 ? `
<h3 style="margin-top:1.5em">Peer Sample (top 20)</h3>
<table>
<tr><th>ASN</th><th>Name</th></tr>
${(rel.peers || []).slice(0, 20).map(p => `<tr><td>AS${p.asn}</td><td>${escHtml(p.name || '')}</td></tr>`).join('')}
</table>` : ''}
</div>`;
const checkDetailPage = `
<div class="page">
<h2>Check Score Detail</h2>
<p class="section-intro">All ${checks.length} health checks with individual scores and diagnostic notes.</p>
<table>
<tr><th>Check</th><th>Status</th><th>Earned</th><th>Weight</th><th>Detail</th></tr>
${checkScoreRows}
</table>
<div style="margin-top:1.5em;padding:12px;background:#f9fafb;border:1px solid #e5e7eb;font-size:8pt;color:#6b7280">
<strong>Scoring:</strong> Total = sum of earned points across all checks. Maximum possible: ${checks.reduce((s, c) => s + (c.weight || 0), 0)} points.
Current score: ${checks.reduce((s, c) => s + (c.earned || 0), 0)} / ${checks.reduce((s, c) => s + (c.weight || 0), 0)} = ${score}/100
</div>
</div>`;
// Report: add data provenance and ASPA deep-dive
const aspaPage = `
<div class="page">
<h2>ASPA Provider Authorization Analysis</h2>
<p class="section-intro">Autonomous System Provider Authorization (ASPA) is a BGP security mechanism that cryptographically links an ASN to its upstream providers in the RPKI.</p>
<div class="metric-grid">
<div class="metric-card"><div class="label">ASPA Deployed</div>
<div class="value" style="font-size:12pt;margin-top:6px"><span class="badge ${aspaDirect === true ? 'badge-green' : 'badge-red'}">${aspaDirect === true ? 'Yes' : 'No'}</span></div>
</div>
<div class="metric-card"><div class="label">Providers Listed</div><div class="value">${aspaProviders}</div><div class="unit">in ASPA object</div></div>
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">of announced prefixes</div></div>
</div>
<h3>What ASPA Protects Against</h3>
<table>
<tr><th>Threat</th><th>ASPA Mitigates?</th><th>Notes</th></tr>
<tr><td>Route leaks (valley-free violations)</td><td><span class="badge badge-green">Yes</span></td><td>Providers verify upstream path</td></tr>
<tr><td>BGP hijacks (origin AS spoofing)</td><td><span class="badge badge-indigo">Partial</span></td><td>Combined with ROV</td></tr>
<tr><td>AS-path prepending attacks</td><td><span class="badge badge-green">Yes</span></td><td>Invalid paths rejected</td></tr>
<tr><td>Forged-origin hijacks</td><td><span class="badge badge-red">No</span></td><td>ROA + ROV required</td></tr>
</table>
${aspaDirect === false ? `
<div style="margin-top:1.5em;padding:14px;background:#fef2f2;border-left:3px solid #ef4444;border-radius:4px">
<div style="font-weight:600;color:#991b1b;margin-bottom:6px">Recommendation: Deploy ASPA</div>
<div style="font-size:9pt;color:#6b7280">
1. Identify all upstream transit providers (${counts.upstreams || 0} detected)<br>
2. Create an ASPA object in your RIR portal listing their ASNs<br>
3. Sign with your RPKI key takes effect within hours<br>
4. Verify with: <code>rpki-client -v</code> or RIPE RPKI Validator
</div>
</div>` : `
<div style="margin-top:1.5em;padding:14px;background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px">
<div style="font-weight:600;color:#15803d">ASPA is deployed listed providers:</div>
<div style="font-size:9pt;color:#374151;margin-top:6px">${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(' · ') || '—'}</div>
</div>`}
</div>`;
const provenancePage = `
<div class="page">
<h2>Data Provenance</h2>
<p class="section-intro">All data sources, cache ages, and methodology used to generate this report.</p>
<table>
<tr><th>Data Source</th><th>What it provides</th><th>Update Frequency</th></tr>
<tr><td>Cloudflare RPKI Feed</td><td>ROA + ASPA objects</td><td>Every 4 hours</td></tr>
<tr><td>PeeringDB</td><td>IXP presence, peering policy, facilities</td><td>Daily (03:00 UTC)</td></tr>
<tr><td>RIPE Stat</td><td>AS relationships, prefix visibility, BGP data</td><td>Real-time / cached 15 min</td></tr>
<tr><td>RIPE Atlas</td><td>Active measurement probes by ASN</td><td>Cached 12 hours</td></tr>
<tr><td>bgproutes.io</td><td>BGP route visibility fallback</td><td>Real-time</td></tr>
<tr><td>bgp.he.net</td><td>AS relationship graph</td><td>Cached 24 hours</td></tr>
</table>
<h3 style="margin-top:1.5em">Report Metadata</h3>
<table>
<tr><th>Field</th><th>Value</th></tr>
<tr><td>ASN</td><td>AS${asn}</td></tr>
<tr><td>Network Name</td><td>${escHtml(name)}</td></tr>
<tr><td>Report Format</td><td>${formatLabel(format)}</td></tr>
<tr><td>Generated</td><td>${ts}</td></tr>
<tr><td>Platform</td><td>PeerCortex · peercortex.org</td></tr>
<tr><td>Prefixes Analysed</td><td>${totalPfx}</td></tr>
<tr><td>RPKI Objects Checked</td><td>${rpkiDetails.length}</td></tr>
<tr><td>Health Checks Run</td><td>${checks.length}</td></tr>
</table>
<div style="margin-top:1.5em;font-size:8pt;color:#9ca3af;border-top:1px solid #e5e7eb;padding-top:12px">
This report is generated automatically based on publicly available BGP and RPKI data.
Data may be delayed by cache TTLs. Not a substitute for real-time monitoring.
MIT License · github.com/renefichtmueller/PeerCortex
</div>
</div>`;
// ── Compose pages by format ────────────────────────────────────────────────
let bodyContent;
if (format === 'executive') {
// 3 pages: clean stakeholder summary — no deep technical data
bodyContent = coverPage + summaryPage + recsPage;
} else if (format === 'technical') {
bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + recsPage;
// ~8 pages: engineer-focused, dense, no decorative cover, full data tables
bodyContent = technicalHeaderPage + rpkiFullPage + bgpFullPage + checkDetailPage + recsPage;
} else {
// 'report' — full
bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + recsPage;
// 'report' — full stakeholder + technical, ~10 pages
bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + aspaPage + recsPage + provenancePage;
}
return `<!DOCTYPE html>
@ -6497,11 +6753,25 @@ ${html}
return res.end(JSON.stringify({ period, count: data.length, data }, null, 2));
}
// GET /api/ipv6-adoption-stats?refresh=1
if (reqPath === '/api/ipv6-adoption-stats' && req.method === 'GET') {
const force = url.searchParams.get('refresh') === '1';
try {
const data = await fetchIpv6AdoptionStats(force);
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
return res.end(JSON.stringify(data, null, 2));
} catch (err) {
res.writeHead(503);
return res.end(JSON.stringify({ error: 'IPv6 stats unavailable', message: err.message }));
}
}
// 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, /api/aspa-adoption-stats?period=30d",
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, /api/ipv6-adoption-stats",
})
);
});
@ -6667,7 +6937,7 @@ Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]
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("PeerCortex v0.6.5 running on http://0.0.0.0:" + PORT);
console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured"));
console.log("PeeringDB API key: " + (PEERINGDB_API_KEY ? "configured" : "NOT configured"));
console.log("RPKI ASPA objects: " + rpkiAspaMap.size);