diff --git a/server.js b/server.js
index b59b44f..c6f9c55 100644
--- a/server.js
+++ b/server.js
@@ -7,50 +7,6 @@ const crypto = require("crypto");
const localDb = require('./local-db-client');
console.log('[PeerCortex] Local DB client initialized');
-// ── PDF Export: lazy puppeteer loader ────────────────────────────────────────
-let _puppeteerBrowser = null;
-let _puppeteerLoading = false;
-async function getPuppeteerBrowser() {
- if (_puppeteerBrowser) return _puppeteerBrowser;
- if (_puppeteerLoading) {
- // Wait for in-flight launch
- await new Promise(r => setTimeout(r, 2000));
- return _puppeteerBrowser;
- }
- _puppeteerLoading = true;
- try {
- const puppeteer = require("puppeteer");
- _puppeteerBrowser = await puppeteer.launch({
- headless: true,
- args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
- });
- console.log("[PDF] Puppeteer browser launched");
- _puppeteerBrowser.on("disconnected", () => {
- console.log("[PDF] Browser disconnected — will relaunch on next request");
- _puppeteerBrowser = null;
- _puppeteerLoading = false;
- });
- } catch (e) {
- console.error("[PDF] Puppeteer unavailable:", e.message);
- _puppeteerBrowser = null;
- }
- _puppeteerLoading = false;
- return _puppeteerBrowser;
-}
-
-// PDF response cache: key = "asn:format" or "compare:asn1:asn2" → {buf, ts}
-const pdfCache = new Map();
-const PDF_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
-
-function pdfCacheGet(key) {
- const e = pdfCache.get(key);
- if (e && (Date.now() - e.ts) < PDF_CACHE_TTL) return e.buf;
- return null;
-}
-function pdfCacheSet(key, buf) {
- if (pdfCache.size > 100) pdfCache.delete(pdfCache.keys().next().value);
- pdfCache.set(key, { buf, ts: Date.now() });
-}
// Load .env file
require('./src/backend/config');
@@ -116,773 +72,26 @@ const {
buildAspaAdoptionDashboard,
} = require("./server/aspa-adoption/tracker");
-// ============================================================
-// 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 } = require("./server/data/rir-delegation-urls");
-
-/**
- * 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 { fetchRirDelegation, fetchIpv6AdoptionStats } = require("./server/ipv6-adoption");
const { UA } = require("./server/data/constants");
const { CITY_COORDS } = require("./server/data/city-coords");
// ============================================================
-// FEATURE 2: PDF Export — template generators + puppeteer renderer
-// ============================================================
-
-/**
- * Generate a health score ring SVG (inline, no external deps)
- * @param {number} score 0–100
- * @param {string} color CSS color
- */
-function scoreRingSvg(score, color) {
- const r = 54, cx = 64, cy = 64;
- const circ = 2 * Math.PI * r;
- const fill = circ * (1 - score / 100);
- const clr = color || (score >= 80 ? '#22c55e' : score >= 60 ? '#f59e0b' : '#ef4444');
- return `
-
-
- ${score}
- /100
- `;
-}
-
-/** Shared CSS for all PDF templates */
-function pdfBaseCSS() {
- return `
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- body { font-family: -apple-system, 'Segoe UI', system-ui, Arial, sans-serif; color: #1e1b4b; background: #fff; font-size: 11pt; line-height: 1.5; }
- @page { size: A4; margin: 18mm 16mm; }
- .page { page-break-after: always; min-height: 247mm; padding: 0; }
- .page:last-child { page-break-after: avoid; }
- h1 { font-size: 22pt; font-weight: 700; color: #4f46e5; }
- h2 { font-size: 14pt; font-weight: 600; color: #3730a3; margin-top: 1.4em; margin-bottom: 0.5em; border-bottom: 2px solid #e0e7ff; padding-bottom: 4px; }
- h3 { font-size: 11pt; font-weight: 600; color: #4338ca; margin-top: 1em; margin-bottom: 0.3em; }
- p { margin-bottom: 0.5em; }
- table { width: 100%; border-collapse: collapse; margin: 0.6em 0; font-size: 9.5pt; }
- th { background: #4f46e5; color: #fff; padding: 6px 10px; text-align: left; font-weight: 600; }
- td { padding: 5px 10px; border-bottom: 1px solid #e0e7ff; vertical-align: top; }
- tr:nth-child(even) td { background: #f5f3ff; }
- .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8.5pt; font-weight: 600; }
- .badge-green { background: #dcfce7; color: #166534; }
- .badge-red { background: #fee2e2; color: #991b1b; }
- .badge-yellow{ background: #fef9c3; color: #854d0e; }
- .badge-gray { background: #f3f4f6; color: #374151; }
- .badge-indigo{ background: #e0e7ff; color: #3730a3; }
- .cover { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; padding: 40px 0 20px; min-height: 247mm; }
- .cover-logo { font-size: 11pt; font-weight: 700; color: #4f46e5; letter-spacing: -0.5px; margin-bottom: 60px; display: flex; align-items: center; gap: 8px; }
- .cover-logo span { color: #1e1b4b; }
- .cover-asn { font-size: 11pt; font-weight: 500; color: #6b7280; margin-bottom: 6px; }
- .cover-name { font-size: 28pt; font-weight: 700; color: #1e1b4b; margin-bottom: 8px; line-height: 1.2; }
- .cover-sub { font-size: 13pt; color: #4f46e5; font-weight: 500; margin-bottom: 30px; }
- .cover-meta { font-size: 9pt; color: #9ca3af; margin-top: auto; padding-top: 20px; border-top: 1px solid #e0e7ff; width: 100%; }
- .cover-stripe { position: fixed; right: -8mm; top: 0; width: 28mm; height: 100vh; background: linear-gradient(180deg, #4f46e5 0%, #6366f1 50%, #818cf8 100%); opacity: 0.08; pointer-events: none; }
- .metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 1em 0; }
- .metric-card { background: #f5f3ff; border: 1px solid #e0e7ff; border-radius: 8px; padding: 12px 14px; }
- .metric-card .label { font-size: 8pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
- .metric-card .value { font-size: 18pt; font-weight: 700; color: #4f46e5; }
- .metric-card .unit { font-size: 8pt; color: #9ca3af; }
- .score-row { display: flex; align-items: center; gap: 20px; margin: 0.8em 0; }
- .check-list { list-style: none; padding: 0; }
- .check-list li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid #f3f4f6; font-size: 9.5pt; }
- .check-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
- .check-pass { color: #16a34a; }
- .check-fail { color: #dc2626; }
- .check-warn { color: #d97706; }
- .section-intro { font-size: 9.5pt; color: #6b7280; margin-bottom: 1em; font-style: italic; }
- footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 8pt; color: #9ca3af; padding: 6px 16mm; border-top: 1px solid #e0e7ff; display: flex; justify-content: space-between; }
- `;
-}
-
-/**
- * Build the PDF HTML for a single ASN.
- * @param {object} data Full /api/validate response
- * @param {object} aspa /api/aspa response (may be null)
- * @param {string} format 'report' | 'executive' | 'technical'
- */
-function buildPdfHtml(data, aspa, format) {
- const asn = data.asn || '?';
- const name = data.name || 'Unknown Network';
- const score = Math.round(data.health_score || 0);
- const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
- const checks = (data.score_breakdown || []);
- const rel = data.relationships || {};
- const counts = rel.counts || {};
- // validations is an object keyed by check name; rpki details live in rpki_completeness
- const validations = (data.validations && typeof data.validations === 'object' && !Array.isArray(data.validations)) ? data.validations : {};
- const rpkiDetails = (validations.rpki_completeness?.details || []);
- const totalPfx = data.meta?.total_prefixes || 0;
-
- // Derived stats — use rpki_completeness for RPKI metrics
- const rpki = validations.rpki_completeness || {};
- const rpkiCoverage = rpki.coverage_pct ?? 0;
- const rpkiTotal = rpki.total_checked || rpkiDetails.length || 0;
- const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length;
- const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').length;
- const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not_found').length;
-
- const passedChecks = checks.filter(c => c.status === 'pass').length;
- const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
- const scoreColor = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
- const aspaDirect = aspa?.direct?.has_aspa ?? null;
- const aspaProviders= aspa?.direct?.provider_asns?.length || 0;
-
- // Check list HTML
- function checkIcon(passed) {
- if (passed) return ' ';
- return ' ';
- }
-
- const checksHtml = checks.map(c => {
- const isPassed = c.status === 'pass';
- const isInfo = c.status === 'info';
- const icon = checkIcon(isPassed || isInfo);
- const statusBadge = isPassed ? 'badge-green' : isInfo ? 'badge-indigo' : 'badge-red';
- const statusLabel = isPassed ? 'PASS' : isInfo ? 'INFO' : 'FAIL';
- return `
-
- ${icon}
-
- ${escHtml(c.check || '')}
- ${statusLabel}
- ${c.earned !== undefined ? `${c.earned}/${c.weight}pt ` : ''}
-
- `;
- }).join('');
-
- // Upstream providers table
- const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => `
- AS${u.asn} ${escHtml(u.name || '')} ${u.power ?? '-'}
- `).join('');
-
- // Prefix validations table (sample from rpki_completeness.details)
- const pfxRows = rpkiDetails.slice(0, 20).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 ?? '-'}
- — `;
- }).join('');
-
- // Recommendations
- const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info');
- const recHtml = failedItems.length === 0
- ? '✓ No critical issues found. Network health is excellent.
'
- : failedItems.map((c, i) => `
-
-
${i+1}. ${escHtml(c.check || '')}
- ${c.detail ? `
${escHtml(c.detail)}
` : ''}
-
`).join('');
-
- // ── Executive pages (always present) ───────────────────────────────────────
- const coverPage = `
-
-
-
⬡ PeerCortex Network Intelligence
-
AS${asn}
-
${escHtml(name)}
-
${formatLabel(format)} Report
-
- ${scoreRingSvg(score)}
-
-
Health Score
-
${score}/100
-
${passedChecks} passed · ${failedChecks} failed
-
-
-
- Generated by PeerCortex · ${ts} · peercortex.org
- | ${totalPfx} prefixes analysed
- | RPKI coverage ${rpkiCoverage}%
-
-
`;
-
- const summaryPage = `
-
-
Executive Summary
-
Key performance indicators for AS${asn} (${escHtml(name)}).
-
-
Health Score
${score}
out of 100
-
RPKI Coverage
${rpkiCoverage}%
${rpkiValid}/${rpkiTotal} prefixes
-
Upstreams
${counts.upstreams || 0}
providers
-
Downstreams
${counts.downstreams || 0}
customers
-
Peers
${counts.peers || 0}
BGP sessions
-
ASPA Status
-
-
- ${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'}
-
-
-
${aspaProviders} providers
-
-
-
Status Overview
-
-
`;
-
- // ── Recommendations page ────────────────────────────────────────────────────
- const recsPage = `
-
-
Recommendations
-
Action items to improve network health and security posture.
- ${recHtml}
- ${failedItems.length > 0 ? `
-
Remediation Priority
-
- # Check Impact Effort
- ${failedItems.map((c, i) => `
-
- ${i+1}
- ${escHtml(c.check || '')}
- High
- Medium
- `).join('')}
-
` : ''}
-
`;
-
- // ── Detail pages (included for 'report' and 'technical') ───────────────────
- const rpkiPage = `
-
-
RPKI Compliance
-
Route Origin Authorization (ROA) validation results for announced prefixes.
-
-
Coverage
${rpkiCoverage}%
${rpkiValid} valid ROAs
-
Invalid
${rpkiInvalid}
prefix${rpkiInvalid !== 1 ? 'es' : ''}
-
Not Found
${rpkiNotFound}
no ROA
-
-
ASPA (Autonomous System Provider Authorization)
-
- Attribute Value
- ASPA Object Present ${aspaDirect ? 'Yes' : 'No'}
- Provider Count ${aspaProviders}
- Providers ${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || '—'}
-
- ${pfxRows ? `
-
Prefix Validation Sample (first 20)
-
- Prefix Status Max Length ROA Origin
- ${pfxRows}
-
` : ''}
-
`;
-
- const bgpPage = `
-
-
BGP Topology
-
AS relationship overview and upstream connectivity analysis.
-
-
Upstreams
${counts.upstreams || 0}
transit providers
-
Downstreams
${counts.downstreams || 0}
customers
-
Peers
${counts.peers || 0}
BGP peers
-
- ${upstreamRows ? `
-
Top Upstream Providers
-
- ASN Name Power Score
- ${upstreamRows}
-
` : '
No upstream data available.
'}
-
`;
-
- const footer = `
-
- PeerCortex · AS${asn} · ${escHtml(name)}
- 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
-
- Attribute Value
- ASPA Present ${aspaDirect ? 'Yes' : 'No'}
- Provider Count ${aspaProviders}
- Registered Providers ${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || '—'}
-
- ${allPfxRows ? `
-
All Prefixes
-
- Prefix RPKI Status Validating ROAs Max Length
- ${allPfxRows}
-
` : '
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
-
- ASN Name Power Score
- ${allUpstreamRows}
-
` : '
No upstream data available.
'}
- ${(rel.peers || []).length > 0 ? `
-
Peer Sample (top 20)
-
- ASN Name
- ${(rel.peers || []).slice(0, 20).map(p => `AS${p.asn} ${escHtml(p.name || '')} `).join('')}
-
` : ''}
-
`;
-
- const checkDetailPage = `
-
-
Check Score Detail
-
All ${checks.length} health checks with individual scores and diagnostic notes.
-
- Check Status Earned Weight Detail
- ${checkScoreRows}
-
-
- 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
-
- Threat ASPA Mitigates? Notes
- Route leaks (valley-free violations) Yes Providers verify upstream path
- BGP hijacks (origin AS spoofing) Partial Combined with ROV
- AS-path prepending attacks Yes Invalid paths rejected
- Forged-origin hijacks No ROA + 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 Source What it provides Update Frequency
- Cloudflare RPKI Feed ROA + ASPA objects Every 4 hours
- PeeringDB IXP presence, peering policy, facilities Daily (03:00 UTC)
- RIPE Stat AS relationships, prefix visibility, BGP data Real-time / cached 15 min
- RIPE Atlas Active measurement probes by ASN Cached 12 hours
- bgproutes.io BGP route visibility fallback Real-time
- bgp.he.net AS relationship graph Cached 24 hours
-
-
Report Metadata
-
- Field Value
- ASN AS${asn}
- Network Name ${escHtml(name)}
- Report Format ${formatLabel(format)}
- Generated ${ts}
- Platform PeerCortex · 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') {
- // ~8 pages: engineer-focused, dense, no decorative cover, full data tables
- bodyContent = technicalHeaderPage + rpkiFullPage + bgpFullPage + checkDetailPage + recsPage;
- } else {
- // 'report' — full stakeholder + technical, ~10 pages
- bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + aspaPage + recsPage + provenancePage;
- }
-
- return `
-
-
-
- PeerCortex — AS${asn} ${formatLabel(format)}
-
-
-
- ${bodyContent}
- ${footer}
-
-`;
-}
-
-/**
- * Build comparison PDF HTML for two ASNs.
- */
-function buildComparisonPdfHtml(d1, aspa1, l1, d2, aspa2, l2) {
- const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
-
- function col(d, aspa) {
- const asn = d.asn || '?';
- const name = d.name || 'Unknown';
- const score = Math.round(d.health_score || 0);
- const rel = d.relationships || {};
- const counts= rel.counts || {};
- const vals = (d.validations && !Array.isArray(d.validations)) ? d.validations : {};
- const rpki = vals.rpki_completeness || {};
- const rpkiV = rpki.with_roa || 0;
- const rpkiT = rpki.total_checked || 0;
- const rpkiC = rpki.coverage_pct ?? (rpkiT > 0 ? Math.round((rpkiV / rpkiT) * 100) : 0);
- const sc = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
- const aspaDep = aspa?.direct?.has_aspa ?? null;
- const checks= (d.score_breakdown || []);
- const passC = checks.filter(c => c.status === 'pass').length;
- const failC = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
-
- return `
-
-
- ${scoreRingSvg(score, sc)}
-
AS${asn}
-
${escHtml(name)}
-
-
- Key Metrics
- Health Score ${score}/100
- RPKI Coverage ${rpkiC}% (${rpkiV}/${rpkiT})
- Upstreams ${counts.upstreams || 0}
- Downstreams ${counts.downstreams || 0}
- Peers ${counts.peers || 0}
- ASPA ${aspaDep ? 'Deployed' : 'Not Deployed'}
- Checks Passed ${passC} / ${checks.length}
- Checks Failed ${failC}
- Total Prefixes ${d.meta?.total_prefixes || 0}
-
- `;
- }
-
- const colHtml = col(d1, aspa1) + col(d2, aspa2);
-
- // ── Peering Intelligence: common IXPs + common facilities ─────────────────
- const ixList1 = (l1?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
- const ixList2 = (l2?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
- const ixIds1 = new Set(ixList1.map(x => x.id));
- const ixIds2 = new Set(ixList2.map(x => x.id));
- const commonIxIds = [...ixIds1].filter(id => ixIds2.has(id));
- const commonIxps = commonIxIds.slice(0, 20).map(id => {
- const x1 = ixList1.find(x => x.id === id);
- const x2 = ixList2.find(x => x.id === id);
- return { id, name: x1?.name || 'Unknown', city: x1?.city || '', speed1: x1?.speed, speed2: x2?.speed };
- });
-
- const facList1 = (l1?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
- const facList2 = (l2?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
- const facIds1 = new Set(facList1.map(f => f.id));
- const facIds2 = new Set(facList2.map(f => f.id));
- const commonFacIds = [...facIds1].filter(id => facIds2.has(id));
- const commonFacs = commonFacIds.slice(0, 15).map(id => facList1.find(f => f.id === id));
-
- // IXPs of ASN1 where ASN2 is NOT present (opportunity IXPs for ASN2)
- const oppsFor2 = ixList1.filter(x => !ixIds2.has(x.id)).slice(0, 8);
- const oppsFor1 = ixList2.filter(x => !ixIds1.has(x.id)).slice(0, 8);
-
- const commonIxHtml = commonIxps.length > 0
- ? `
- IXP Name City AS${d1.asn} Speed AS${d2.asn} Speed
- ${commonIxps.map(x => `
-
- ${escHtml(x.name)}
- ${escHtml(x.city)}
- ${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}
- ${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}
- `).join('')}
-
`
- : 'No common IXPs found — these networks do not share any Internet Exchange Points.
';
-
- const commonFacHtml = commonFacs.length > 0
- ? `
- Datacenter / Colocation Facility City Country
- ${commonFacs.map(f => `
-
- ${escHtml(f?.name||'')}
- ${escHtml(f?.city||'')}
- ${escHtml(f?.country||'')}
- `).join('')}
-
`
- : 'No shared colocation facilities found.
';
-
- const oppsHtml = (asns, opps) => opps.length === 0
- ? `Already maximally peered on these exchanges — or no data available.
`
- : opps.map(x => `
- ${escHtml(x.name)}
- ${x.city ? ` · ${escHtml(x.city)} ` : ''}
- ${x.speed ? `${(x.speed/1000).toLocaleString()}G ` : ''}
-
`).join('');
-
- const peeringPage = (commonIxps.length + commonFacs.length > 0 || oppsFor1.length + oppsFor2.length > 0) ? `
-
-
Peering Intelligence
-
Common Internet Exchange Points and colocation facilities — potential peering locations between AS${d1.asn} and AS${d2.asn}.
-
-
-
-
${commonIxps.length}
-
Common IXPs
-
-
-
${commonFacs.length}
-
Shared Facilities
-
-
-
-
Common Internet Exchange Points (direct peering possible)
- ${commonIxHtml}
-
-
Shared Colocation Facilities
- ${commonFacHtml}
-
- ${oppsFor1.length + oppsFor2.length > 0 ? `
-
Expansion Opportunities
-
-
-
AS${d2.asn} could join (AS${d1.asn} is there)
- ${oppsHtml(d2.asn, oppsFor2)}
-
-
-
AS${d1.asn} could join (AS${d2.asn} is there)
- ${oppsHtml(d1.asn, oppsFor1)}
-
-
` : ''}
-
` : '';
-
- // Side-by-side check comparison
- const allChecks = [...new Set([
- ...(d1.score_breakdown || []).map(c => c.check),
- ...(d2.score_breakdown || []).map(c => c.check),
- ])];
- const checkRows = allChecks.map(checkName => {
- const c1 = (d1.score_breakdown || []).find(c => c.check === checkName);
- const c2 = (d2.score_breakdown || []).find(c => c.check === checkName);
- const isP1 = c1?.status === 'pass';
- const isP2 = c2?.status === 'pass';
- const icon = p => p ? '✓' : '✗';
- const st = p => p ? 'color:#16a34a;font-weight:700' : 'color:#dc2626;font-weight:700';
- return `
- ${escHtml(checkName)}
- ${c1 ? icon(isP1) : '—'}
- ${c2 ? icon(isP2) : '—'}
- `;
- }).join('');
-
- return `
-
-
-
- PeerCortex — AS${d1.asn} vs AS${d2.asn} Comparison
-
-
-
-
-
-
⬡ PeerCortex Network Intelligence
-
-
ASN Comparison Report
-
AS${d1.asn} vs AS${d2.asn}
-
${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}
-
-
-
Generated by PeerCortex · ${ts} · peercortex.org
-
-
-
Head-to-Head Health Checks
-
- Check AS${d1.asn} AS${d2.asn}
- ${checkRows}
-
-
- ${peeringPage}
-
- PeerCortex · AS${d1.asn} vs AS${d2.asn}
- Generated ${ts} · peercortex.org
-
-
-`;
-}
-
-function formatLabel(fmt) {
- return fmt === 'executive' ? 'Executive Summary' : fmt === 'technical' ? 'Technical Analysis' : 'Full Report';
-}
-
-function escHtml(s) {
- if (!s) return '';
- return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
-}
-
-/**
- * Render HTML to PDF buffer using puppeteer.
- * Returns Buffer or throws.
- */
-async function renderHtmlToPdf(html) {
- const browser = await getPuppeteerBrowser();
- if (!browser) throw new Error('PDF renderer unavailable (puppeteer not installed)');
- const page = await browser.newPage();
- try {
- await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 15000 });
- const buf = await page.pdf({
- format: 'A4',
- printBackground: true,
- margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' },
- });
- return buf;
- } finally {
- await page.close();
- }
-}
+const {
+ scoreRingSvg,
+ pdfBaseCSS,
+ buildPdfHtml,
+ buildComparisonPdfHtml,
+ formatLabel,
+ escHtml,
+} = require("./server/pdf-export/templates");
+const {
+ getPuppeteerBrowser,
+ pdfCacheGet,
+ pdfCacheSet,
+ renderHtmlToPdf,
+} = require("./server/pdf-export/renderer");
const {
responseCache,
diff --git a/server/ipv6-adoption.js b/server/ipv6-adoption.js
new file mode 100644
index 0000000..9c55dfb
--- /dev/null
+++ b/server/ipv6-adoption.js
@@ -0,0 +1,80 @@
+const { RIR_DELEGATION_URLS } = require("./data/rir-delegation-urls");
+
+// FEATURE 4: IPv6 Adoption per RIR
+
+// In-memory cache: { data, ts }
+let ipv6StatsCache = null;
+const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h
+
+// 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(() => {});
+
+module.exports = { fetchRirDelegation, fetchIpv6AdoptionStats };
diff --git a/server/pdf-export/renderer.js b/server/pdf-export/renderer.js
new file mode 100644
index 0000000..7b3d3d1
--- /dev/null
+++ b/server/pdf-export/renderer.js
@@ -0,0 +1,65 @@
+// PDF Export: lazy puppeteer loader + response cache + renderer
+
+// ── PDF Export: lazy puppeteer loader ────────────────────────────────────────
+let _puppeteerBrowser = null;
+let _puppeteerLoading = false;
+async function getPuppeteerBrowser() {
+ if (_puppeteerBrowser) return _puppeteerBrowser;
+ if (_puppeteerLoading) {
+ // Wait for in-flight launch
+ await new Promise(r => setTimeout(r, 2000));
+ return _puppeteerBrowser;
+ }
+ _puppeteerLoading = true;
+ try {
+ const puppeteer = require("puppeteer");
+ _puppeteerBrowser = await puppeteer.launch({
+ headless: true,
+ args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
+ });
+ console.log("[PDF] Puppeteer browser launched");
+ _puppeteerBrowser.on("disconnected", () => {
+ console.log("[PDF] Browser disconnected — will relaunch on next request");
+ _puppeteerBrowser = null;
+ _puppeteerLoading = false;
+ });
+ } catch (e) {
+ console.error("[PDF] Puppeteer unavailable:", e.message);
+ _puppeteerBrowser = null;
+ }
+ _puppeteerLoading = false;
+ return _puppeteerBrowser;
+}
+
+// PDF response cache: key = "asn:format" or "compare:asn1:asn2" → {buf, ts}
+const pdfCache = new Map();
+const PDF_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
+
+function pdfCacheGet(key) {
+ const e = pdfCache.get(key);
+ if (e && (Date.now() - e.ts) < PDF_CACHE_TTL) return e.buf;
+ return null;
+}
+function pdfCacheSet(key, buf) {
+ if (pdfCache.size > 100) pdfCache.delete(pdfCache.keys().next().value);
+ pdfCache.set(key, { buf, ts: Date.now() });
+}
+
+async function renderHtmlToPdf(html) {
+ const browser = await getPuppeteerBrowser();
+ if (!browser) throw new Error('PDF renderer unavailable (puppeteer not installed)');
+ const page = await browser.newPage();
+ try {
+ await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 15000 });
+ const buf = await page.pdf({
+ format: 'A4',
+ printBackground: true,
+ margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' },
+ });
+ return buf;
+ } finally {
+ await page.close();
+ }
+}
+
+module.exports = { getPuppeteerBrowser, pdfCacheGet, pdfCacheSet, renderHtmlToPdf };
diff --git a/server/pdf-export/templates.js b/server/pdf-export/templates.js
new file mode 100644
index 0000000..14159df
--- /dev/null
+++ b/server/pdf-export/templates.js
@@ -0,0 +1,661 @@
+// FEATURE 2: PDF Export -- pure HTML/CSS template generators, no I/O
+
+// FEATURE 2: PDF Export — template generators + puppeteer renderer
+// ============================================================
+
+/**
+ * Generate a health score ring SVG (inline, no external deps)
+ * @param {number} score 0–100
+ * @param {string} color CSS color
+ */
+function scoreRingSvg(score, color) {
+ const r = 54, cx = 64, cy = 64;
+ const circ = 2 * Math.PI * r;
+ const fill = circ * (1 - score / 100);
+ const clr = color || (score >= 80 ? '#22c55e' : score >= 60 ? '#f59e0b' : '#ef4444');
+ return `
+
+
+ ${score}
+ /100
+ `;
+}
+
+/** Shared CSS for all PDF templates */
+function pdfBaseCSS() {
+ return `
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ body { font-family: -apple-system, 'Segoe UI', system-ui, Arial, sans-serif; color: #1e1b4b; background: #fff; font-size: 11pt; line-height: 1.5; }
+ @page { size: A4; margin: 18mm 16mm; }
+ .page { page-break-after: always; min-height: 247mm; padding: 0; }
+ .page:last-child { page-break-after: avoid; }
+ h1 { font-size: 22pt; font-weight: 700; color: #4f46e5; }
+ h2 { font-size: 14pt; font-weight: 600; color: #3730a3; margin-top: 1.4em; margin-bottom: 0.5em; border-bottom: 2px solid #e0e7ff; padding-bottom: 4px; }
+ h3 { font-size: 11pt; font-weight: 600; color: #4338ca; margin-top: 1em; margin-bottom: 0.3em; }
+ p { margin-bottom: 0.5em; }
+ table { width: 100%; border-collapse: collapse; margin: 0.6em 0; font-size: 9.5pt; }
+ th { background: #4f46e5; color: #fff; padding: 6px 10px; text-align: left; font-weight: 600; }
+ td { padding: 5px 10px; border-bottom: 1px solid #e0e7ff; vertical-align: top; }
+ tr:nth-child(even) td { background: #f5f3ff; }
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8.5pt; font-weight: 600; }
+ .badge-green { background: #dcfce7; color: #166534; }
+ .badge-red { background: #fee2e2; color: #991b1b; }
+ .badge-yellow{ background: #fef9c3; color: #854d0e; }
+ .badge-gray { background: #f3f4f6; color: #374151; }
+ .badge-indigo{ background: #e0e7ff; color: #3730a3; }
+ .cover { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; padding: 40px 0 20px; min-height: 247mm; }
+ .cover-logo { font-size: 11pt; font-weight: 700; color: #4f46e5; letter-spacing: -0.5px; margin-bottom: 60px; display: flex; align-items: center; gap: 8px; }
+ .cover-logo span { color: #1e1b4b; }
+ .cover-asn { font-size: 11pt; font-weight: 500; color: #6b7280; margin-bottom: 6px; }
+ .cover-name { font-size: 28pt; font-weight: 700; color: #1e1b4b; margin-bottom: 8px; line-height: 1.2; }
+ .cover-sub { font-size: 13pt; color: #4f46e5; font-weight: 500; margin-bottom: 30px; }
+ .cover-meta { font-size: 9pt; color: #9ca3af; margin-top: auto; padding-top: 20px; border-top: 1px solid #e0e7ff; width: 100%; }
+ .cover-stripe { position: fixed; right: -8mm; top: 0; width: 28mm; height: 100vh; background: linear-gradient(180deg, #4f46e5 0%, #6366f1 50%, #818cf8 100%); opacity: 0.08; pointer-events: none; }
+ .metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 1em 0; }
+ .metric-card { background: #f5f3ff; border: 1px solid #e0e7ff; border-radius: 8px; padding: 12px 14px; }
+ .metric-card .label { font-size: 8pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
+ .metric-card .value { font-size: 18pt; font-weight: 700; color: #4f46e5; }
+ .metric-card .unit { font-size: 8pt; color: #9ca3af; }
+ .score-row { display: flex; align-items: center; gap: 20px; margin: 0.8em 0; }
+ .check-list { list-style: none; padding: 0; }
+ .check-list li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid #f3f4f6; font-size: 9.5pt; }
+ .check-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
+ .check-pass { color: #16a34a; }
+ .check-fail { color: #dc2626; }
+ .check-warn { color: #d97706; }
+ .section-intro { font-size: 9.5pt; color: #6b7280; margin-bottom: 1em; font-style: italic; }
+ footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 8pt; color: #9ca3af; padding: 6px 16mm; border-top: 1px solid #e0e7ff; display: flex; justify-content: space-between; }
+ `;
+}
+
+/**
+ * Build the PDF HTML for a single ASN.
+ * @param {object} data Full /api/validate response
+ * @param {object} aspa /api/aspa response (may be null)
+ * @param {string} format 'report' | 'executive' | 'technical'
+ */
+function buildPdfHtml(data, aspa, format) {
+ const asn = data.asn || '?';
+ const name = data.name || 'Unknown Network';
+ const score = Math.round(data.health_score || 0);
+ const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+ const checks = (data.score_breakdown || []);
+ const rel = data.relationships || {};
+ const counts = rel.counts || {};
+ // validations is an object keyed by check name; rpki details live in rpki_completeness
+ const validations = (data.validations && typeof data.validations === 'object' && !Array.isArray(data.validations)) ? data.validations : {};
+ const rpkiDetails = (validations.rpki_completeness?.details || []);
+ const totalPfx = data.meta?.total_prefixes || 0;
+
+ // Derived stats — use rpki_completeness for RPKI metrics
+ const rpki = validations.rpki_completeness || {};
+ const rpkiCoverage = rpki.coverage_pct ?? 0;
+ const rpkiTotal = rpki.total_checked || rpkiDetails.length || 0;
+ const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length;
+ const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').length;
+ const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not_found').length;
+
+ const passedChecks = checks.filter(c => c.status === 'pass').length;
+ const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
+ const scoreColor = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
+ const aspaDirect = aspa?.direct?.has_aspa ?? null;
+ const aspaProviders= aspa?.direct?.provider_asns?.length || 0;
+
+ // Check list HTML
+ function checkIcon(passed) {
+ if (passed) return ' ';
+ return ' ';
+ }
+
+ const checksHtml = checks.map(c => {
+ const isPassed = c.status === 'pass';
+ const isInfo = c.status === 'info';
+ const icon = checkIcon(isPassed || isInfo);
+ const statusBadge = isPassed ? 'badge-green' : isInfo ? 'badge-indigo' : 'badge-red';
+ const statusLabel = isPassed ? 'PASS' : isInfo ? 'INFO' : 'FAIL';
+ return `
+
+ ${icon}
+
+ ${escHtml(c.check || '')}
+ ${statusLabel}
+ ${c.earned !== undefined ? `${c.earned}/${c.weight}pt ` : ''}
+
+ `;
+ }).join('');
+
+ // Upstream providers table
+ const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => `
+ AS${u.asn} ${escHtml(u.name || '')} ${u.power ?? '-'}
+ `).join('');
+
+ // Prefix validations table (sample from rpki_completeness.details)
+ const pfxRows = rpkiDetails.slice(0, 20).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 ?? '-'}
+ — `;
+ }).join('');
+
+ // Recommendations
+ const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info');
+ const recHtml = failedItems.length === 0
+ ? '✓ No critical issues found. Network health is excellent.
'
+ : failedItems.map((c, i) => `
+
+
${i+1}. ${escHtml(c.check || '')}
+ ${c.detail ? `
${escHtml(c.detail)}
` : ''}
+
`).join('');
+
+ // ── Executive pages (always present) ───────────────────────────────────────
+ const coverPage = `
+
+
+
⬡ PeerCortex Network Intelligence
+
AS${asn}
+
${escHtml(name)}
+
${formatLabel(format)} Report
+
+ ${scoreRingSvg(score)}
+
+
Health Score
+
${score}/100
+
${passedChecks} passed · ${failedChecks} failed
+
+
+
+ Generated by PeerCortex · ${ts} · peercortex.org
+ | ${totalPfx} prefixes analysed
+ | RPKI coverage ${rpkiCoverage}%
+
+
`;
+
+ const summaryPage = `
+
+
Executive Summary
+
Key performance indicators for AS${asn} (${escHtml(name)}).
+
+
Health Score
${score}
out of 100
+
RPKI Coverage
${rpkiCoverage}%
${rpkiValid}/${rpkiTotal} prefixes
+
Upstreams
${counts.upstreams || 0}
providers
+
Downstreams
${counts.downstreams || 0}
customers
+
Peers
${counts.peers || 0}
BGP sessions
+
ASPA Status
+
+
+ ${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'}
+
+
+
${aspaProviders} providers
+
+
+
Status Overview
+
+
`;
+
+ // ── Recommendations page ────────────────────────────────────────────────────
+ const recsPage = `
+
+
Recommendations
+
Action items to improve network health and security posture.
+ ${recHtml}
+ ${failedItems.length > 0 ? `
+
Remediation Priority
+
+ # Check Impact Effort
+ ${failedItems.map((c, i) => `
+
+ ${i+1}
+ ${escHtml(c.check || '')}
+ High
+ Medium
+ `).join('')}
+
` : ''}
+
`;
+
+ // ── Detail pages (included for 'report' and 'technical') ───────────────────
+ const rpkiPage = `
+
+
RPKI Compliance
+
Route Origin Authorization (ROA) validation results for announced prefixes.
+
+
Coverage
${rpkiCoverage}%
${rpkiValid} valid ROAs
+
Invalid
${rpkiInvalid}
prefix${rpkiInvalid !== 1 ? 'es' : ''}
+
Not Found
${rpkiNotFound}
no ROA
+
+
ASPA (Autonomous System Provider Authorization)
+
+ Attribute Value
+ ASPA Object Present ${aspaDirect ? 'Yes' : 'No'}
+ Provider Count ${aspaProviders}
+ Providers ${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || '—'}
+
+ ${pfxRows ? `
+
Prefix Validation Sample (first 20)
+
+ Prefix Status Max Length ROA Origin
+ ${pfxRows}
+
` : ''}
+
`;
+
+ const bgpPage = `
+
+
BGP Topology
+
AS relationship overview and upstream connectivity analysis.
+
+
Upstreams
${counts.upstreams || 0}
transit providers
+
Downstreams
${counts.downstreams || 0}
customers
+
Peers
${counts.peers || 0}
BGP peers
+
+ ${upstreamRows ? `
+
Top Upstream Providers
+
+ ASN Name Power Score
+ ${upstreamRows}
+
` : '
No upstream data available.
'}
+
`;
+
+ const footer = `
+
+ PeerCortex · AS${asn} · ${escHtml(name)}
+ 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
+
+ Attribute Value
+ ASPA Present ${aspaDirect ? 'Yes' : 'No'}
+ Provider Count ${aspaProviders}
+ Registered Providers ${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || '—'}
+
+ ${allPfxRows ? `
+
All Prefixes
+
+ Prefix RPKI Status Validating ROAs Max Length
+ ${allPfxRows}
+
` : '
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
+
+ ASN Name Power Score
+ ${allUpstreamRows}
+
` : '
No upstream data available.
'}
+ ${(rel.peers || []).length > 0 ? `
+
Peer Sample (top 20)
+
+ ASN Name
+ ${(rel.peers || []).slice(0, 20).map(p => `AS${p.asn} ${escHtml(p.name || '')} `).join('')}
+
` : ''}
+
`;
+
+ const checkDetailPage = `
+
+
Check Score Detail
+
All ${checks.length} health checks with individual scores and diagnostic notes.
+
+ Check Status Earned Weight Detail
+ ${checkScoreRows}
+
+
+ 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
+
+ Threat ASPA Mitigates? Notes
+ Route leaks (valley-free violations) Yes Providers verify upstream path
+ BGP hijacks (origin AS spoofing) Partial Combined with ROV
+ AS-path prepending attacks Yes Invalid paths rejected
+ Forged-origin hijacks No ROA + 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 Source What it provides Update Frequency
+ Cloudflare RPKI Feed ROA + ASPA objects Every 4 hours
+ PeeringDB IXP presence, peering policy, facilities Daily (03:00 UTC)
+ RIPE Stat AS relationships, prefix visibility, BGP data Real-time / cached 15 min
+ RIPE Atlas Active measurement probes by ASN Cached 12 hours
+ bgproutes.io BGP route visibility fallback Real-time
+ bgp.he.net AS relationship graph Cached 24 hours
+
+
Report Metadata
+
+ Field Value
+ ASN AS${asn}
+ Network Name ${escHtml(name)}
+ Report Format ${formatLabel(format)}
+ Generated ${ts}
+ Platform PeerCortex · 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') {
+ // ~8 pages: engineer-focused, dense, no decorative cover, full data tables
+ bodyContent = technicalHeaderPage + rpkiFullPage + bgpFullPage + checkDetailPage + recsPage;
+ } else {
+ // 'report' — full stakeholder + technical, ~10 pages
+ bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + aspaPage + recsPage + provenancePage;
+ }
+
+ return `
+
+
+
+ PeerCortex — AS${asn} ${formatLabel(format)}
+
+
+
+ ${bodyContent}
+ ${footer}
+
+`;
+}
+
+/**
+ * Build comparison PDF HTML for two ASNs.
+ */
+function buildComparisonPdfHtml(d1, aspa1, l1, d2, aspa2, l2) {
+ const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+
+ function col(d, aspa) {
+ const asn = d.asn || '?';
+ const name = d.name || 'Unknown';
+ const score = Math.round(d.health_score || 0);
+ const rel = d.relationships || {};
+ const counts= rel.counts || {};
+ const vals = (d.validations && !Array.isArray(d.validations)) ? d.validations : {};
+ const rpki = vals.rpki_completeness || {};
+ const rpkiV = rpki.with_roa || 0;
+ const rpkiT = rpki.total_checked || 0;
+ const rpkiC = rpki.coverage_pct ?? (rpkiT > 0 ? Math.round((rpkiV / rpkiT) * 100) : 0);
+ const sc = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
+ const aspaDep = aspa?.direct?.has_aspa ?? null;
+ const checks= (d.score_breakdown || []);
+ const passC = checks.filter(c => c.status === 'pass').length;
+ const failC = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
+
+ return `
+
+
+ ${scoreRingSvg(score, sc)}
+
AS${asn}
+
${escHtml(name)}
+
+
+ Key Metrics
+ Health Score ${score}/100
+ RPKI Coverage ${rpkiC}% (${rpkiV}/${rpkiT})
+ Upstreams ${counts.upstreams || 0}
+ Downstreams ${counts.downstreams || 0}
+ Peers ${counts.peers || 0}
+ ASPA ${aspaDep ? 'Deployed' : 'Not Deployed'}
+ Checks Passed ${passC} / ${checks.length}
+ Checks Failed ${failC}
+ Total Prefixes ${d.meta?.total_prefixes || 0}
+
+ `;
+ }
+
+ const colHtml = col(d1, aspa1) + col(d2, aspa2);
+
+ // ── Peering Intelligence: common IXPs + common facilities ─────────────────
+ const ixList1 = (l1?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
+ const ixList2 = (l2?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
+ const ixIds1 = new Set(ixList1.map(x => x.id));
+ const ixIds2 = new Set(ixList2.map(x => x.id));
+ const commonIxIds = [...ixIds1].filter(id => ixIds2.has(id));
+ const commonIxps = commonIxIds.slice(0, 20).map(id => {
+ const x1 = ixList1.find(x => x.id === id);
+ const x2 = ixList2.find(x => x.id === id);
+ return { id, name: x1?.name || 'Unknown', city: x1?.city || '', speed1: x1?.speed, speed2: x2?.speed };
+ });
+
+ const facList1 = (l1?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
+ const facList2 = (l2?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
+ const facIds1 = new Set(facList1.map(f => f.id));
+ const facIds2 = new Set(facList2.map(f => f.id));
+ const commonFacIds = [...facIds1].filter(id => facIds2.has(id));
+ const commonFacs = commonFacIds.slice(0, 15).map(id => facList1.find(f => f.id === id));
+
+ // IXPs of ASN1 where ASN2 is NOT present (opportunity IXPs for ASN2)
+ const oppsFor2 = ixList1.filter(x => !ixIds2.has(x.id)).slice(0, 8);
+ const oppsFor1 = ixList2.filter(x => !ixIds1.has(x.id)).slice(0, 8);
+
+ const commonIxHtml = commonIxps.length > 0
+ ? `
+ IXP Name City AS${d1.asn} Speed AS${d2.asn} Speed
+ ${commonIxps.map(x => `
+
+ ${escHtml(x.name)}
+ ${escHtml(x.city)}
+ ${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}
+ ${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}
+ `).join('')}
+
`
+ : 'No common IXPs found — these networks do not share any Internet Exchange Points.
';
+
+ const commonFacHtml = commonFacs.length > 0
+ ? `
+ Datacenter / Colocation Facility City Country
+ ${commonFacs.map(f => `
+
+ ${escHtml(f?.name||'')}
+ ${escHtml(f?.city||'')}
+ ${escHtml(f?.country||'')}
+ `).join('')}
+
`
+ : 'No shared colocation facilities found.
';
+
+ const oppsHtml = (asns, opps) => opps.length === 0
+ ? `Already maximally peered on these exchanges — or no data available.
`
+ : opps.map(x => `
+ ${escHtml(x.name)}
+ ${x.city ? ` · ${escHtml(x.city)} ` : ''}
+ ${x.speed ? `${(x.speed/1000).toLocaleString()}G ` : ''}
+
`).join('');
+
+ const peeringPage = (commonIxps.length + commonFacs.length > 0 || oppsFor1.length + oppsFor2.length > 0) ? `
+
+
Peering Intelligence
+
Common Internet Exchange Points and colocation facilities — potential peering locations between AS${d1.asn} and AS${d2.asn}.
+
+
+
+
${commonIxps.length}
+
Common IXPs
+
+
+
${commonFacs.length}
+
Shared Facilities
+
+
+
+
Common Internet Exchange Points (direct peering possible)
+ ${commonIxHtml}
+
+
Shared Colocation Facilities
+ ${commonFacHtml}
+
+ ${oppsFor1.length + oppsFor2.length > 0 ? `
+
Expansion Opportunities
+
+
+
AS${d2.asn} could join (AS${d1.asn} is there)
+ ${oppsHtml(d2.asn, oppsFor2)}
+
+
+
AS${d1.asn} could join (AS${d2.asn} is there)
+ ${oppsHtml(d1.asn, oppsFor1)}
+
+
` : ''}
+
` : '';
+
+ // Side-by-side check comparison
+ const allChecks = [...new Set([
+ ...(d1.score_breakdown || []).map(c => c.check),
+ ...(d2.score_breakdown || []).map(c => c.check),
+ ])];
+ const checkRows = allChecks.map(checkName => {
+ const c1 = (d1.score_breakdown || []).find(c => c.check === checkName);
+ const c2 = (d2.score_breakdown || []).find(c => c.check === checkName);
+ const isP1 = c1?.status === 'pass';
+ const isP2 = c2?.status === 'pass';
+ const icon = p => p ? '✓' : '✗';
+ const st = p => p ? 'color:#16a34a;font-weight:700' : 'color:#dc2626;font-weight:700';
+ return `
+ ${escHtml(checkName)}
+ ${c1 ? icon(isP1) : '—'}
+ ${c2 ? icon(isP2) : '—'}
+ `;
+ }).join('');
+
+ return `
+
+
+
+ PeerCortex — AS${d1.asn} vs AS${d2.asn} Comparison
+
+
+
+
+
+
⬡ PeerCortex Network Intelligence
+
+
ASN Comparison Report
+
AS${d1.asn} vs AS${d2.asn}
+
${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}
+
+
+
Generated by PeerCortex · ${ts} · peercortex.org
+
+
+
Head-to-Head Health Checks
+
+ Check AS${d1.asn} AS${d2.asn}
+ ${checkRows}
+
+
+ ${peeringPage}
+
+ PeerCortex · AS${d1.asn} vs AS${d2.asn}
+ Generated ${ts} · peercortex.org
+
+
+`;
+}
+
+function formatLabel(fmt) {
+ return fmt === 'executive' ? 'Executive Summary' : fmt === 'technical' ? 'Technical Analysis' : 'Full Report';
+}
+
+function escHtml(s) {
+ if (!s) return '';
+ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+}
+
+module.exports = { scoreRingSvg, pdfBaseCSS, buildPdfHtml, buildComparisonPdfHtml, formatLabel, escHtml };