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 = ` -
    -
    - -
    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

    - - - ${failedItems.map((c, i) => ` - - - - - - `).join('')} -
    #CheckImpactEffort
    ${i+1}${escHtml(c.check || '')}HighMedium
    ` : ''} -
    `; - - // ── 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)

    - - - - - -
    AttributeValue
    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)

    - - - ${pfxRows} -
    PrefixStatusMax LengthROA Origin
    ` : ''} -
    `; - - 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

    - - - ${upstreamRows} -
    ASNNamePower Score
    ` : '

    No upstream data available.

    '} -
    `; - - const 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 `${escHtml(v.prefix)} - ${v.status} - ${v.validating_roas ?? '-'} - ${v.max_length ?? '-'}`; - }).join(''); - - const allUpstreamRows = (rel.upstreams || []).map(u => - `AS${u.asn}${escHtml(u.name || '')}${u.power ?? '-'}` - ).join(''); - - const checkScoreRows = checks.map(c => - ` - ${escHtml(c.check || '')} - ${c.status?.toUpperCase()} - ${c.earned ?? '-'}${c.weight ?? '-'} - ${escHtml(c.detail || '')} - ` - ).join(''); - - const technicalHeaderPage = ` -
    -
    -
    PeerCortex · Technical Analysis Report
    -
    AS${asn} — ${escHtml(name)}
    -
    Generated ${ts} · peercortex.org · ${totalPfx} prefixes · RPKI ${rpkiCoverage}%
    -
    -
    -
    Health Score
    ${score}/100
    ${passedChecks} pass · ${failedChecks} fail
    -
    RPKI Coverage
    ${rpkiCoverage}%
    ${rpkiValid} valid · ${rpkiInvalid} invalid · ${rpkiNotFound} missing
    -
    Upstreams
    ${counts.upstreams || 0}
    transit providers
    -
    Peers / Downstreams
    ${counts.peers || 0} / ${counts.downstreams || 0}
    BGP relationships
    -
    ASPA
    -
    ${aspaDirect === true ? 'Deployed' : 'Not deployed'}
    -
    ${aspaProviders} providers registered
    -
    -
    Prefixes Analysed
    ${totalPfx}
    ${rpkiDetails.length} with RPKI data
    -
    -
    `; - - const rpkiFullPage = ` -
    -

    RPKI Compliance — Full Prefix Table

    -

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

    -

    ASPA Object

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

    All Prefixes

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

    No RPKI prefix data available.

    '} -
    `; - - const bgpFullPage = ` -
    -

    BGP Topology — Full Relationship Table

    -

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

    - ${allUpstreamRows ? ` -

    All Upstream Providers

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

    No upstream data available.

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

    Peer Sample (top 20)

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

    Check Score Detail

    -

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

    - - - ${checkScoreRows} -
    CheckStatusEarnedWeightDetail
    -
    - Scoring: Total = sum of earned points across all checks. Maximum possible: ${checks.reduce((s, c) => s + (c.weight || 0), 0)} points. - Current score: ${checks.reduce((s, c) => s + (c.earned || 0), 0)} / ${checks.reduce((s, c) => s + (c.weight || 0), 0)} = ${score}/100 -
    -
    `; - - // Report: add data provenance and ASPA deep-dive - const aspaPage = ` -
    -

    ASPA — Provider Authorization Analysis

    -

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

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

    What ASPA Protects Against

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

    Data Provenance

    -

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

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

    Report Metadata

    - - - - - - - - - - -
    FieldValue
    ASNAS${asn}
    Network Name${escHtml(name)}
    Report Format${formatLabel(format)}
    Generated${ts}
    PlatformPeerCortex · peercortex.org
    Prefixes Analysed${totalPfx}
    RPKI Objects Checked${rpkiDetails.length}
    Health Checks Run${checks.length}
    -
    - This report is generated automatically based on publicly available BGP and RPKI data. - Data may be delayed by cache TTLs. Not a substitute for real-time monitoring. - MIT License · github.com/renefichtmueller/PeerCortex -
    -
    `; - - // ── Compose pages by format ──────────────────────────────────────────────── - let bodyContent; - if (format === 'executive') { - // 3 pages: clean stakeholder summary — no deep technical data - bodyContent = coverPage + summaryPage + recsPage; - } else if (format === 'technical') { - // ~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 - ? ` - - ${commonIxps.map(x => ` - - - - - - `).join('')} -
    IXP NameCityAS${d1.asn} SpeedAS${d2.asn} Speed
    ${escHtml(x.name)}${escHtml(x.city)}${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}
    ` - : '

    No common IXPs found — these networks do not share any Internet Exchange Points.

    '; - - const commonFacHtml = commonFacs.length > 0 - ? ` - - ${commonFacs.map(f => ` - - - - - `).join('')} -
    Datacenter / Colocation FacilityCityCountry
    ${escHtml(f?.name||'')}${escHtml(f?.city||'')}${escHtml(f?.country||'')}
    ` - : '

    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 - - - -
    -
    - -
    -
    ASN Comparison Report
    -
    AS${d1.asn} vs AS${d2.asn}
    -
    ${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}
    -
    - ${colHtml}
    -
    Generated by PeerCortex · ${ts} · peercortex.org
    -
    -
    -

    Head-to-Head Health Checks

    - - - ${checkRows} -
    CheckAS${d1.asn}AS${d2.asn}
    -
    - ${peeringPage} - - -`; -} - -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 = ` +
    +
    + +
    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

    + + + ${failedItems.map((c, i) => ` + + + + + + `).join('')} +
    #CheckImpactEffort
    ${i+1}${escHtml(c.check || '')}HighMedium
    ` : ''} +
    `; + + // ── 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)

    + + + + + +
    AttributeValue
    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)

    + + + ${pfxRows} +
    PrefixStatusMax LengthROA Origin
    ` : ''} +
    `; + + 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

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

    No upstream data available.

    '} +
    `; + + const 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 `${escHtml(v.prefix)} + ${v.status} + ${v.validating_roas ?? '-'} + ${v.max_length ?? '-'}`; + }).join(''); + + const allUpstreamRows = (rel.upstreams || []).map(u => + `AS${u.asn}${escHtml(u.name || '')}${u.power ?? '-'}` + ).join(''); + + const checkScoreRows = checks.map(c => + ` + ${escHtml(c.check || '')} + ${c.status?.toUpperCase()} + ${c.earned ?? '-'}${c.weight ?? '-'} + ${escHtml(c.detail || '')} + ` + ).join(''); + + const technicalHeaderPage = ` +
    +
    +
    PeerCortex · Technical Analysis Report
    +
    AS${asn} — ${escHtml(name)}
    +
    Generated ${ts} · peercortex.org · ${totalPfx} prefixes · RPKI ${rpkiCoverage}%
    +
    +
    +
    Health Score
    ${score}/100
    ${passedChecks} pass · ${failedChecks} fail
    +
    RPKI Coverage
    ${rpkiCoverage}%
    ${rpkiValid} valid · ${rpkiInvalid} invalid · ${rpkiNotFound} missing
    +
    Upstreams
    ${counts.upstreams || 0}
    transit providers
    +
    Peers / Downstreams
    ${counts.peers || 0} / ${counts.downstreams || 0}
    BGP relationships
    +
    ASPA
    +
    ${aspaDirect === true ? 'Deployed' : 'Not deployed'}
    +
    ${aspaProviders} providers registered
    +
    +
    Prefixes Analysed
    ${totalPfx}
    ${rpkiDetails.length} with RPKI data
    +
    +
    `; + + const rpkiFullPage = ` +
    +

    RPKI Compliance — Full Prefix Table

    +

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

    +

    ASPA Object

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

    All Prefixes

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

    No RPKI prefix data available.

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

    BGP Topology — Full Relationship Table

    +

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

    + ${allUpstreamRows ? ` +

    All Upstream Providers

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

    No upstream data available.

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

    Peer Sample (top 20)

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

    Check Score Detail

    +

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

    + + + ${checkScoreRows} +
    CheckStatusEarnedWeightDetail
    +
    + Scoring: Total = sum of earned points across all checks. Maximum possible: ${checks.reduce((s, c) => s + (c.weight || 0), 0)} points. + Current score: ${checks.reduce((s, c) => s + (c.earned || 0), 0)} / ${checks.reduce((s, c) => s + (c.weight || 0), 0)} = ${score}/100 +
    +
    `; + + // Report: add data provenance and ASPA deep-dive + const aspaPage = ` +
    +

    ASPA — Provider Authorization Analysis

    +

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

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

    What ASPA Protects Against

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

    Data Provenance

    +

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

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

    Report Metadata

    + + + + + + + + + + +
    FieldValue
    ASNAS${asn}
    Network Name${escHtml(name)}
    Report Format${formatLabel(format)}
    Generated${ts}
    PlatformPeerCortex · peercortex.org
    Prefixes Analysed${totalPfx}
    RPKI Objects Checked${rpkiDetails.length}
    Health Checks Run${checks.length}
    +
    + This report is generated automatically based on publicly available BGP and RPKI data. + Data may be delayed by cache TTLs. Not a substitute for real-time monitoring. + MIT License · github.com/renefichtmueller/PeerCortex +
    +
    `; + + // ── Compose pages by format ──────────────────────────────────────────────── + let bodyContent; + if (format === 'executive') { + // 3 pages: clean stakeholder summary — no deep technical data + bodyContent = coverPage + summaryPage + recsPage; + } else if (format === 'technical') { + // ~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 + ? ` + + ${commonIxps.map(x => ` + + + + + + `).join('')} +
    IXP NameCityAS${d1.asn} SpeedAS${d2.asn} Speed
    ${escHtml(x.name)}${escHtml(x.city)}${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}
    ` + : '

    No common IXPs found — these networks do not share any Internet Exchange Points.

    '; + + const commonFacHtml = commonFacs.length > 0 + ? ` + + ${commonFacs.map(f => ` + + + + + `).join('')} +
    Datacenter / Colocation FacilityCityCountry
    ${escHtml(f?.name||'')}${escHtml(f?.city||'')}${escHtml(f?.country||'')}
    ` + : '

    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 + + + +
    +
    + +
    +
    ASN Comparison Report
    +
    AS${d1.asn} vs AS${d2.asn}
    +
    ${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}
    +
    + ${colHtml}
    +
    Generated by PeerCortex · ${ts} · peercortex.org
    +
    +
    +

    Head-to-Head Health Checks

    + + + ${checkRows} +
    CheckAS${d1.asn}AS${d2.asn}
    +
    + ${peeringPage} + + +`; +} + +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 };