refactor: extract ipv6-adoption.js and pdf-export/{templates,renderer}.js

templates.js and renderer.js content extracted via direct line-slicing
(not retyped) given their size (~660-line HTML/CSS template functions) --
avoids transcription risk on template-literal-heavy code.

Verified via smoke-test harness (28/28 match). server.js: 4492 -> 3784 lines
(6838 originally -- 45% reduction so far).
This commit is contained in:
Rene Fichtmueller 2026-07-17 00:09:10 +02:00
parent a5912a3d0c
commit 7701edd657
4 changed files with 821 additions and 806 deletions

821
server.js
View File

@ -7,50 +7,6 @@ const crypto = require("crypto");
const localDb = require('./local-db-client'); const localDb = require('./local-db-client');
console.log('[PeerCortex] Local DB client initialized'); 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 // Load .env file
require('./src/backend/config'); require('./src/backend/config');
@ -116,773 +72,26 @@ const {
buildAspaAdoptionDashboard, buildAspaAdoptionDashboard,
} = require("./server/aspa-adoption/tracker"); } = require("./server/aspa-adoption/tracker");
// ============================================================ const { fetchRirDelegation, fetchIpv6AdoptionStats } = require("./server/ipv6-adoption");
// 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 { UA } = require("./server/data/constants"); const { UA } = require("./server/data/constants");
const { CITY_COORDS } = require("./server/data/city-coords"); const { CITY_COORDS } = require("./server/data/city-coords");
// ============================================================ // ============================================================
// FEATURE 2: PDF Export — template generators + puppeteer renderer const {
// ============================================================ scoreRingSvg,
pdfBaseCSS,
/** buildPdfHtml,
* Generate a health score ring SVG (inline, no external deps) buildComparisonPdfHtml,
* @param {number} score 0100 formatLabel,
* @param {string} color CSS color escHtml,
*/ } = require("./server/pdf-export/templates");
function scoreRingSvg(score, color) { const {
const r = 54, cx = 64, cy = 64; getPuppeteerBrowser,
const circ = 2 * Math.PI * r; pdfCacheGet,
const fill = circ * (1 - score / 100); pdfCacheSet,
const clr = color || (score >= 80 ? '#22c55e' : score >= 60 ? '#f59e0b' : '#ef4444'); renderHtmlToPdf,
return `<svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg"> } = require("./server/pdf-export/renderer");
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="12"/>
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${clr}" stroke-width="12"
stroke-dasharray="${circ}" stroke-dashoffset="${fill}"
stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/>
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
font-family="system-ui,sans-serif" font-size="26" font-weight="700" fill="${clr}">${score}</text>
<text x="${cx}" y="${cy + 20}" text-anchor="middle"
font-family="system-ui,sans-serif" font-size="10" fill="#6b7280">/100</text>
</svg>`;
}
/** 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 '<svg class="check-icon check-pass" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
return '<svg class="check-icon check-fail" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
}
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 `
<li>
${icon}
<div>
<span style="font-weight:600">${escHtml(c.check || '')}</span>
<span class="badge ${statusBadge}" style="margin-left:6px">${statusLabel}</span>
${c.earned !== undefined ? `<span class="badge badge-indigo" style="margin-left:4px">${c.earned}/${c.weight}pt</span>` : ''}
</div>
</li>`;
}).join('');
// Upstream providers table
const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => `
<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>
`).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 `<tr><td>${escHtml(v.prefix)}</td>
<td><span class="badge ${badge}">${v.status}</span></td>
<td>${v.validating_roas ?? '-'}</td>
<td></td></tr>`;
}).join('');
// Recommendations
const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info');
const recHtml = failedItems.length === 0
? '<p style="color:#16a34a;font-weight:500">✓ No critical issues found. Network health is excellent.</p>'
: failedItems.map((c, i) => `
<div style="margin:8px 0; padding:10px 14px; background:#fef2f2; border-left:3px solid #ef4444; border-radius:4px;">
<div style="font-weight:600;color:#991b1b">${i+1}. ${escHtml(c.check || '')}</div>
${c.detail ? `<div style="font-size:9pt;color:#6b7280;margin-top:3px">${escHtml(c.detail)}</div>` : ''}
</div>`).join('');
// ── Executive pages (always present) ───────────────────────────────────────
const coverPage = `
<div class="page cover">
<div class="cover-stripe"></div>
<div class="cover-logo"> PeerCortex <span>Network Intelligence</span></div>
<div class="cover-asn">AS${asn}</div>
<div class="cover-name">${escHtml(name)}</div>
<div class="cover-sub">${formatLabel(format)} Report</div>
<div style="margin:20px 0; display:flex; align-items:center; gap:16px;">
${scoreRingSvg(score)}
<div>
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Health Score</div>
<div style="font-size:26pt;font-weight:700;color:${scoreColor}">${score}/100</div>
<div style="font-size:9pt;color:#6b7280">${passedChecks} passed · ${failedChecks} failed</div>
</div>
</div>
<div class="cover-meta">
Generated by PeerCortex · ${ts} · peercortex.org
&nbsp;&nbsp;|&nbsp;&nbsp; ${totalPfx} prefixes analysed
&nbsp;&nbsp;|&nbsp;&nbsp; RPKI coverage ${rpkiCoverage}%
</div>
</div>`;
const summaryPage = `
<div class="page">
<h2>Executive Summary</h2>
<p class="section-intro">Key performance indicators for AS${asn} (${escHtml(name)}).</p>
<div class="metric-grid">
<div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}</div><div class="unit">out of 100</div></div>
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid}/${rpkiTotal} prefixes</div></div>
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">providers</div></div>
<div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP sessions</div></div>
<div class="metric-card"><div class="label">ASPA Status</div>
<div class="value" style="font-size:12pt;margin-top:4px">
<span class="badge ${aspaDirect === true ? 'badge-green' : aspaDirect === false ? 'badge-red' : 'badge-gray'}">
${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'}
</span>
</div>
<div class="unit">${aspaProviders} providers</div>
</div>
</div>
<h3>Status Overview</h3>
<ul class="check-list">${checksHtml}</ul>
</div>`;
// ── Recommendations page ────────────────────────────────────────────────────
const recsPage = `
<div class="page">
<h2>Recommendations</h2>
<p class="section-intro">Action items to improve network health and security posture.</p>
${recHtml}
${failedItems.length > 0 ? `
<h3 style="margin-top:1.5em">Remediation Priority</h3>
<table>
<tr><th>#</th><th>Check</th><th>Impact</th><th>Effort</th></tr>
${failedItems.map((c, i) => `
<tr>
<td>${i+1}</td>
<td>${escHtml(c.check || '')}</td>
<td><span class="badge badge-red">High</span></td>
<td><span class="badge badge-yellow">Medium</span></td>
</tr>`).join('')}
</table>` : ''}
</div>`;
// ── Detail pages (included for 'report' and 'technical') ───────────────────
const rpkiPage = `
<div class="page">
<h2>RPKI Compliance</h2>
<p class="section-intro">Route Origin Authorization (ROA) validation results for announced prefixes.</p>
<div class="metric-grid">
<div class="metric-card"><div class="label">Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid ROAs</div></div>
<div class="metric-card"><div class="label">Invalid</div><div class="value" style="color:#dc2626">${rpkiInvalid}</div><div class="unit">prefix${rpkiInvalid !== 1 ? 'es' : ''}</div></div>
<div class="metric-card"><div class="label">Not Found</div><div class="value" style="color:#d97706">${rpkiNotFound}</div><div class="unit">no ROA</div></div>
</div>
<h3>ASPA (Autonomous System Provider Authorization)</h3>
<table>
<tr><th>Attribute</th><th>Value</th></tr>
<tr><td>ASPA Object 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>Providers</td><td>${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || ''}</td></tr>
</table>
${pfxRows ? `
<h3>Prefix Validation Sample (first 20)</h3>
<table>
<tr><th>Prefix</th><th>Status</th><th>Max Length</th><th>ROA Origin</th></tr>
${pfxRows}
</table>` : ''}
</div>`;
const bgpPage = `
<div class="page">
<h2>BGP Topology</h2>
<p class="section-intro">AS relationship overview and upstream connectivity analysis.</p>
<div class="metric-grid">
<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">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP peers</div></div>
</div>
${upstreamRows ? `
<h3>Top Upstream Providers</h3>
<table>
<tr><th>ASN</th><th>Name</th><th>Power Score</th></tr>
${upstreamRows}
</table>` : '<p style="color:#9ca3af">No upstream data available.</p>'}
</div>`;
const footer = `
<footer>
<span>PeerCortex · AS${asn} · ${escHtml(name)}</span>
<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') {
// ~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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>PeerCortex AS${asn} ${formatLabel(format)}</title>
<style>${pdfBaseCSS()}</style>
</head>
<body>
${bodyContent}
${footer}
</body>
</html>`;
}
/**
* 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 `
<td style="width:50%;padding:0 10px;vertical-align:top;border-right:2px solid #e0e7ff;">
<div style="text-align:center;padding:16px 0 12px;">
${scoreRingSvg(score, sc)}
<div style="font-size:10pt;color:#6b7280;margin-top:4px">AS${asn}</div>
<div style="font-size:13pt;font-weight:700;color:#1e1b4b">${escHtml(name)}</div>
</div>
<table>
<tr><th colspan="2" style="background:#4f46e5">Key Metrics</th></tr>
<tr><td>Health Score</td><td style="font-weight:700;color:${sc}">${score}/100</td></tr>
<tr><td>RPKI Coverage</td><td>${rpkiC}% (${rpkiV}/${rpkiT})</td></tr>
<tr><td>Upstreams</td><td>${counts.upstreams || 0}</td></tr>
<tr><td>Downstreams</td><td>${counts.downstreams || 0}</td></tr>
<tr><td>Peers</td><td>${counts.peers || 0}</td></tr>
<tr><td>ASPA</td><td><span class="badge ${aspaDep ? 'badge-green' : 'badge-red'}">${aspaDep ? 'Deployed' : 'Not Deployed'}</span></td></tr>
<tr><td>Checks Passed</td><td style="color:#16a34a;font-weight:600">${passC} / ${checks.length}</td></tr>
<tr><td>Checks Failed</td><td style="color:#dc2626;font-weight:600">${failC}</td></tr>
<tr><td>Total Prefixes</td><td>${d.meta?.total_prefixes || 0}</td></tr>
</table>
</td>`;
}
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
? `<table>
<tr><th>IXP Name</th><th>City</th><th>AS${d1.asn} Speed</th><th>AS${d2.asn} Speed</th></tr>
${commonIxps.map(x => `
<tr>
<td style="font-weight:500">${escHtml(x.name)}</td>
<td>${escHtml(x.city)}</td>
<td>${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}</td>
<td>${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}</td>
</tr>`).join('')}
</table>`
: '<p style="color:#6b7280;font-style:italic">No common IXPs found — these networks do not share any Internet Exchange Points.</p>';
const commonFacHtml = commonFacs.length > 0
? `<table>
<tr><th>Datacenter / Colocation Facility</th><th>City</th><th>Country</th></tr>
${commonFacs.map(f => `
<tr>
<td style="font-weight:500">${escHtml(f?.name||'')}</td>
<td>${escHtml(f?.city||'')}</td>
<td>${escHtml(f?.country||'')}</td>
</tr>`).join('')}
</table>`
: '<p style="color:#6b7280;font-style:italic">No shared colocation facilities found.</p>';
const oppsHtml = (asns, opps) => opps.length === 0
? `<p style="color:#16a34a;font-size:9pt">Already maximally peered on these exchanges — or no data available.</p>`
: opps.map(x => `<div style="padding:4px 0;border-bottom:1px solid #f3f4f6;font-size:9pt">
<span style="font-weight:600">${escHtml(x.name)}</span>
${x.city ? `<span style="color:#6b7280"> · ${escHtml(x.city)}</span>` : ''}
${x.speed ? `<span class="badge badge-indigo" style="margin-left:6px">${(x.speed/1000).toLocaleString()}G</span>` : ''}
</div>`).join('');
const peeringPage = (commonIxps.length + commonFacs.length > 0 || oppsFor1.length + oppsFor2.length > 0) ? `
<div class="page">
<h2>Peering Intelligence</h2>
<p class="section-intro">Common Internet Exchange Points and colocation facilities potential peering locations between AS${d1.asn} and AS${d2.asn}.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px">
<div style="background:#e0e7ff;border-radius:6px;padding:10px 14px;text-align:center">
<div style="font-size:20pt;font-weight:700;color:#4f46e5">${commonIxps.length}</div>
<div style="font-size:9pt;color:#6b7280">Common IXPs</div>
</div>
<div style="background:#e0e7ff;border-radius:6px;padding:10px 14px;text-align:center">
<div style="font-size:20pt;font-weight:700;color:#4f46e5">${commonFacs.length}</div>
<div style="font-size:9pt;color:#6b7280">Shared Facilities</div>
</div>
</div>
<h3>Common Internet Exchange Points (direct peering possible)</h3>
${commonIxHtml}
<h3 style="margin-top:1.2em">Shared Colocation Facilities</h3>
${commonFacHtml}
${oppsFor1.length + oppsFor2.length > 0 ? `
<h3 style="margin-top:1.2em">Expansion Opportunities</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:6px">
<div>
<div style="font-weight:600;color:#4f46e5;margin-bottom:6px;font-size:9.5pt">AS${d2.asn} could join (AS${d1.asn} is there)</div>
${oppsHtml(d2.asn, oppsFor2)}
</div>
<div>
<div style="font-weight:600;color:#4f46e5;margin-bottom:6px;font-size:9.5pt">AS${d1.asn} could join (AS${d2.asn} is there)</div>
${oppsHtml(d1.asn, oppsFor1)}
</div>
</div>` : ''}
</div>` : '';
// 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 `<tr>
<td>${escHtml(checkName)}</td>
<td style="${st(isP1)}">${c1 ? icon(isP1) : '—'}</td>
<td style="${st(isP2)}">${c2 ? icon(isP2) : '—'}</td>
</tr>`;
}).join('');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>PeerCortex AS${d1.asn} vs AS${d2.asn} Comparison</title>
<style>${pdfBaseCSS()}</style>
</head>
<body>
<div class="page cover">
<div class="cover-stripe"></div>
<div class="cover-logo"> PeerCortex <span>Network Intelligence</span></div>
<div style="margin-top:40px">
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">ASN Comparison Report</div>
<div style="font-size:24pt;font-weight:700;color:#1e1b4b">AS${d1.asn} vs AS${d2.asn}</div>
<div style="font-size:13pt;color:#4f46e5;margin-top:4px">${escHtml(d1.name)} vs ${escHtml(d2.name)}</div>
</div>
<table style="margin-top:40px"><tr>${colHtml}</tr></table>
<div class="cover-meta" style="margin-top:auto;padding-top:16px">Generated by PeerCortex · ${ts} · peercortex.org</div>
</div>
<div class="page">
<h2>Head-to-Head Health Checks</h2>
<table>
<tr><th>Check</th><th>AS${d1.asn}</th><th>AS${d2.asn}</th></tr>
${checkRows}
</table>
</div>
${peeringPage}
<footer>
<span>PeerCortex · AS${d1.asn} vs AS${d2.asn}</span>
<span>Generated ${ts} · peercortex.org</span>
</footer>
</body>
</html>`;
}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/**
* 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 { const {
responseCache, responseCache,

80
server/ipv6-adoption.js Normal file
View File

@ -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 };

View File

@ -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 };

View File

@ -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 0100
* @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 `<svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg">
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="12"/>
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${clr}" stroke-width="12"
stroke-dasharray="${circ}" stroke-dashoffset="${fill}"
stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/>
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
font-family="system-ui,sans-serif" font-size="26" font-weight="700" fill="${clr}">${score}</text>
<text x="${cx}" y="${cy + 20}" text-anchor="middle"
font-family="system-ui,sans-serif" font-size="10" fill="#6b7280">/100</text>
</svg>`;
}
/** 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 '<svg class="check-icon check-pass" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
return '<svg class="check-icon check-fail" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
}
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 `
<li>
${icon}
<div>
<span style="font-weight:600">${escHtml(c.check || '')}</span>
<span class="badge ${statusBadge}" style="margin-left:6px">${statusLabel}</span>
${c.earned !== undefined ? `<span class="badge badge-indigo" style="margin-left:4px">${c.earned}/${c.weight}pt</span>` : ''}
</div>
</li>`;
}).join('');
// Upstream providers table
const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => `
<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>
`).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 `<tr><td>${escHtml(v.prefix)}</td>
<td><span class="badge ${badge}">${v.status}</span></td>
<td>${v.validating_roas ?? '-'}</td>
<td></td></tr>`;
}).join('');
// Recommendations
const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info');
const recHtml = failedItems.length === 0
? '<p style="color:#16a34a;font-weight:500">✓ No critical issues found. Network health is excellent.</p>'
: failedItems.map((c, i) => `
<div style="margin:8px 0; padding:10px 14px; background:#fef2f2; border-left:3px solid #ef4444; border-radius:4px;">
<div style="font-weight:600;color:#991b1b">${i+1}. ${escHtml(c.check || '')}</div>
${c.detail ? `<div style="font-size:9pt;color:#6b7280;margin-top:3px">${escHtml(c.detail)}</div>` : ''}
</div>`).join('');
// ── Executive pages (always present) ───────────────────────────────────────
const coverPage = `
<div class="page cover">
<div class="cover-stripe"></div>
<div class="cover-logo"> PeerCortex <span>Network Intelligence</span></div>
<div class="cover-asn">AS${asn}</div>
<div class="cover-name">${escHtml(name)}</div>
<div class="cover-sub">${formatLabel(format)} Report</div>
<div style="margin:20px 0; display:flex; align-items:center; gap:16px;">
${scoreRingSvg(score)}
<div>
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Health Score</div>
<div style="font-size:26pt;font-weight:700;color:${scoreColor}">${score}/100</div>
<div style="font-size:9pt;color:#6b7280">${passedChecks} passed · ${failedChecks} failed</div>
</div>
</div>
<div class="cover-meta">
Generated by PeerCortex · ${ts} · peercortex.org
&nbsp;&nbsp;|&nbsp;&nbsp; ${totalPfx} prefixes analysed
&nbsp;&nbsp;|&nbsp;&nbsp; RPKI coverage ${rpkiCoverage}%
</div>
</div>`;
const summaryPage = `
<div class="page">
<h2>Executive Summary</h2>
<p class="section-intro">Key performance indicators for AS${asn} (${escHtml(name)}).</p>
<div class="metric-grid">
<div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}</div><div class="unit">out of 100</div></div>
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid}/${rpkiTotal} prefixes</div></div>
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">providers</div></div>
<div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP sessions</div></div>
<div class="metric-card"><div class="label">ASPA Status</div>
<div class="value" style="font-size:12pt;margin-top:4px">
<span class="badge ${aspaDirect === true ? 'badge-green' : aspaDirect === false ? 'badge-red' : 'badge-gray'}">
${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'}
</span>
</div>
<div class="unit">${aspaProviders} providers</div>
</div>
</div>
<h3>Status Overview</h3>
<ul class="check-list">${checksHtml}</ul>
</div>`;
// ── Recommendations page ────────────────────────────────────────────────────
const recsPage = `
<div class="page">
<h2>Recommendations</h2>
<p class="section-intro">Action items to improve network health and security posture.</p>
${recHtml}
${failedItems.length > 0 ? `
<h3 style="margin-top:1.5em">Remediation Priority</h3>
<table>
<tr><th>#</th><th>Check</th><th>Impact</th><th>Effort</th></tr>
${failedItems.map((c, i) => `
<tr>
<td>${i+1}</td>
<td>${escHtml(c.check || '')}</td>
<td><span class="badge badge-red">High</span></td>
<td><span class="badge badge-yellow">Medium</span></td>
</tr>`).join('')}
</table>` : ''}
</div>`;
// ── Detail pages (included for 'report' and 'technical') ───────────────────
const rpkiPage = `
<div class="page">
<h2>RPKI Compliance</h2>
<p class="section-intro">Route Origin Authorization (ROA) validation results for announced prefixes.</p>
<div class="metric-grid">
<div class="metric-card"><div class="label">Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid ROAs</div></div>
<div class="metric-card"><div class="label">Invalid</div><div class="value" style="color:#dc2626">${rpkiInvalid}</div><div class="unit">prefix${rpkiInvalid !== 1 ? 'es' : ''}</div></div>
<div class="metric-card"><div class="label">Not Found</div><div class="value" style="color:#d97706">${rpkiNotFound}</div><div class="unit">no ROA</div></div>
</div>
<h3>ASPA (Autonomous System Provider Authorization)</h3>
<table>
<tr><th>Attribute</th><th>Value</th></tr>
<tr><td>ASPA Object 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>Providers</td><td>${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || ''}</td></tr>
</table>
${pfxRows ? `
<h3>Prefix Validation Sample (first 20)</h3>
<table>
<tr><th>Prefix</th><th>Status</th><th>Max Length</th><th>ROA Origin</th></tr>
${pfxRows}
</table>` : ''}
</div>`;
const bgpPage = `
<div class="page">
<h2>BGP Topology</h2>
<p class="section-intro">AS relationship overview and upstream connectivity analysis.</p>
<div class="metric-grid">
<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">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP peers</div></div>
</div>
${upstreamRows ? `
<h3>Top Upstream Providers</h3>
<table>
<tr><th>ASN</th><th>Name</th><th>Power Score</th></tr>
${upstreamRows}
</table>` : '<p style="color:#9ca3af">No upstream data available.</p>'}
</div>`;
const footer = `
<footer>
<span>PeerCortex · AS${asn} · ${escHtml(name)}</span>
<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') {
// ~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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>PeerCortex AS${asn} ${formatLabel(format)}</title>
<style>${pdfBaseCSS()}</style>
</head>
<body>
${bodyContent}
${footer}
</body>
</html>`;
}
/**
* 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 `
<td style="width:50%;padding:0 10px;vertical-align:top;border-right:2px solid #e0e7ff;">
<div style="text-align:center;padding:16px 0 12px;">
${scoreRingSvg(score, sc)}
<div style="font-size:10pt;color:#6b7280;margin-top:4px">AS${asn}</div>
<div style="font-size:13pt;font-weight:700;color:#1e1b4b">${escHtml(name)}</div>
</div>
<table>
<tr><th colspan="2" style="background:#4f46e5">Key Metrics</th></tr>
<tr><td>Health Score</td><td style="font-weight:700;color:${sc}">${score}/100</td></tr>
<tr><td>RPKI Coverage</td><td>${rpkiC}% (${rpkiV}/${rpkiT})</td></tr>
<tr><td>Upstreams</td><td>${counts.upstreams || 0}</td></tr>
<tr><td>Downstreams</td><td>${counts.downstreams || 0}</td></tr>
<tr><td>Peers</td><td>${counts.peers || 0}</td></tr>
<tr><td>ASPA</td><td><span class="badge ${aspaDep ? 'badge-green' : 'badge-red'}">${aspaDep ? 'Deployed' : 'Not Deployed'}</span></td></tr>
<tr><td>Checks Passed</td><td style="color:#16a34a;font-weight:600">${passC} / ${checks.length}</td></tr>
<tr><td>Checks Failed</td><td style="color:#dc2626;font-weight:600">${failC}</td></tr>
<tr><td>Total Prefixes</td><td>${d.meta?.total_prefixes || 0}</td></tr>
</table>
</td>`;
}
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
? `<table>
<tr><th>IXP Name</th><th>City</th><th>AS${d1.asn} Speed</th><th>AS${d2.asn} Speed</th></tr>
${commonIxps.map(x => `
<tr>
<td style="font-weight:500">${escHtml(x.name)}</td>
<td>${escHtml(x.city)}</td>
<td>${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}</td>
<td>${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}</td>
</tr>`).join('')}
</table>`
: '<p style="color:#6b7280;font-style:italic">No common IXPs found — these networks do not share any Internet Exchange Points.</p>';
const commonFacHtml = commonFacs.length > 0
? `<table>
<tr><th>Datacenter / Colocation Facility</th><th>City</th><th>Country</th></tr>
${commonFacs.map(f => `
<tr>
<td style="font-weight:500">${escHtml(f?.name||'')}</td>
<td>${escHtml(f?.city||'')}</td>
<td>${escHtml(f?.country||'')}</td>
</tr>`).join('')}
</table>`
: '<p style="color:#6b7280;font-style:italic">No shared colocation facilities found.</p>';
const oppsHtml = (asns, opps) => opps.length === 0
? `<p style="color:#16a34a;font-size:9pt">Already maximally peered on these exchanges — or no data available.</p>`
: opps.map(x => `<div style="padding:4px 0;border-bottom:1px solid #f3f4f6;font-size:9pt">
<span style="font-weight:600">${escHtml(x.name)}</span>
${x.city ? `<span style="color:#6b7280"> · ${escHtml(x.city)}</span>` : ''}
${x.speed ? `<span class="badge badge-indigo" style="margin-left:6px">${(x.speed/1000).toLocaleString()}G</span>` : ''}
</div>`).join('');
const peeringPage = (commonIxps.length + commonFacs.length > 0 || oppsFor1.length + oppsFor2.length > 0) ? `
<div class="page">
<h2>Peering Intelligence</h2>
<p class="section-intro">Common Internet Exchange Points and colocation facilities potential peering locations between AS${d1.asn} and AS${d2.asn}.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px">
<div style="background:#e0e7ff;border-radius:6px;padding:10px 14px;text-align:center">
<div style="font-size:20pt;font-weight:700;color:#4f46e5">${commonIxps.length}</div>
<div style="font-size:9pt;color:#6b7280">Common IXPs</div>
</div>
<div style="background:#e0e7ff;border-radius:6px;padding:10px 14px;text-align:center">
<div style="font-size:20pt;font-weight:700;color:#4f46e5">${commonFacs.length}</div>
<div style="font-size:9pt;color:#6b7280">Shared Facilities</div>
</div>
</div>
<h3>Common Internet Exchange Points (direct peering possible)</h3>
${commonIxHtml}
<h3 style="margin-top:1.2em">Shared Colocation Facilities</h3>
${commonFacHtml}
${oppsFor1.length + oppsFor2.length > 0 ? `
<h3 style="margin-top:1.2em">Expansion Opportunities</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:6px">
<div>
<div style="font-weight:600;color:#4f46e5;margin-bottom:6px;font-size:9.5pt">AS${d2.asn} could join (AS${d1.asn} is there)</div>
${oppsHtml(d2.asn, oppsFor2)}
</div>
<div>
<div style="font-weight:600;color:#4f46e5;margin-bottom:6px;font-size:9.5pt">AS${d1.asn} could join (AS${d2.asn} is there)</div>
${oppsHtml(d1.asn, oppsFor1)}
</div>
</div>` : ''}
</div>` : '';
// 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 `<tr>
<td>${escHtml(checkName)}</td>
<td style="${st(isP1)}">${c1 ? icon(isP1) : '—'}</td>
<td style="${st(isP2)}">${c2 ? icon(isP2) : '—'}</td>
</tr>`;
}).join('');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>PeerCortex AS${d1.asn} vs AS${d2.asn} Comparison</title>
<style>${pdfBaseCSS()}</style>
</head>
<body>
<div class="page cover">
<div class="cover-stripe"></div>
<div class="cover-logo"> PeerCortex <span>Network Intelligence</span></div>
<div style="margin-top:40px">
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">ASN Comparison Report</div>
<div style="font-size:24pt;font-weight:700;color:#1e1b4b">AS${d1.asn} vs AS${d2.asn}</div>
<div style="font-size:13pt;color:#4f46e5;margin-top:4px">${escHtml(d1.name)} vs ${escHtml(d2.name)}</div>
</div>
<table style="margin-top:40px"><tr>${colHtml}</tr></table>
<div class="cover-meta" style="margin-top:auto;padding-top:16px">Generated by PeerCortex · ${ts} · peercortex.org</div>
</div>
<div class="page">
<h2>Head-to-Head Health Checks</h2>
<table>
<tr><th>Check</th><th>AS${d1.asn}</th><th>AS${d2.asn}</th></tr>
${checkRows}
</table>
</div>
${peeringPage}
<footer>
<span>PeerCortex · AS${d1.asn} vs AS${d2.asn}</span>
<span>Generated ${ts} · peercortex.org</span>
</footer>
</body>
</html>`;
}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
module.exports = { scoreRingSvg, pdfBaseCSS, buildPdfHtml, buildComparisonPdfHtml, formatLabel, escHtml };