diff --git a/.gitignore b/.gitignore index 6915d9d..8b5c0f1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 7fa7817..8899778 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -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"} diff --git a/server.js b/server.js index b9b3831..b3c708b 100644 --- a/server.js +++ b/server.js @@ -838,6 +838,97 @@ new Chart(document.getElementById('asnChart'), { `; } +// ============================================================ +// 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) { Generated ${ts} · peercortex.org `; + // ── 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 `${escHtml(v.prefix)} + ${v.status} + ${v.validating_roas ?? '-'} + ${v.max_length ?? '-'}`; + }).join(''); + + const allUpstreamRows = (rel.upstreams || []).map(u => + `AS${u.asn}${escHtml(u.name || '')}${u.power ?? '-'}` + ).join(''); + + const checkScoreRows = checks.map(c => + ` + ${escHtml(c.check || '')} + ${c.status?.toUpperCase()} + ${c.earned ?? '-'}${c.weight ?? '-'} + ${escHtml(c.detail || '')} + ` + ).join(''); + + const technicalHeaderPage = ` +
+
+
PeerCortex · Technical Analysis Report
+
AS${asn} — ${escHtml(name)}
+
Generated ${ts} · peercortex.org · ${totalPfx} prefixes · RPKI ${rpkiCoverage}%
+
+
+
Health Score
${score}/100
${passedChecks} pass · ${failedChecks} fail
+
RPKI Coverage
${rpkiCoverage}%
${rpkiValid} valid · ${rpkiInvalid} invalid · ${rpkiNotFound} missing
+
Upstreams
${counts.upstreams || 0}
transit providers
+
Peers / Downstreams
${counts.peers || 0} / ${counts.downstreams || 0}
BGP relationships
+
ASPA
+
${aspaDirect === true ? 'Deployed' : 'Not deployed'}
+
${aspaProviders} providers registered
+
+
Prefixes Analysed
${totalPfx}
${rpkiDetails.length} with RPKI data
+
+
`; + + const rpkiFullPage = ` +
+

RPKI Compliance — Full Prefix Table

+

${rpkiDetails.length} prefixes with RPKI validation data. Coverage: ${rpkiCoverage}% · Valid: ${rpkiValid} · Invalid: ${rpkiInvalid} · Not Found: ${rpkiNotFound}

+

ASPA Object

+ + + + + +
AttributeValue
ASPA Present${aspaDirect ? 'Yes' : 'No'}
Provider Count${aspaProviders}
Registered Providers${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || '—'}
+ ${allPfxRows ? ` +

All Prefixes

+ + + ${allPfxRows} +
PrefixRPKI StatusValidating ROAsMax Length
` : '

No RPKI prefix data available.

'} +
`; + + const bgpFullPage = ` +
+

BGP Topology — Full Relationship Table

+

Complete AS relationship data. Upstreams: ${counts.upstreams || 0} · Peers: ${counts.peers || 0} · Downstreams: ${counts.downstreams || 0}

+ ${allUpstreamRows ? ` +

All Upstream Providers

+ + + ${allUpstreamRows} +
ASNNamePower Score
` : '

No upstream data available.

'} + ${(rel.peers || []).length > 0 ? ` +

Peer Sample (top 20)

+ + + ${(rel.peers || []).slice(0, 20).map(p => ``).join('')} +
ASNName
AS${p.asn}${escHtml(p.name || '')}
` : ''} +
`; + + const checkDetailPage = ` +
+

Check Score Detail

+

All ${checks.length} health checks with individual scores and diagnostic notes.

+ + + ${checkScoreRows} +
CheckStatusEarnedWeightDetail
+
+ Scoring: 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 +
+
`; + + // Report: add data provenance and ASPA deep-dive + const aspaPage = ` +
+

ASPA — Provider Authorization Analysis

+

Autonomous System Provider Authorization (ASPA) is a BGP security mechanism that cryptographically links an ASN to its upstream providers in the RPKI.

+
+
ASPA Deployed
+
${aspaDirect === true ? 'Yes' : 'No'}
+
+
Providers Listed
${aspaProviders}
in ASPA object
+
RPKI Coverage
${rpkiCoverage}%
of announced prefixes
+
+

What ASPA Protects Against

+ + + + + + +
ThreatASPA Mitigates?Notes
Route leaks (valley-free violations)YesProviders verify upstream path
BGP hijacks (origin AS spoofing)PartialCombined with ROV
AS-path prepending attacksYesInvalid paths rejected
Forged-origin hijacksNoROA + ROV required
+ ${aspaDirect === false ? ` +
+
Recommendation: Deploy ASPA
+
+ 1. Identify all upstream transit providers (${counts.upstreams || 0} detected)
+ 2. Create an ASPA object in your RIR portal listing their ASNs
+ 3. Sign with your RPKI key — takes effect within hours
+ 4. Verify with: rpki-client -v or RIPE RPKI Validator +
+
` : ` +
+
ASPA is deployed — listed providers:
+
${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(' · ') || '—'}
+
`} +
`; + + const provenancePage = ` +
+

Data Provenance

+

All data sources, cache ages, and methodology used to generate this report.

+ + + + + + + + +
Data SourceWhat it providesUpdate Frequency
Cloudflare RPKI FeedROA + ASPA objectsEvery 4 hours
PeeringDBIXP presence, peering policy, facilitiesDaily (03:00 UTC)
RIPE StatAS relationships, prefix visibility, BGP dataReal-time / cached 15 min
RIPE AtlasActive measurement probes by ASNCached 12 hours
bgproutes.ioBGP route visibility fallbackReal-time
bgp.he.netAS relationship graphCached 24 hours
+

Report Metadata

+ + + + + + + + + + +
FieldValue
ASNAS${asn}
Network Name${escHtml(name)}
Report Format${formatLabel(format)}
Generated${ts}
PlatformPeerCortex · peercortex.org
Prefixes Analysed${totalPfx}
RPKI Objects Checked${rpkiDetails.length}
Health Checks Run${checks.length}
+
+ 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 +
+
`; + // ── 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 ` @@ -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);