PeerCortex/server.js
Rene Fichtmueller a5912a3d0c refactor: extract whois.js, topology.js, bgp-he-net.js, resolve-as-names.js
fetchBgpHeNet moved as-is, not removed, despite being confirmed dead code
in practice (its only call site in /api/lookup is hardcoded to
Promise.resolve(null)) -- that's a "disabled pending re-enable" state, a
bigger behavioral decision than this extraction pass should make alone.

Also widened the smoke-test harness's volatile-path exclusions: the live
Cloudflare RPKI feed's ASPA object count ticks between runs (independent
of any code change here), and the field appears under three different
names (aspa_map.entries, aspa_adoption.total_objects, plus the derived
delta_from_previous) that weren't all covered by the existing aspa_objects
exclusion. Re-captured baseline accordingly.

Verified via smoke-test harness (28/28 match). server.js: 4827 -> 4575 lines.
2026-07-17 00:04:51 +02:00

4576 lines
219 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const fs = require("fs");
const http = require("http");
const https = require("https");
const crypto = require("crypto");
// ── LOCAL DATABASE CLIENT (BGP, RPKI, Threat Intel) ──────────────
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');
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1";
// bio-rd gRPC client (optional — graceful fallback if unavailable)
let risClient = null;
try {
const { createRisClient } = require('./bio-rd-client');
const BIO_RD_HOST = process.env.BIO_RD_HOST || 'localhost';
const BIO_RD_PORT = parseInt(process.env.BIO_RD_PORT || '4321');
risClient = createRisClient(BIO_RD_HOST, BIO_RD_PORT);
console.log(`[bio-rd] RIS client configured → ${BIO_RD_HOST}:${BIO_RD_PORT}`);
} catch (e) {
console.log('[bio-rd] RIS client not available (bio-rd-client.js missing or gRPC not installed)');
}
const {
PEERINGDB_API_KEY,
PEERINGDB_API_URL,
getPdbLocal,
queryPeeringDBLocal,
pdbSemaphore,
fetchPeeringDB,
fetchPeeringDBWithRetry,
} = require("./server/services/peeringdb");
const FEEDBACK_TOKEN = process.env.FEEDBACK_TOKEN || "changeme-set-in-env";
const FEEDBACK_FILE = "/opt/peercortex-app/feedback.json";
// ── SMTP / Email ──────────────────────────────────────────────
const { sendFeedbackMail } = require('./src/backend/services/smtp');
// ── SMTP / Email ──────────────────────────────────────────────
const { loadVisitors, trackVisitor } = require("./server/visitors");
// ═══════════════════════════════════════════════════════════════
// PEERCORTEX v0.6.1 — New Features
// ═══════════════════════════════════════════════════════════════
// ── BGP Community Database ─────────────────────────────────────
// Migrated to src/features/bgp-communities/bgp-community-db.ts
// ── Hijack Monitoring ──────────────────────────────────────────
const {
DATA_DIR,
loadHijackSubs,
loadHijackAlerts,
loadWebhookSubs,
saveWebhookSubs,
saveHijackAlerts,
saveHijackSubs,
} = require("./server/hijack-monitoring/store");
const { webhookSignature, deliverWebhook, notifyWebhooks } = require("./server/hijack-monitoring/webhooks");
const { classifyHijackSeverity, checkHijacksForAsn, runHijackCheck } = require("./server/hijack-monitoring/monitor");
const {
aspaAdoptionDailyHistory,
recordAspaAdoptionSnapshot,
getAdoptionTrend,
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 { 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 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;');
}
/**
* 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 {
responseCache,
cacheGet,
cacheSet,
CACHE_TTL_LOOKUP,
CACHE_TTL_LOOKUP_DEGRADED,
CACHE_TTL_DEFAULT,
rdapCacheGet,
rdapCacheSet,
whoisCacheGet,
whoisCacheSet,
WHOIS_NOT_FOUND_CACHE_TTL,
quickIxCacheGet,
quickIxCacheSet,
bgproutesResultCache,
aspaResultCache,
validateResultCache,
resultCacheGet,
resultCacheSet,
} = require("./server/caches/response-caches");
// bgproutesVpCache/bgproutesVpCacheTs/BGPROUTES_VP_TTL removed 2026-07-16:
// confirmed dead code, never read anywhere in the file.
const { ensureManrsCache, checkManrsMembership } = require("./server/services/manrs");
// ============================================================
// subCableCache/globalFacCache removed 2026-07-16: confirmed dead code,
// never read anywhere in the file.
const { roaStore } = require("./server/caches/roa-store");
const {
rpkiAspaMap,
getRpkiAspaLastFetch,
fetchRpkiAspaFeed,
ensureAspaCache,
lookupAspaFromRpki,
validateRPKIWithCache,
fetchRipeRpkiValidator,
crossCheckRpki,
} = require("./server/services/rpki");
const { pdbSourceCache } = require("./server/caches/pdb-source-cache");
const {
ripeStatCache,
fetchRipeStatCached,
fetchRipeStatCachedFromApi,
fetchRipeStatCachedWithRetry,
saveRipeStatCacheToDisk,
loadRipeStatCacheFromDisk,
Semaphore,
} = require("./server/services/ripe-stat");
const {
fetchJSON,
fetchJSONWithRetry,
fetchHTML,
postJSON,
fetchBgproutesVisibility,
} = require("./server/services/http-helpers");
// checkRateLimit/rateLimitMap removed 2026-07-16: dead code, never called by
// the request handler (confirmed via the server.js structural exploration).
const { resolveASNames } = require("./server/resolve-as-names");
const {
hasAsSet,
hopCheck,
collapsePrepends,
verifyUpstream,
verifyDownstream,
detectValleys,
buildAspaStore,
calculateAspaReadinessScore,
} = require("./server/aspa-verification/engine");
const { fetchBgpHeNet } = require("./server/bgp-he-net");
const { fetchTopology } = require("./server/topology");
const { computeResilienceScore } = require("./server/resilience-score");
const { computeRouteLeakDetection } = require("./server/route-leak");
const { fetchWhois } = require("./server/whois");
// ============================================================
// Internal API Server Proxy
// ============================================================
// Several routes were extracted into src/api/ + src/features/*/routes.ts
// (Fastify, dist/api/index.js, run as a separate PM2 process on
// API_SERVER_PORT) but were never re-wired here after that migration —
// they just silently 404'd. This forwards those specific paths through
// rather than reimplementing them inline a second time.
const API_SERVER_PORT = parseInt(process.env.API_SERVER_PORT || "3102", 10);
function proxyToApiServer(req, res, targetUrl) {
const proxyReq = http.request(
{
hostname: "127.0.0.1",
port: API_SERVER_PORT,
path: targetUrl,
method: req.method,
headers: Object.assign({}, req.headers, { host: "127.0.0.1:" + API_SERVER_PORT }),
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
}
);
proxyReq.on("error", (err) => {
console.error("[API Proxy] Error forwarding " + targetUrl + ":", err.message);
if (!res.headersSent) {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "API server unavailable", detail: err.message }));
}
});
req.pipe(proxyReq);
}
// ============================================================
// HTTP Server
// ============================================================
const server = http.createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.writeHead(204);
return res.end();
}
let url, reqPath;
try {
url = new URL(req.url, "http://localhost");
reqPath = url.pathname;
} catch (_urlErr) {
res.writeHead(400);
return res.end('Bad Request');
}
// Serve static files — host-based routing
const host = (req.headers.host || '').split(':')[0];
if (reqPath === "/" || reqPath === "/index.html") {
// shell.peercortex.org → admin feedback terminal (check first)
if (host === 'shell.peercortex.org') {
try {
const html = fs.readFileSync('/opt/peercortex-app/public/shell.html', 'utf8');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
return res.end(html);
} catch (_e) {
res.writeHead(500);
return res.end('shell.html not found');
}
}
// v2.peercortex.org → redirect to main domain
if (host === 'v2.peercortex.org') {
res.writeHead(301, { Location: 'https://peercortex.org' + reqPath });
return res.end();
}
const htmlFile = "index.html";
try {
const html = fs.readFileSync("/opt/peercortex-app/public/" + htmlFile, "utf8");
res.setHeader("Content-Type", "text/html; charset=utf-8");
return res.end(html);
} catch (_e) {
res.writeHead(500);
return res.end(htmlFile + " not found");
}
}
// Direct access to editorial version
if (reqPath === "/index-editorial.html") {
try {
const html = fs.readFileSync("/opt/peercortex-app/public/index-editorial.html", "utf8");
res.setHeader("Content-Type", "text/html; charset=utf-8");
return res.end(html);
} catch (_e) {
res.writeHead(500);
return res.end("index-editorial.html not found");
}
}
// ============================================================
// Feedback API
// ============================================================
// ── Name Search (RIPE Stat + PeeringDB combined) ─────────────
if (reqPath === '/api/search') {
const params = new URL(req.url, 'http://localhost').searchParams;
const q = (params.get('q') || '').trim();
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=120');
if (!q || q.length < 2) { res.writeHead(400); return res.end(JSON.stringify({error:'query too short'})); }
try {
const results = [];
const seen = new Set();
// Source 1: RIPE Stat searchcomplete (fast, covers ASNs + org names)
try {
const ripeUrl = 'https://stat.ripe.net/data/searchcomplete/data.json?resource=' + encodeURIComponent(q);
const ripeData = await fetchJSONWithRetry(ripeUrl, { timeout: 6000 });
const cats = ripeData && ripeData.data && ripeData.data.categories || [];
for (var ci = 0; ci < cats.length; ci++) {
var suggs = cats[ci].suggestions || [];
for (var si = 0; si < suggs.length; si++) {
var s = suggs[si];
var val = (s.value || '').toString();
// Only ASN results
if (/^AS\d+$/i.test(val) && !seen.has(val)) {
seen.add(val);
// Use description (e.g. "FLEXOPTIX, DE") as the display label
var ripeName = s.description || s.label || val;
results.push({ asn: val.replace(/^AS/i,''), label: ripeName, description: '', source: 'RIPE Stat' });
}
}
}
} catch(e) { /* RIPE Stat failed, continue */ }
// Source 2: PeeringDB name search (best for network operator names)
try {
var pdbUrl = 'https://www.peeringdb.com/api/net?name__icontains=' + encodeURIComponent(q) + '&depth=1&limit=10';
if (PEERINGDB_API_KEY) pdbUrl += '&key=' + PEERINGDB_API_KEY;
const pdbData = await fetchJSONWithRetry(pdbUrl, { timeout: 8000 });
var nets = pdbData && pdbData.data || [];
for (var ni = 0; ni < nets.length; ni++) {
var net = nets[ni];
var asnKey = 'AS' + net.asn;
if (net.asn && !seen.has(asnKey)) {
seen.add(asnKey);
var pdbDesc = [net.info_type, net.country].filter(Boolean).join(' · ');
results.push({
asn: String(net.asn),
label: net.name || asnKey,
description: pdbDesc,
source: 'PeeringDB'
});
}
}
} catch(e) { /* PeeringDB failed, continue */ }
// Sort: RIPE results first (usually more relevant for ASN lookup), then PeeringDB
results.sort((a, b) => {
if (a.source === b.source) return 0;
return a.source === 'RIPE Stat' ? -1 : 1;
});
res.writeHead(200);
return res.end(JSON.stringify({ q: q, results: results.slice(0, 12) }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error: e.message}));
}
}
// GET /api/visitors — unique visitor count
if (reqPath === "/api/visitors" && req.method === "GET") {
res.setHeader("Content-Type", "application/json");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "no-store");
const count = trackVisitor(req);
res.writeHead(200);
return res.end(JSON.stringify({ visitors: count }));
}
// OPTIONS preflight (CORS)
if (reqPath === '/api/feedback' && req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.writeHead(204);
return res.end();
}
// POST /api/feedback — submit feedback entry
if (reqPath === '/api/feedback' && req.method === 'POST') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
let body = '';
req.on('data', function(chunk) { body += chunk; });
req.on('end', function() {
try {
const data = JSON.parse(body);
if (!data.message || String(data.message).trim().length < 3) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'Message too short' }));
}
const entry = {
id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
timestamp: new Date().toISOString(),
category: String(data.category || 'General').slice(0, 50),
message: String(data.message || '').slice(0, 2000),
name: String(data.name || 'Anonymous').slice(0, 100),
asn: data.asn ? String(data.asn).slice(0, 20) : null,
ip: req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.socket.remoteAddress || null,
ua: String(req.headers['user-agent'] || '').slice(0, 200)
};
let entries = [];
try { entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8')); } catch (_e) { /* no file yet */ }
entries.push(entry);
fs.writeFileSync(FEEDBACK_FILE, JSON.stringify(entries, null, 2));
// Send email async — don't block response
sendFeedbackMail(entry).catch(e => console.error('[MAIL] Failed:', e.message));
return res.end(JSON.stringify({ ok: true, id: entry.id }));
} catch (_e) {
res.writeHead(500);
return res.end(JSON.stringify({ ok: false, error: 'Server error' }));
}
});
return;
}
// GET /api/feedback?token=... — admin: fetch all entries as JSON
if (reqPath === '/api/feedback' && req.method === 'GET') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
const token = url.searchParams.get('token');
if (!token || token !== FEEDBACK_TOKEN) {
res.writeHead(401);
return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
}
try {
const entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8'));
return res.end(JSON.stringify({ ok: true, entries: entries, count: entries.length }));
} catch (_e) {
return res.end(JSON.stringify({ ok: true, entries: [], count: 0 }));
}
}
// Serve favicon
if (reqPath === "/favicon.ico") {
res.writeHead(204);
return res.end();
}
// Lia's Atlas Paradise - Easter egg page
if (reqPath === "/lia" || reqPath === "/lia/") {
try {
const liaHtml = fs.readFileSync(__dirname + "/public/lia.html", "utf8");
res.setHeader("Content-Type", "text/html; charset=utf-8");
return res.end(liaHtml);
} catch (_e) {
res.writeHead(500);
return res.end("lia.html not found");
}
}
// ============================================================
// Lia's Atlas Paradise: Atlas probe coverage endpoint
// ============================================================
if (reqPath === "/api/atlas/coverage") {
res.setHeader("Content-Type", "application/json");
if (!atlasState.cache) {
res.writeHead(503);
return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
}
return res.end(JSON.stringify(atlasState.cache, null, 2));
}
// ============================================================
// Lia's Paradise: File parsing endpoint (for binary uploads)
// ============================================================
if (reqPath === "/api/lia/parse-file" && req.method === "POST") {
res.setHeader("Content-Type", "application/json");
let body = "";
req.on("data", function(chunk) { body += chunk; });
req.on("end", function() {
try {
var parsed = JSON.parse(body);
var filename = parsed.filename || "";
var ext = filename.split(".").pop().toLowerCase();
// For text-based formats, decode base64 and extract text
if (ext === "csv" || ext === "txt") {
var text = Buffer.from(parsed.data, "base64").toString("utf8");
return res.end(JSON.stringify({ text: text }));
}
// For binary formats (PDF, XLS, DOC), we can't parse server-side without
// heavy dependencies. Return helpful error.
return res.end(JSON.stringify({
error: "Binary file parsing (" + ext.toUpperCase() + ") requires client-side extraction. Please use CSV or TXT format, or copy-paste the content.",
suggestion: "Export your spreadsheet as CSV first, then upload the CSV file."
}));
} catch(e) {
return res.end(JSON.stringify({ error: "Parse error: " + e.message }));
}
});
return;
}
// ============================================================
// Lia's Paradise: Combined PeeringDB + Atlas coverage data
// ============================================================
if (reqPath === "/api/lia/coverage") {
res.setHeader("Content-Type", "application/json");
if (!atlasState.cache) {
res.writeHead(503);
return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
}
// Cache this expensive response for 30 min
var liaCacheKey = "lia_coverage";
var liaCached = cacheGet(liaCacheKey);
if (liaCached) return res.end(liaCached);
// Fetch PeeringDB network list (all networks with status "ok")
// Use pre-cached org→country map (loaded at startup, 16MB response cached in memory)
fetchPeeringDB("/net?status=ok&depth=0").then(function(pdbData) {
if (!pdbData || !pdbData.data) {
return res.end(JSON.stringify({ error: "Could not fetch PeeringDB networks" }));
}
var probeAsns = new Set(atlasState.cache.asns_with_probes || []);
var enriched = pdbData.data.map(function(n) {
var org = pdbOrgCountryMap.get(n.org_id) || {};
var cc = org.country || "";
return {
asn: n.asn,
name: n.name || "",
org_name: org.name || "",
country: cc,
country_name: cc,
info_type: n.info_type || "",
has_probe: probeAsns.has(n.asn),
};
}).filter(function(n) { return n.asn > 0 && n.country; });
var result = JSON.stringify({
networks: enriched,
total: enriched.length,
with_probes: enriched.filter(function(n) { return n.has_probe; }).length,
without_probes: enriched.filter(function(n) { return !n.has_probe; }).length,
atlas_unique_asns: probeAsns.size,
org_countries_loaded: pdbOrgCountryMap.size,
fetched_at: new Date().toISOString(),
});
cacheSet(liaCacheKey, result, 30 * 60 * 1000);
res.end(result);
}).catch(function(e) {
res.end(JSON.stringify({ error: "PeeringDB fetch failed: " + e.message }));
});
return;
}
res.setHeader("Content-Type", "application/json");
// Health endpoint — extended with cache status, ASPA metrics, and local DB stats
if (reqPath === "/api/health") {
const mem = process.memoryUsage();
const rpkiAspaLastFetch = getRpkiAspaLastFetch();
const aspaAge = rpkiAspaLastFetch ? Math.floor((Date.now() - rpkiAspaLastFetch) / 60000) : -1;
const pdbTotal = pdbSourceCache.hits + pdbSourceCache.misses;
// Query local DB stats (async, but return partial if needed)
localDb.getLocalDbStats().then(function(dbStats) {
// Determine health status based on local DB data availability
const hasLocalBgp = dbStats && dbStats.bgp_routes > 100000; // should have >2M rows normally
const hasLocalRpki = dbStats && dbStats.rpki_roas > 100000; // should have >500k rows normally
const status = (hasLocalBgp && hasLocalRpki && aspaAge < 300) ? "ok" : "degraded";
const healthResponse = {
status,
service: "PeerCortex",
version: "0.6.9",
timestamp: new Date().toISOString(),
uptime_seconds: Math.floor(process.uptime()),
memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
bgproutes_configured: !!BGPROUTES_API_KEY,
caches: {
aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
pdb_netfac: { entries: pdbSourceCache.netfac.size },
ripe_stat: { entries: ripeStatCache.size },
response_cache: { entries: responseCache.size },
},
local_db: dbStats ? {
bgp_routes: dbStats.bgp_routes,
rpki_roas: dbStats.rpki_roas,
threat_intel: dbStats.threat_intel,
rdap_cache_entries: dbStats.rdap_cache_entries,
source: "PostgreSQL (local)",
healthy: hasLocalBgp && hasLocalRpki,
} : null,
aspa_adoption: {
total_objects: rpkiAspaMap.size,
roa_count: roaStore.count,
history_samples: aspaAdoptionDailyHistory.length,
delta_last: aspaAdoptionDailyHistory.length >= 2
? aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1].aspa_objects - aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2].aspa_objects
: 0,
},
};
return res.end(JSON.stringify(healthResponse, null, 2));
}).catch(function(e) {
console.error('[/api/health] Local DB stats error:', e.message);
// Return health without local DB stats on error
return res.end(
JSON.stringify({
status,
service: "PeerCortex",
version: "0.6.9",
timestamp: new Date().toISOString(),
uptime_seconds: Math.floor(process.uptime()),
memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
bgproutes_configured: !!BGPROUTES_API_KEY,
caches: {
roa_store: { entries: roaStore.count, age_minutes: roaAge, ready: roaStore.ready },
aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
pdb_netfac: { entries: pdbSourceCache.netfac.size },
ripe_stat: { entries: ripeStatCache.size },
response_cache: { entries: responseCache.size },
},
local_db: { error: "Could not fetch local DB stats", message: e.message },
aspa_adoption: {
total_objects: rpkiAspaMap.size,
roa_count: roaStore.count,
history_samples: aspaAdoptionDailyHistory.length,
delta_last: aspaAdoptionDailyHistory.length >= 2
? aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1].aspa_objects - aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2].aspa_objects
: 0,
},
}, null, 2)
);
});
return;
}
// ============================================================
// ASPA Deep Verification endpoint: /api/aspa/verify?asn=X
// ============================================================
if (reqPath === "/api/aspa/verify") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
if (!rawAsn) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
}
const cachedVerify = resultCacheGet(aspaResultCache, "verify:" + rawAsn);
if (cachedVerify !== undefined) {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(cachedVerify));
}
const targetAsn = parseInt(rawAsn);
const start = Date.now();
try {
// Fetch neighbour and prefix data first
const [neighbourData, prefixData] = await Promise.all([
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
]);
// Use looking-glass with actual prefixes to get BGP paths
const announcedPrefixes = prefixData?.data?.prefixes || [];
const samplePrefixes = announcedPrefixes.slice(0, 3).map((p) => p.prefix); // reduced 5→3
// Fetch looking-glass data for up to 3 prefixes in parallel (3s timeout each)
const lgResults = await Promise.all(
samplePrefixes.map((pfx) =>
fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=" + encodeURIComponent(pfx), { timeout: 3000 }).catch(() => null)
)
);
// Extract AS paths from looking glass results
const allPaths = [];
const pathNeighbourCount = new Map(); // Count how often each AS appears next to target in paths
lgResults.forEach((lgData) => {
const rrcs = lgData?.data?.rrcs || [];
rrcs.forEach((rrc) => {
const peers = rrc.peers || [];
peers.forEach((peer) => {
const rawPath = peer.as_path || "";
const pathArr = rawPath.split(" ").map(Number).filter(Boolean);
if (pathArr.length > 1) {
allPaths.push({
rrc: rrc.rrc,
path: pathArr,
rawPath: rawPath,
prefix: peer.prefix || "",
hasAsSet: hasAsSet(rawPath),
});
const idx = pathArr.indexOf(targetAsn);
if (idx > 0) {
const neighbour = pathArr[idx - 1];
pathNeighbourCount.set(neighbour, (pathNeighbourCount.get(neighbour) || 0) + 1);
}
}
});
});
});
// Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
// AS-path analysis is used for frequency/confirmation, NOT as standalone provider source
const neighbours = neighbourData?.data?.neighbours || [];
const leftNeighbours = neighbours.filter((n) => n.type === "left");
const upstreamSet = new Set();
leftNeighbours.forEach((n) => upstreamSet.add(n.asn));
// Classify left neighbours: high-power = likely upstream, low-power = likely peer
const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1);
const detectedProviders = [...upstreamSet].map((asn) => {
const nb = leftNeighbours.find((n) => n.asn === asn);
const power = nb ? (nb.power || 0) : 0;
const powerPct = Math.round((power / maxPower) * 100);
const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer";
return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification };
});
await resolveASNames(detectedProviders);
// Count how often each provider appears in paths
const providerFrequency = new Map();
allPaths.forEach((p) => {
const idx = p.path.indexOf(targetAsn);
if (idx > 0) {
const prov = p.path[idx - 1];
providerFrequency.set(prov, (providerFrequency.get(prov) || 0) + 1);
}
});
// Check Cloudflare RPKI feed for ASPA object
await ensureAspaCache();
const aspaLookup = lookupAspaFromRpki(targetAsn);
const aspaObjectExists = aspaLookup.exists;
const aspaDeclaredProviders = aspaLookup.providers;
// Build ASPA store from RPKI feed data (real ASPA objects only). If the
// target has no real ASPA object, its hops must show "NoAttestation" --
// NOT a fabricated declaration built from heuristically-detected BGP
// neighbours, which would contradict the aspa_object_exists:false this
// same response reports and falsely present unattested hops as
// "ProviderPlus"/Valid. hopCheck() already returns "NoAttestation" for
// any ASN with no entry in aspaStore, so simply not setting one here is
// correct and sufficient -- no fallback needed.
const aspaStore = new Map();
if (aspaObjectExists) {
aspaStore.set(targetAsn, new Set(aspaDeclaredProviders));
}
// Also populate store with all known ASPA objects from the RPKI feed
// for providers that have their own ASPA objects (enables full path verification)
for (const [cas, provSet] of rpkiAspaMap) {
if (!aspaStore.has(cas)) {
aspaStore.set(cas, provSet);
}
}
// Deliberately NOT adding empty Set() entries for detected-but-unattested
// providers here: aspaStore.get() returning undefined for them makes
// hopCheck() correctly report "NoAttestation". An empty (but present) Set
// would make providers.has(x) return false, misreporting "NotProviderPlus"
// (implying a real ASPA object that denies this hop) instead of "we simply
// don't know" -- see the 2026-07-16 audit for how this previously caused
// clean, unattested paths to be misclassified as route leaks.
// Sample paths for verification (up to 50)
const samplePaths = allPaths.slice(0, 50);
const pathResults = samplePaths.map((p) => {
const upstream = verifyUpstream(p.path, aspaStore, p.rawPath);
const downstream = verifyDownstream(p.path, aspaStore, p.rawPath);
const valleys = detectValleys(p.path, aspaStore);
return {
rrc: p.rrc,
prefix: p.prefix,
path: p.path.map((a) => "AS" + a).join(" "),
collapsed_path: collapsePrepends(p.path).map((a) => "AS" + a).join(" "),
has_as_set: p.hasAsSet,
upstream_verification: upstream,
downstream_verification: downstream,
valleys: valleys,
overall: p.hasAsSet
? "Invalid"
: upstream.result === "Valid" && downstream.result === "Valid"
? "Valid"
: upstream.result === "Invalid" || downstream.result === "Invalid"
? "Invalid"
: "Unknown",
};
});
// Calculate statistics
const validPaths = pathResults.filter((p) => p.overall === "Valid").length;
const invalidPaths = pathResults.filter((p) => p.overall === "Invalid").length;
const unknownPaths = pathResults.filter((p) => p.overall === "Unknown").length;
const asSetPaths = pathResults.filter((p) => p.has_as_set).length;
const valleyPaths = pathResults.filter((p) => p.valleys.length > 0).length;
// For readiness scoring: Valid = full credit, Unknown = partial (no ASPA data is normal),
// only Invalid actually indicates problems
const pathNotInvalidPct = pathResults.length > 0
? Math.round(((validPaths + unknownPaths) / pathResults.length) * 100)
: 0;
const pathValidPct = pathResults.length > 0 ? Math.round((validPaths / pathResults.length) * 100) : 0;
// Provider audit: compare detected vs declared
const detectedSet = new Set(detectedProviders.map((p) => p.asn));
const declaredSet = new Set(aspaDeclaredProviders);
const missingFromAspa = detectedProviders
.filter((p) => !declaredSet.has(p.asn))
.map((p) => ({
asn: p.asn,
name: p.name,
frequency: providerFrequency.get(p.asn) || 0,
frequency_pct: allPaths.length > 0
? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100)
: 0,
}))
.sort((a, b) => b.frequency - a.frequency);
const extraInAspa = aspaDeclaredProviders
.filter((asn) => !detectedSet.has(asn))
.map((asn) => ({
asn,
name: "",
seen_in_paths: false,
}));
const providerCompleteness = detectedProviders.length > 0
? Math.round(
(detectedProviders.filter((p) => declaredSet.has(p.asn)).length /
detectedProviders.length) *
100
)
: aspaObjectExists ? 100 : 0;
// Get RPKI coverage for readiness score
// Validate ALL prefixes using local RPKI data (Cloudflare feed - all 5 RIRs)
await ensureAspaCache();
const rpkiBatch = announcedPrefixes.map((p) => p.prefix);
const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx)));
// Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
// must not lower a network's displayed ASPA readiness score.
const rpkiChecked = rpkiResults.filter((r) => r.status !== "unavailable");
const rpkiValid = rpkiChecked.filter((r) => r.status === "valid").length;
const rpkiCoverage = rpkiChecked.length > 0 ? Math.round((rpkiValid / rpkiChecked.length) * 100) : 0;
// Calculate readiness score
const readinessScore = calculateAspaReadinessScore({
rpkiCoverage,
aspaObjectExists,
providerCompleteness,
pathValidationPct: pathNotInvalidPct,
});
const duration = Date.now() - start;
const verifyResult = {
meta: {
query: "AS" + rawAsn,
duration_ms: duration,
timestamp: new Date().toISOString(),
paths_analyzed: pathResults.length,
total_paths_seen: allPaths.length,
},
asn: targetAsn,
readiness_score: readinessScore,
aspa_object_exists: aspaObjectExists,
aspa_feed_healthy: aspaLookup.feedLoaded,
detected_providers: detectedProviders.map((p) => ({
...p,
frequency: providerFrequency.get(p.asn) || 0,
frequency_pct: allPaths.length > 0
? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100)
: 0,
})),
provider_audit: {
declared_count: aspaDeclaredProviders.length,
detected_count: detectedProviders.length,
completeness_pct: providerCompleteness,
missing_from_aspa: missingFromAspa,
extra_in_aspa: extraInAspa,
},
path_verification: {
total: pathResults.length,
valid: validPaths,
invalid: invalidPaths,
unknown: unknownPaths,
as_set_flagged: asSetPaths,
valley_detected: valleyPaths,
valid_pct: pathValidPct,
not_invalid_pct: pathNotInvalidPct,
results: pathResults,
},
rpki_coverage: rpkiCoverage,
};
resultCacheSet(aspaResultCache, "verify:" + rawAsn, verifyResult);
return res.end(JSON.stringify(verifyResult, null, 2));
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "ASPA verification failed", message: err.message }));
}
}
// ============================================================
// ASPA Check endpoint: /api/aspa?asn=X (existing, kept for compat)
// ============================================================
if (reqPath === "/api/aspa") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
if (!rawAsn) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
}
const cachedAspa = resultCacheGet(aspaResultCache, rawAsn);
if (cachedAspa !== undefined) {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(cachedAspa));
}
const start = Date.now();
let _aspaDone = false;
const _aspaTimer = setTimeout(() => {
if (!_aspaDone) {
_aspaDone = true;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) }));
}
}, 12000);
try {
const [lgData, neighbourData] = await Promise.all([
fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 3000 }).catch(() => null),
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 4000 }),
]);
const rrcs = lgData?.data?.rrcs || [];
const asPaths = [];
rrcs.forEach((rrc) => {
const peers = rrc.peers || [];
peers.forEach((peer) => {
const path = peer.as_path || "";
const pathArr = path.split(" ").map(Number).filter(Boolean);
if (pathArr.length > 1) {
asPaths.push({ rrc: rrc.rrc, path: pathArr, prefix: peer.prefix || "" });
}
});
});
// Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
const neighbours = neighbourData?.data?.neighbours || [];
const leftNeighbours = neighbours.filter((n) => n.type === "left");
const upstreamSet = new Set();
leftNeighbours.forEach((n) => upstreamSet.add(n.asn));
// Classify left neighbours: high-power = likely upstream, low-power = likely peer
const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1);
const detectedProviders = [...upstreamSet].map((asn) => {
const nb = leftNeighbours.find((n) => n.asn === asn);
const power = nb ? (nb.power || 0) : 0;
const powerPct = Math.round((power / maxPower) * 100);
const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer";
return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification };
});
await resolveASNames(detectedProviders);
// Check Cloudflare RPKI feed for ASPA object
await ensureAspaCache();
const aspaLookup = lookupAspaFromRpki(rawAsn);
const aspaObjectExists = aspaLookup.exists;
const aspaDeclaredProviders = aspaLookup.providers;
const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", ");
let recommendedAspa =
"aut-num: AS" + rawAsn + "\n" +
"# Recommended ASPA object:\n" +
"# customer: AS" + rawAsn + "\n" +
"# provider-set: " + providerList + "\n" +
"# AFI: ipv4, ipv6\n" +
"#\n" +
"# Detected providers from BGP path analysis:\n" +
detectedProviders.map((p) => "# AS" + p.asn + (p.name ? " (" + p.name + ")" : "")).join("\n");
// If ASPA object exists, show RPKI-declared providers
if (aspaObjectExists && aspaDeclaredProviders.length > 0) {
recommendedAspa += "\n#\n# RPKI-declared providers (from Cloudflare RPKI feed):\n" +
aspaDeclaredProviders.map((a) => "# AS" + a).join("\n");
}
const samplePaths = asPaths.slice(0, 10).map((p) => {
const pathStr = p.path.map((a) => "AS" + a).join(" -> ");
const idx = p.path.indexOf(parseInt(rawAsn));
const provider = idx > 0 ? p.path[idx - 1] : null;
return {
rrc: p.rrc,
prefix: p.prefix,
path: pathStr,
detected_provider: provider ? "AS" + provider : null,
provider_in_set: provider ? upstreamSet.has(provider) : false,
};
});
if (_aspaDone) return; // hard timeout already responded
_aspaDone = true;
clearTimeout(_aspaTimer);
const duration = Date.now() - start;
const aspaResult = {
meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() },
asn: parseInt(rawAsn),
detected_providers: detectedProviders,
provider_count: detectedProviders.length,
aspa_object_exists: aspaObjectExists,
aspa_feed_healthy: aspaLookup.feedLoaded,
aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })),
aspa_declared_count: aspaDeclaredProviders.length,
recommended_aspa: recommendedAspa,
path_analysis: {
total_paths_seen: asPaths.length,
sample_paths: samplePaths,
},
};
resultCacheSet(aspaResultCache, rawAsn, aspaResult);
return res.end(JSON.stringify(aspaResult, null, 2));
} catch (err) {
if (!_aspaDone) {
_aspaDone = true;
clearTimeout(_aspaTimer);
res.writeHead(500);
return res.end(JSON.stringify({ error: "ASPA check failed", message: err.message }));
}
}
}
// ============================================================
// BGP endpoint (LOCAL DB): /api/bgp?asn=X (or prefix=X)
// Queries local PostgreSQL bgp_routes table — zero external API calls
// ============================================================
if (reqPath === "/api/bgp") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
const prefix = url.searchParams.get("prefix") || "";
if (!rawAsn && !prefix) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Need asn or prefix parameter" }));
}
const cacheKey = rawAsn || prefix;
const cached = resultCacheGet(bgproutesResultCache, cacheKey);
if (cached !== undefined) {
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
return res.end(JSON.stringify(cached));
}
const start = Date.now();
try {
const result = {
meta: { timestamp: new Date().toISOString(), source: "local_bgp_db" },
bgp_status: null,
threat_intel: null,
};
// ---- BGP Status (local DB lookup) ----
if (prefix) {
// Prefix lookup: Get BGP status for this prefix
const bgpStatus = await localDb.getBgpStatus(prefix);
if (bgpStatus) {
result.bgp_status = {
prefix,
announced: bgpStatus.announced,
origin_asns: bgpStatus.origin_asns,
visibility_percent: bgpStatus.visibility_percent,
last_seen: bgpStatus.last_seen,
source: "local_bgp",
};
// Check for hijack (multiple origin ASNs). null means the check itself
// failed (DB error) -- distinct from a genuine single/no-origin result.
const hijackAsns = await localDb.checkBgpHijack(prefix);
if (hijackAsns === null) {
result.bgp_status.hijack_check_unavailable = true;
} else if (hijackAsns.length > 1) {
result.bgp_status.hijack_warning = {
detected: true,
origin_asns: hijackAsns,
message: `Multiple origin ASNs detected for ${prefix}`,
};
}
}
} else if (rawAsn) {
// ASN lookup: Get all announced prefixes for this ASN
const prefixes = await localDb.getAnnouncedPrefixes(rawAsn);
if (prefixes && prefixes.length > 0) {
result.bgp_status = {
asn: rawAsn,
announced_count: prefixes.length,
prefixes: prefixes.slice(0, 50).map((p) => ({
prefix: p.prefix,
origin_asn: p.origin_asn,
visibility_percent: p.visibility_percent,
last_seen: p.last_seen,
})),
source: "local_bgp",
};
} else {
result.bgp_status = {
asn: rawAsn,
announced: false,
announced_count: 0,
message: "No prefixes found for this ASN in local BGP table",
source: "local_bgp",
};
}
}
// ---- Threat Intelligence (local cache lookup) ----
// If we have an IP context, look up threat intel
if (prefix && prefix.includes(".")) {
// Extract IP from prefix (e.g., "1.1.1.0/24" → "1.1.1.0")
const ipAddr = prefix.split("/")[0];
const threat = await localDb.getThreatIntel(ipAddr);
if (threat) {
result.threat_intel = {
ip_address: threat.ip_address,
threat_level: threat.threat_level,
confidence_score: threat.confidence_score,
source: threat.source,
cached_at: threat.cached_at,
};
}
}
result.meta.duration_ms = Date.now() - start;
resultCacheSet(bgproutesResultCache, cacheKey, result);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(result, null, 2));
} catch (err) {
console.error("[/api/bgp] Error:", err.message);
res.writeHead(500);
return res.end(JSON.stringify({ error: "BGP query failed", message: err.message }));
}
}
// ============================================================
// Unified Validation endpoint: /api/validate?asn=X
// Runs ALL validations in parallel, returns comprehensive report
// ============================================================
if (reqPath === "/api/validate") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
if (!rawAsn) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
}
const cachedValidate = resultCacheGet(validateResultCache, rawAsn);
if (cachedValidate !== undefined) {
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
return res.end(JSON.stringify(cachedValidate));
}
const start = Date.now();
const targetAsn = parseInt(rawAsn);
try {
// Phase 1: Fetch core data — 5s cap prevents large ASNs from blocking Phase 2
const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
fetchPeeringDB("/net?asn=" + rawAsn),
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
]);
const allPrefixes = (prefixData && prefixData.data && prefixData.data.prefixes ? prefixData.data.prefixes : []).map(function(p) { return p.prefix; });
// Use all prefixes for RPKI validation (local lookup is fast, no API calls)
const samplePrefixes = allPrefixes;
const net = pdbNet && pdbNet.data && pdbNet.data[0] ? pdbNet.data[0] : {};
const netId = net.id;
const neighbours = neighbourData && neighbourData.data && neighbourData.data.neighbours ? neighbourData.data.neighbours : [];
// ---- 11. Bogon Detection (local check) ----
function checkBogonPrefix(prefix) {
var bogonV4 = [
{ net: "0.0.0.0", mask: 8 }, { net: "10.0.0.0", mask: 8 },
{ net: "100.64.0.0", mask: 10 }, { net: "127.0.0.0", mask: 8 },
{ net: "169.254.0.0", mask: 16 }, { net: "172.16.0.0", mask: 12 },
{ net: "192.0.2.0", mask: 24 }, { net: "192.168.0.0", mask: 16 },
{ net: "198.51.100.0", mask: 24 }, { net: "203.0.113.0", mask: 24 },
{ net: "240.0.0.0", mask: 4 },
];
if (prefix.includes(":")) return { prefix: prefix, is_bogon: false, reason: "IPv6 bogon check skipped" };
var split = prefix.split("/");
var addr = split[0];
var mask = parseInt(split[1] || "0");
var parts = addr.split(".").map(Number);
var ip = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
for (var bi = 0; bi < bogonV4.length; bi++) {
var b = bogonV4[bi];
var bParts = b.net.split(".").map(Number);
var bIp = ((bParts[0] << 24) | (bParts[1] << 16) | (bParts[2] << 8) | bParts[3]) >>> 0;
var bMask = (~((1 << (32 - b.mask)) - 1)) >>> 0;
if ((ip & bMask) === (bIp & bMask) && mask >= b.mask) {
return { prefix: prefix, is_bogon: true, reason: "Matches bogon " + b.net + "/" + b.mask };
}
}
return { prefix: prefix, is_bogon: false };
}
function checkBogonAsn(asnNum) {
if (asnNum === 0 || asnNum === 23456 || asnNum === 65535) return true;
if (asnNum >= 64496 && asnNum <= 64511) return true;
if (asnNum >= 64512 && asnNum <= 65534) return true;
return false;
}
var bogonPrefixResults = allPrefixes.map(checkBogonPrefix);
var bogonPrefixes = bogonPrefixResults.filter(function(r) { return r.is_bogon; });
var asnInPaths = neighbours.map(function(n) { return n.asn; });
var bogonAsns = asnInPaths.filter(checkBogonAsn);
// "pass" must mean prefixes/neighbours were actually fetched and checked clean --
// not that the upstream source failed and left nothing to check. A source is
// unavailable if it's falsy (fetchRipeStatCached network failure) OR if it's a
// truthy object with status:"error" (local-db-client.js's DB-error signal --
// see the 2026-07-16 audit; that object is NOT null, so a plain `!x` check
// alone misses this case and would show "pass" on 0 checked prefixes).
function isSourceUnavailable(x) { return !x || x.status === "error"; }
var prefixesUnavailable = isSourceUnavailable(prefixData);
var neighboursUnavailable = isSourceUnavailable(neighbourData);
var bogonDataUnavailable = prefixesUnavailable && neighboursUnavailable;
var bogonResult = {
status: bogonDataUnavailable ? "info" : (bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail"),
bogon_prefixes: bogonPrefixes,
bogon_asns_in_paths: bogonAsns,
total_prefixes_checked: allPrefixes.length,
prefixes_source_unavailable: prefixesUnavailable,
neighbours_source_unavailable: neighboursUnavailable,
};
// Phase 2: All API-dependent validations in parallel
var validationPromises = {};
// 12. IRR Validation
// fetchJSON resolves(null) on timeout/network-error/parse-failure rather than
// rejecting, so the .catch() below never sees the common failure mode -- a
// dead upstream would otherwise silently read as "0 entries, no mismatches, pass".
validationPromises.irr = fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) {
if (irrData === null) {
return { status: "info", message: "IRR Explorer unavailable (fetch failed)", total_entries: 0, mismatches: [], mismatch_count: 0 };
}
var entries = Array.isArray(irrData) ? irrData : [];
var mismatches = entries.filter(function(e) {
if (!e.bgpOrigins && !e.bgp_origins) return false;
if (!e.irrRoutes && !e.irr_origins) return false;
var bgpArr = e.bgpOrigins || e.bgp_origins || [];
var irrArr = e.irrRoutes || e.irr_origins || [];
var bgpSet = {};
bgpArr.forEach(function(a) { bgpSet[String(typeof a === "object" ? a.asn : a)] = true; });
var match = false;
irrArr.forEach(function(a) { if (bgpSet[String(typeof a === "object" ? a.asn : a)]) match = true; });
return Object.keys(bgpSet).length > 0 && irrArr.length > 0 && !match;
});
return {
status: mismatches.length === 0 ? "pass" : "warning",
total_entries: entries.length,
mismatches: mismatches.slice(0, 10).map(function(e) { return { prefix: e.prefix, bgp_origins: e.bgpOrigins || e.bgp_origins, irr_origins: e.irrRoutes || e.irr_origins }; }),
mismatch_count: mismatches.length,
};
}).catch(function(e) { return { status: "error", error: String(e) }; });
// 13. RPKI ROA Completeness (local validation against Cloudflare RPKI feed - all RIRs)
await ensureAspaCache(); // Ensure ROA data is loaded
validationPromises.rpki_completeness = Promise.all(
allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
).then(function(rpkiResults) {
var checkedResults = rpkiResults.filter(function(r) { return r.status !== "unavailable"; });
var unavailableCount = rpkiResults.length - checkedResults.length;
var withRoa = checkedResults.filter(function(r) { return r.status === "valid"; });
var coverage = checkedResults.length > 0 ? Math.round((withRoa.length / checkedResults.length) * 100) : 0;
var overSpecific = checkedResults.filter(function(r) {
var mask = parseInt((r.prefix || "").split("/")[1] || "0");
return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid";
});
// If the local RPKI DB was unreachable for every prefix, we haven't actually
// checked anything -- say so instead of reporting a misleading pass/fail.
var allUnavailable = rpkiResults.length > 0 && checkedResults.length === 0;
return {
status: allUnavailable ? "info" : coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail",
coverage_pct: coverage,
total_checked: checkedResults.length,
db_unavailable_count: unavailableCount,
with_roa: withRoa.length,
over_specific: overSpecific.map(function(r) { return r.prefix; }),
details: rpkiResults,
};
}).catch(function(e) { return { status: "error", error: String(e) }; });
// 14. Abuse Contact Validation
validationPromises.abuse_contact = fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) {
if (data === null) return { status: "info", message: "abuse-contact-finder unavailable (fetch failed)", contacts: [], has_valid_email: false };
var contacts = data && data.data && data.data.abuse_contacts ? data.data.abuse_contacts : [];
var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); });
return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail };
}).catch(function(e) { return { status: "error", error: String(e) }; });
// 15. Spamhaus DROP/Blocklist
validationPromises.blocklist = Promise.all(
samplePrefixes.slice(0, 5).map(function(pfx) {
return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) {
// fetchRipeStatCached resolves(null) on timeout/network-error rather than
// throwing, so the .catch() below never sees that case -- distinguish it
// explicitly instead of letting a failed lookup read as "not listed".
if (data === null) return { prefix: pfx, listed: false, checked: false };
var sources = data && data.data && data.data.sources ? data.data.sources : [];
var listed = sources.filter(function(s) { return s.prefix_count > 0 || (s.entries && s.entries.length > 0); });
return { prefix: pfx, listed: listed.length > 0, checked: true, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) };
}).catch(function() { return { prefix: pfx, listed: false, checked: false }; });
})
).then(function(results) {
var checkedResults = results.filter(function(r) { return r.checked; });
var listedPrefixes = checkedResults.filter(function(r) { return r.listed; });
var allUnchecked = results.length > 0 && checkedResults.length === 0;
return {
status: allUnchecked ? "info" : (listedPrefixes.length === 0 ? "pass" : "fail"),
checked: checkedResults.length,
unavailable: results.length - checkedResults.length,
listed_prefixes: listedPrefixes,
};
}).catch(function(e) { return { status: "error", error: String(e) }; });
// 16. MANRS Compliance — scraped from public participants list (24h cache)
validationPromises.manrs = ensureManrsCache().then(function() {
return checkManrsMembership(rawAsn);
}).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable: " + e.message, note: "https://www.manrs.org/netops/participants/" }; });
// 17. Reverse DNS Coverage (3 prefix sample — more causes semaphore starvation on large ASNs)
var rdnsSampleSize = Math.min(3, samplePrefixes.length);
validationPromises.rdns = Promise.all(
samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) {
return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) {
if (data === null) return { prefix: pfx, has_rdns: false, details: [], error: true };
var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {};
var hasDelegation = false;
var details = [];
// API returns { ipv4: { "prefix": { complete, domains } }, ipv6: { ... } }
["ipv4", "ipv6"].forEach(function(af) {
var afData = pfxData[af] || {};
Object.keys(afData).forEach(function(p) {
var entry = afData[p];
if (entry && entry.complete) hasDelegation = true;
if (entry && entry.domains) {
entry.domains.forEach(function(d) {
if (d.found) hasDelegation = true;
details.push({ domain: d.domain, found: !!d.found });
});
}
});
});
// Fallback: old array format
if (Array.isArray(pfxData)) {
pfxData.forEach(function(p) {
if (p.ipv4 || p.ipv6 || (p.delegations && p.delegations.length > 0)) hasDelegation = true;
});
}
return { prefix: pfx, has_rdns: hasDelegation, details: details };
}).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; });
})
).then(function(results) {
var checkedResults = results.filter(function(r) { return !r.error; });
var withRdns = checkedResults.filter(function(r) { return r.has_rdns; });
var coverage = checkedResults.length > 0 ? Math.round((withRdns.length / checkedResults.length) * 100) : 0;
var failedPrefixes = checkedResults.filter(function(r) { return !r.has_rdns; }).map(function(r) { return r.prefix; });
var allUnavailable = results.length > 0 && checkedResults.length === 0;
return {
status: allUnavailable ? "info" : (coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail"),
coverage_pct: coverage,
checked: checkedResults.length,
unavailable: results.length - checkedResults.length,
results: results,
failed_prefixes: failedPrefixes,
};
}).catch(function(e) { return { status: "error", error: String(e) }; });
// 18. BGP Visibility (uses routing-status API which is more reliable than visibility API)
validationPromises.visibility = fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 8000 }).then(function(rsData) {
var vis = rsData && rsData.data && rsData.data.visibility ? rsData.data.visibility : {};
var v4 = vis.v4 || {};
var v6 = vis.v6 || {};
var totalPeers = (v4.total_ris_peers || 0) + (v6.total_ris_peers || 0);
var seeingPeers = (v4.ris_peers_seeing || 0) + (v6.ris_peers_seeing || 0);
var score = totalPeers > 0 ? Math.round((seeingPeers / totalPeers) * 100) : 0;
var observedNeighbours = rsData && rsData.data ? (rsData.data.observed_neighbours || 0) : 0;
// If routing-status returned no data, try bgproutes.io
if (totalPeers === 0 && samplePrefixes[0]) {
return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) {
if (bgprFb && bgprFb.vps_seeing > 0) {
seeingPeers = bgprFb.vps_seeing;
totalPeers = Math.max(bgprFb.vps_seeing, 300);
score = Math.round((seeingPeers / totalPeers) * 100);
}
return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "bgproutes.io_fallback" };
}).catch(function() {
// Neither RIPE routing-status nor the bgproutes.io fallback returned data --
// this network hasn't actually been measured, not confirmed at 0% visibility.
return { status: "info", message: "visibility could not be measured (both data sources unavailable)", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" };
});
}
return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "ripe_routing_status" };
}).catch(function(e) { return { status: "error", error: String(e) }; });
// 19. BGP Communities Analysis
validationPromises.communities = (samplePrefixes.length > 0
? (function() {
var now = new Date();
var end = now.toISOString().replace(/\.\d+Z/, "");
var startTime = new Date(now.getTime() - 3600000).toISOString().replace(/\.\d+Z/, "");
return fetchRipeStatCached("https://stat.ripe.net/data/bgp-updates/data.json?resource=" + encodeURIComponent(samplePrefixes[0]) + "&starttime=" + startTime + "&endtime=" + end);
})()
: Promise.resolve(null)
).then(function(data) {
var updates = data && data.data && data.data.updates ? data.data.updates : [];
var communityMap = {};
var wellKnown = { "65535:0": "GRACEFUL_SHUTDOWN", "65535:65281": "NO_EXPORT", "65535:65282": "NO_ADVERTISE", "65535:666": "BLACKHOLE" };
updates.forEach(function(u) {
var attrs = u.attrs || {};
var communities = attrs.community || [];
communities.forEach(function(c) {
var key = Array.isArray(c) ? c.join(":") : String(c);
if (!communityMap[key]) communityMap[key] = { community: key, count: 0, well_known: wellKnown[key] || null };
communityMap[key].count++;
});
});
var sorted = Object.values(communityMap).sort(function(a, b) { return b.count - a.count; });
var hasBlackhole = sorted.some(function(c) { return c.well_known === "BLACKHOLE"; });
return { status: hasBlackhole ? "warning" : "pass", total_updates: updates.length, unique_communities: sorted.length, top_communities: sorted.slice(0, 20), well_known_detected: sorted.filter(function(c) { return c.well_known; }) };
}).catch(function(e) { return { status: "error", error: String(e) }; });
// 20. Geolocation Verification
validationPromises.geolocation = (samplePrefixes.length > 0
? fetchRipeStatCached("https://stat.ripe.net/data/maxmind-geo-lite-pfx/data.json?resource=" + encodeURIComponent(samplePrefixes[0]))
: Promise.resolve(null)
).then(function(data) {
var locatedPfxs = data && data.data && data.data.located_resources ? data.data.located_resources : [];
var countries = {};
locatedPfxs.forEach(function(l) { var locs = l.locations || []; locs.forEach(function(loc) { if (loc.country) countries[loc.country] = true; }); });
return { status: Object.keys(countries).length > 0 ? "pass" : "warning", geo_countries: Object.keys(countries), sample_prefix: samplePrefixes[0] || null, located_resources: locatedPfxs.length };
}).catch(function(e) { return { status: "error", error: String(e) }; });
// 21. RPSL/IRR Object Validation (query all 5 RIRs in parallel)
validationPromises.rpsl = (function() {
// Try RIPE first (has richest policy data), then RDAP for other RIRs
var ripePromise = fetchJSON("https://rest.db.ripe.net/lookup/ripe/aut-num/AS" + rawAsn + ".json", { timeout: 5000 }).then(function(data) {
var objects = data && data.objects && data.objects.object ? data.objects.object : [];
if (objects.length === 0) return null;
var attrs = objects[0] && objects[0].attributes && objects[0].attributes.attribute ? objects[0].attributes.attribute : [];
var hasImport = attrs.some(function(a) { return a.name === "import" || a.name === "mp-import"; });
var hasExport = attrs.some(function(a) { return a.name === "export" || a.name === "mp-export"; });
var hasRemarks = attrs.some(function(a) { return a.name === "remarks"; });
return { status: (hasImport || hasExport) ? "pass" : "warning", exists: true, has_import: hasImport, has_export: hasExport, has_remarks: hasRemarks, has_policy: hasImport || hasExport, source: "RIPE" };
}).catch(function() { return null; });
var rdapEndpoints = [
{ name: "APNIC", url: "https://rdap.apnic.net/autnum/" + rawAsn },
{ name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + rawAsn },
{ name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + rawAsn },
{ name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + rawAsn },
];
var rdapPromises = rdapEndpoints.map(function(ep) {
return fetchJSON(ep.url, { timeout: 5000 }).then(function(data) {
if (!data || data.errorCode || !data.handle) return null;
var hasRemarks = !!(data.remarks && data.remarks.length > 0);
var name = data.name || "";
return { status: hasRemarks ? "pass" : "warning", exists: true, has_import: false, has_export: false, has_remarks: hasRemarks, has_policy: false, source: ep.name, rdap_name: name, rdap_handle: data.handle || "" };
}).catch(function() { return null; });
});
return Promise.all([ripePromise].concat(rdapPromises)).then(function(results) {
// Take first successful result
for (var ri = 0; ri < results.length; ri++) {
if (results[ri] !== null) return results[ri];
}
return { status: "warning", exists: false, has_policy: false };
});
})();
// 22. IXP Route Server Participation (Bug 5 fix: fair scoring for bilateral peering)
// Always use asn= for netixlan (more reliable than net_id when PDB rate-limits)
var ixRsQueryUrl = "/netixlan?asn=" + rawAsn;
{
validationPromises.ix_route_server = fetchPeeringDB(ixRsQueryUrl).then(function(ixData) {
var connections = ixData && ixData.data ? ixData.data : [];
var rsParticipants = connections.filter(function(c) { return c.is_rs_peer === true; });
var totalIx = connections.length;
var rsCount = rsParticipants.length;
var rsPct = totalIx > 0 ? Math.round((rsCount / totalIx) * 100) : 0;
var status, note;
if (totalIx > 0 && rsCount > 0) {
// Using route servers - good
status = "pass";
note = rsCount + " of " + totalIx + " IX connections use route servers (" + rsPct + "%)";
} else if (totalIx >= 10 && rsCount === 0) {
// Network with 10+ IX connections but no RS = deliberate bilateral peering policy
status = "pass";
note = "Bilateral peering policy — " + totalIx + " IX connections, all bilateral (no route server usage)";
} else if (totalIx < 3 && rsCount === 0) {
// Very small IX presence and no RS
status = "warning";
note = "Only " + totalIx + " IX connection(s) and no route server usage";
} else {
// Small-medium network (3-9 IX) without RS - informational
status = "info";
note = totalIx + " IX connections without route server usage — consider enabling RS for broader reachability";
}
return { status: status, total_ix_connections: totalIx, rs_peer_count: rsCount, rs_peer_pct: rsPct, note: note };
}).catch(function(e) { return { status: "error", error: String(e) }; });
}
// 23. Resource Certification (local RPKI validation - all prefixes, all RIRs)
validationPromises.resource_cert = Promise.all(
allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
).then(function(results) {
var checked = results.filter(function(r) { return r.status !== "unavailable"; });
var hasRoa = checked.some(function(r) { return r.status === "valid" || r.validating_roas > 0; });
var allUnavailable = results.length > 0 && checked.length === 0;
return { status: allUnavailable ? "info" : hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: checked.length, roa_count: checked.filter(function(r) { return r.status === "valid"; }).length };
}).catch(function(e) { return { status: "error", error: String(e) }; });
// Geolocation cross-ref with PeeringDB facilities
var facCountriesPromise = netId
? fetchPeeringDB("/netfac?net_id=" + netId).then(function(facData) {
return (facData && facData.data ? facData.data : []).map(function(f) { return f.country; }).filter(Boolean);
}).catch(function() { return []; })
: Promise.resolve([]);
// Run all validations in parallel — 5s cap per check, total validate bounded to ~10s
var keys = Object.keys(validationPromises);
var promises = keys.map(function(k) {
return Promise.race([
validationPromises[k],
new Promise(function(resolve) { setTimeout(function() { resolve({ status: "info", message: "timed out" }); }, 5000); }),
]);
});
var settled = await Promise.allSettled(promises);
var facCountries = await facCountriesPromise;
var validations = {};
keys.forEach(function(key, i) {
if (settled[i].status === "fulfilled") {
validations[key] = settled[i].value;
} else {
validations[key] = { status: "error", error: settled[i].reason ? String(settled[i].reason) : "Unknown error" };
}
});
// Enrich geolocation (Bug 4 fix: handle anycast/CDN/global networks)
if (validations.geolocation && validations.geolocation.status !== "error") {
var uniqueFacCountries = {};
facCountries.forEach(function(c) { uniqueFacCountries[c] = true; });
var facCountryCount = Object.keys(uniqueFacCountries).length;
validations.geolocation.pdb_facility_countries = Object.keys(uniqueFacCountries);
var geoSet = {};
(validations.geolocation.geo_countries || []).forEach(function(c) { geoSet[c] = true; });
var geoCountryCount = Object.keys(geoSet).length;
var mismatches = Object.keys(geoSet).filter(function(c) { return !uniqueFacCountries[c] && facCountryCount > 0; });
validations.geolocation.country_mismatches = mismatches;
// Detect global/anycast networks: 5+ facility countries OR Content/NSP type
var netInfoType = (net.info_type || "").toLowerCase();
var isGlobalNetwork = facCountryCount >= 5 || netInfoType === "content" || netInfoType === "nsp";
if (isGlobalNetwork) {
// Global/anycast/CDN network: geo mismatches are expected, not anomalies
validations.geolocation.status = "pass";
if (geoCountryCount === 0) {
validations.geolocation.note = "Global network (" + facCountryCount + " countries, type: " + (net.info_type || "N/A") + ") - no MaxMind geolocation data available";
} else {
validations.geolocation.note = "Global/anycast network - multi-country presence expected (" + facCountryCount + " facility countries, type: " + (net.info_type || "N/A") + ")";
}
validations.geolocation.country_mismatches = [];
} else if (facCountryCount <= 2 && geoCountryCount >= 10) {
// Actual anomaly: small network appearing in many countries
validations.geolocation.status = "warning";
validations.geolocation.note = "Prefixes geolocated in " + geoCountryCount + " countries but only " + facCountryCount + " facility countries - possible hijack or misconfiguration";
}
}
validations.bogon = bogonResult;
// Calculate overall health score (0-100)
var checks = [
{ key: "bogon", weight: 15 },
{ key: "irr", weight: 10 },
{ key: "rpki_completeness", weight: 15 },
{ key: "abuse_contact", weight: 5 },
{ key: "blocklist", weight: 15 },
{ key: "manrs", weight: 5 },
{ key: "rdns", weight: 5 },
{ key: "visibility", weight: 10 },
{ key: "rpsl", weight: 5 },
{ key: "ix_route_server", weight: 5 },
{ key: "resource_cert", weight: 10 },
];
var totalWeight = 0;
var earnedScore = 0;
var checkResults = [];
checks.forEach(function(c) {
var v = validations[c.key];
var points = 0;
if (v && v.status === "info") {
// "info" = unable to verify (e.g. API auth required) — exclude from scoring
checkResults.push({ check: c.key, weight: c.weight, earned: 0, status: "info" });
return;
}
if (v && v.status === "pass") points = c.weight;
else if (v && v.status === "warning") points = Math.round(c.weight * 0.5);
totalWeight += c.weight;
earnedScore += points;
checkResults.push({ check: c.key, weight: c.weight, earned: points, status: v ? v.status : "error" });
});
var healthScore = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0;
var duration = Date.now() - start;
// Build relationships from neighbour data
var relNeighbours = neighbourData && neighbourData.data && neighbourData.data.neighbour_counts
? neighbourData.data.neighbour_counts : {};
var relList = neighbourData && neighbourData.data && neighbourData.data.neighbours
? neighbourData.data.neighbours : [];
var relUpstreams = relList.filter(function(n) { return n.type === "left"; })
.sort(function(a, b) { return (b.power || 0) - (a.power || 0); })
.slice(0, 20)
.map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; });
var relDownstreams = relList.filter(function(n) { return n.type === "right"; })
.sort(function(a, b) { return (b.power || 0) - (a.power || 0); })
.slice(0, 20)
.map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; });
var relPeers = relList.filter(function(n) { return n.type === "uncertain"; })
.sort(function(a, b) { return (b.power || 0) - (a.power || 0); })
.slice(0, 30)
.map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; });
const validateResult = {
meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString(), total_prefixes: allPrefixes.length, prefixes_sampled: samplePrefixes.length },
asn: targetAsn,
name: net.name || (overviewData && overviewData.data ? overviewData.data.holder : "") || "Unknown",
health_score: healthScore,
score_breakdown: checkResults,
validations: validations,
relationships: {
counts: { upstreams: relNeighbours.left || relUpstreams.length, downstreams: relNeighbours.right || relDownstreams.length, peers: relNeighbours.unique || relPeers.length, uncertain: relNeighbours.uncertain || 0 },
upstreams: relUpstreams,
downstreams: relDownstreams,
top_peers: relPeers,
source: "RIPE Stat asn-neighbours",
note: "left=upstream providers, right=downstream customers, uncertain=peers. Sorted by power score.",
},
};
// Cache 0-prefix results only briefly (90s) — they may be due to temporary API failures
// Full results with prefixes are cached for the standard 15 minutes
const validateCacheTTL = allPrefixes.length === 0 ? 90 * 1000 : RESULT_CACHE_TTL;
resultCacheSet(validateResultCache, rawAsn, validateResult, validateCacheTTL);
return res.end(JSON.stringify(validateResult, null, 2));
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "Validation failed", message: err.message }));
}
}
// ============================================================
// Main lookup endpoint: /api/lookup?asn=X
// ============================================================
if (reqPath === "/api/lookup") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
if (!rawAsn) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
}
const asn = rawAsn;
const cacheKey = "lookup:" + asn;
const cached = cacheGet(cacheKey);
if (cached) {
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
return res.end(JSON.stringify(cached));
}
const start = Date.now();
try {
// Phase 0: Get PDB net first — check L2 cache, then API with retry
let pdbNet = pdbSourceCache.get("net", asn);
if (!pdbNet) {
pdbNet = await fetchPeeringDBWithRetry("/net?asn=" + asn);
if (pdbNet) pdbSourceCache.set("net", asn, pdbNet);
}
const net = pdbNet?.data?.[0] || {};
const netId = net.id;
// Phase 1: ALL calls in parallel — RIPE Stat (cached+throttled) + PDB IX/Fac (cached) + Atlas + bgp.he.net
const ixQuery = netId
? "/netixlan?net_id=" + netId + "&limit=1000"
: "/netixlan?asn=" + asn + "&limit=1000";
const ixCacheKey = netId ? String(netId) : "asn:" + asn;
// Check PDB source cache for IX/Fac data
let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey);
let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null;
// Per-source timing tracking — 9s hard cap per source to prevent long-tail blocking
const sourceTiming = {};
function timedFetch(name, promise) {
const ts = Date.now();
return Promise.race([
Promise.resolve(promise),
new Promise(function(r) { setTimeout(function() { r(null); }, 9000); }),
])
.then(function(r) { sourceTiming[name] = Date.now() - ts; return r; })
.catch(function() { sourceTiming[name] = null; return null; });
}
const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null;
// RDAP: check module-level cache first, only hit RIR endpoints on cache miss
const rdapCached = rdapCacheGet(asn);
const rdapPromise = rdapCached !== undefined
? Promise.resolve(rdapCached)
: Promise.race([
...["https://rdap.db.ripe.net/autnum/"+asn, "https://rdap.arin.net/registry/autnum/"+asn,
"https://rdap.apnic.net/autnum/"+asn, "https://rdap.lacnic.net/rdap/autnum/"+asn,
"https://rdap.afrinic.net/rdap/autnum/"+asn].map(url =>
fetchJSON(url, { timeout: 4000 })
.then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {}))
.catch(() => new Promise(() => {}))
),
new Promise(resolve => setTimeout(() => resolve(null), 5000)),
]).then(d => { rdapCacheSet(asn, d); return d; });
const promises = [
timedFetch("RIPE Stat Prefixes", localDb ? localDb.getRipeStatAnnouncedPrefixes(asn) : Promise.resolve(null)),
timedFetch("RIPE Stat Neighbours", localDb ? localDb.getRipeStatAsnNeighbours(asn) : Promise.resolve(null)),
timedFetch("RIPE Stat Overview", localDb ? localDb.getRipeStatAsOverview(asn) : Promise.resolve(null)),
timedFetch("RIPE Stat RIR", Promise.resolve(null)),
timedFetch("RIPE Atlas", Promise.resolve(null)),
timedFetch("bgp.he.net", Promise.resolve(null)),
timedFetch("RIPE Stat Visibility", localDb ? localDb.getRipeStatVisibility(asn) : Promise.resolve(null)),
timedFetch("RIPE Stat PrefixSize", localDb ? localDb.getRipeStatPrefixSizeDistribution(asn) : Promise.resolve(null)),
timedFetch("PeeringDB IXLan", cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery)),
timedFetch("PeeringDB Facilities", cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))),
timedFetch("PeeringDB Contacts", pocQuery ? fetchPeeringDB(pocQuery).catch(() => null) : Promise.resolve(null)),
timedFetch("RDAP Registration", rdapPromise),
];
const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData, pocData, rdapData] = await Promise.all(promises);
// Store PDB results in L2 source cache for future lookups
if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData);
if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData);
// local-db-client's getRipeStat* functions return status:"error" (not "ok") when
// the local Postgres DB query itself failed -- track which sources actually
// failed so we don't cache a DB hiccup's fake-empty result as if it were a
// genuine "this ASN has 0 prefixes" answer for the full 5-minute TTL.
const degradedSources = [
["RIPE Stat Prefixes", prefixData], ["RIPE Stat Neighbours", neighbourData],
["RIPE Stat Overview", overviewData], ["RIPE Stat Visibility", visibilityData],
["RIPE Stat PrefixSize", prefixSizeData],
].filter(([, v]) => v && v.status === "error").map(([name]) => name);
const prefixes = prefixData?.data?.prefixes || [];
const neighbours = neighbourData?.data?.neighbours || [];
const overview = overviewData?.data || {};
const rirEntries = rirData?.data?.located_resources || rirData?.data?.rir_stats || [];
// Bug 6 fix: Atlas probe status uses status.name (object), not status_name (flat)
const atlasProbes = atlasProbeData?.results || [];
const atlasConnected = atlasProbes.filter(p => {
const sName = (p.status_name || (p.status && p.status.name) || "").toLowerCase();
return sName === "connected";
});
const atlasAnchors = atlasProbes.filter(p => p.is_anchor === true);
// RPKI: validate ALL prefixes using local Cloudflare RPKI data (all 5 RIRs, instant)
await ensureAspaCache();
const allPrefixes = prefixes.map((p) => p.prefix);
const rpkiAllResults = await Promise.all(allPrefixes.map((pfx) => validateRPKIWithCache(asn, pfx)));
const ixConnections = (ixlanData?.data || [])
.map((ix) => ({
ix_name: ix.name || "",
ix_id: ix.ix_id,
speed_mbps: ix.speed || 0,
ipv4: ix.ipaddr4 || null,
ipv6: ix.ipaddr6 || null,
city: ix.city || "",
is_rs_peer: ix.is_rs_peer === true,
}))
.sort((a, b) => b.speed_mbps - a.speed_mbps);
const facilitiesRaw = (facData?.data || []).map((f) => ({
fac_id: f.fac_id,
name: f.name || "",
city: f.city || "",
country: f.country || "",
}));
// Batch-fetch facility coordinates for map (max 50 facilities)
const facIds = facilitiesRaw.map(f => f.fac_id).filter(Boolean).slice(0, 50);
let facCoordMap = {};
if (facIds.length > 0) {
try {
const chunks = [];
for (let i = 0; i < facIds.length; i += 25) chunks.push(facIds.slice(i, i + 25));
const coordResults = await Promise.race([
Promise.all(chunks.map(chunk =>
fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
)),
new Promise(r => setTimeout(() => r([]), 5000))
]);
(coordResults || []).forEach(res => {
(res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
});
} catch(e) { /* graceful degradation */ }
}
const facilities = facilitiesRaw.map(f => ({
...f,
latitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lat : null,
longitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lon : null,
}));
// Get IX locations for map via ixfac -> fac coordinates (max 20 IXs)
const uniqueIxIds = [...new Set(ixConnections.map(c => c.ix_id))].filter(Boolean).slice(0, 20);
let ixLocations = [];
if (uniqueIxIds.length > 0) {
try {
const ixFacData = await Promise.race([
fetchPeeringDB("/ixfac?ix_id__in=" + uniqueIxIds.join(",")),
new Promise(r => setTimeout(() => r(null), 5000))
]);
const ixFacs = ixFacData?.data || [];
// Collect unique fac_ids we don't already have coords for
const extraFacIds = [...new Set(ixFacs.map(f => f.fac_id).filter(id => id && !facCoordMap[id]))].slice(0, 30);
if (extraFacIds.length > 0) {
const extraChunks = [];
for (let i = 0; i < extraFacIds.length; i += 25) extraChunks.push(extraFacIds.slice(i, i + 25));
const extraRes = await Promise.race([
Promise.all(extraChunks.map(chunk =>
fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
)),
new Promise(r => setTimeout(() => r([]), 4000))
]);
(extraRes || []).forEach(res => {
(res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
});
}
// Build IX locations: pick first facility with coords per IX
const ixNameMap = {};
ixConnections.forEach(c => { if (c.ix_id && c.ix_name) ixNameMap[c.ix_id] = c.ix_name; });
const seenIx = {};
ixFacs.forEach(f => {
if (seenIx[f.ix_id]) return;
const coords = facCoordMap[f.fac_id];
if (coords) {
seenIx[f.ix_id] = true;
ixLocations.push({ ix_id: f.ix_id, name: ixNameMap[f.ix_id] || f.name || "", city: f.city || "", country: f.country || "", latitude: coords.lat, longitude: coords.lon });
}
});
} catch(e) { /* graceful degradation */ }
}
const rpkiStatuses = rpkiAllResults;
const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length;
const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length;
const rpkiUnavailable = rpkiStatuses.filter((r) => r.status === "unavailable").length;
const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid" && r.status !== "unavailable").length;
// Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
// must not lower a network's displayed RPKI coverage percentage.
const rpkiTotal = rpkiStatuses.length - rpkiUnavailable;
const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0;
let upstreams = neighbours
.filter((n) => n.type === "left")
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
.sort((a, b) => b.power - a.power);
let downstreams = neighbours
.filter((n) => n.type === "right")
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
.sort((a, b) => b.power - a.power);
let peers = neighbours
.filter((n) => n.type === "uncertain" || n.type === "peer")
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
.sort((a, b) => b.power - a.power);
// Resolve empty AS names — all in parallel, with 3s timeout
const emptyNameNeighbours = [...upstreams, ...downstreams, ...peers].filter(n => !n.name);
if (emptyNameNeighbours.length > 0) {
const resolvePromise = Promise.all(
emptyNameNeighbours.map(n =>
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
.then(r => { if (r?.data?.holder) n.name = r.data.holder; })
.catch(() => {})
)
);
await Promise.race([resolvePromise, new Promise(r => setTimeout(r, 3000))]);
}
// ---- Threat Intelligence Enrichment for Neighbors ----
// Enrich neighbor data with threat status from local threat_intel table
const threatEnrichNeighbors = async (neighbors) => {
const allNeighbors = [...neighbors];
const threatMap = {};
// Batch threat intel lookups (cap at 50 to avoid overwhelming DB)
const toCheck = allNeighbors.slice(0, 50);
const threatPromises = toCheck.map(async (n) => {
try {
// Try to get threat intel by AS number or typical AS IP pattern
// For now, we'll mark neighbors without direct IP threat data
const asNum = String(n.asn);
const threat = await localDb.getThreatIntel(asNum);
if (threat) {
threatMap[n.asn] = {
threat_level: threat.threat_level,
confidence_score: threat.confidence_score,
source: threat.source,
cached_at: threat.cached_at,
};
}
} catch (e) {
// Gracefully skip on error
console.error(`[Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
}
});
// Run threat lookups with 4s timeout
await Promise.race([
Promise.all(threatPromises),
new Promise(r => setTimeout(r, 4000)),
]);
return threatMap;
};
const threatMap = await threatEnrichNeighbors([...upstreams, ...downstreams, ...peers]);
// Attach threat status to neighbor objects
const addThreatToNeighbor = (n) => ({
...n,
threat_level: threatMap[n.asn]?.threat_level || null,
threat_confidence: threatMap[n.asn]?.confidence_score || null,
threat_source: threatMap[n.asn]?.source || null,
});
upstreams = upstreams.map(addThreatToNeighbor);
downstreams = downstreams.map(addThreatToNeighbor);
peers = peers.map(addThreatToNeighbor);
let rir = "";
let country = "";
// RIPE Stat rir-stats-country uses 'location' field (not 'country' or 'rir')
if (Array.isArray(rirEntries) && rirEntries.length > 0) {
country = rirEntries[0]?.location || rirEntries[0]?.country || "";
rir = rirEntries[0]?.rir || "";
}
if (!rir && rirData?.data) {
const rirField = rirData.data.rirs || [];
if (rirField.length > 0) rir = rirField[0]?.rir || "";
}
// Derive RIR from rdapData.port43 (e.g. "whois.ripe.net" → "RIPE")
if (!rir && rdapData && rdapData.port43) {
const p43 = (rdapData.port43 || "").toLowerCase();
if (p43.includes("ripe")) rir = "RIPE";
else if (p43.includes("arin")) rir = "ARIN";
else if (p43.includes("apnic")) rir = "APNIC";
else if (p43.includes("lacnic")) rir = "LACNIC";
else if (p43.includes("afrinic")) rir = "AFRINIC";
}
// Also derive RIR from RDAP links (URL of the RDAP endpoint that responded)
if (!rir && rdapData && rdapData.links) {
const selfLink = (rdapData.links.find(l => l.rel === "self") || {}).href || "";
if (selfLink.includes("ripe")) rir = "RIPE";
else if (selfLink.includes("arin")) rir = "ARIN";
else if (selfLink.includes("apnic")) rir = "APNIC";
else if (selfLink.includes("lacnic")) rir = "LACNIC";
else if (selfLink.includes("afrinic")) rir = "AFRINIC";
}
// bgp.he.net country_code fallback (for unannounced/reserve ASNs)
if (!country && bgpHeData && bgpHeData.country_code) {
country = bgpHeData.country_code;
}
// Last resort: derive RIR from country code (common assignments)
if (!rir && country) {
const { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC } = require("./server/data/rir-country-codes");
if (ARIN_CC.has(country)) rir = "ARIN";
else if (APNIC_CC.has(country)) rir = "APNIC";
else if (LACNIC_CC.has(country)) rir = "LACNIC";
else if (AFRINIC_CC.has(country)) rir = "AFRINIC";
else rir = "RIPE"; // Europe + rest = RIPE NCC
}
const duration = Date.now() - start;
// Compute routing visibility and prefix size distribution
const routingInfo = await (async function() {
const ipv4Prefixes = prefixes.filter(function(p) { return !p.prefix.includes(":"); });
const ipv6Prefixes = prefixes.filter(function(p) { return p.prefix.includes(":"); });
var ipv4VisAvg = 0, ipv6VisAvg = 0, totalRisPeersV4 = 0, totalRisPeersV6 = 0;
// Visibility API returns per-RIS-collector data
// Each collector has ipv4_full_table_peer_count and ipv4_full_table_peers_not_seeing[]
// Bug 3 fix: visibility API may timeout for large ASNs — handle gracefully
var visibilities = (visibilityData && visibilityData.data && visibilityData.data.visibilities) || [];
var v4Seeing = 0, v4Total = 0, v6Seeing = 0, v6Total = 0;
var visTimedOut = !visibilityData || !visibilityData.data;
visibilities.forEach(function(v) {
if (!v || !v.probe) return;
var v4PeerCount = v.ipv4_full_table_peer_count || 0;
var v4NotSeeing = (v.ipv4_full_table_peers_not_seeing || []).length;
var v6PeerCount = v.ipv6_full_table_peer_count || 0;
var v6NotSeeing = (v.ipv6_full_table_peers_not_seeing || []).length;
v4Total += v4PeerCount;
v4Seeing += (v4PeerCount - v4NotSeeing);
v6Total += v6PeerCount;
v6Seeing += (v6PeerCount - v6NotSeeing);
});
if (v4Total > 0) ipv4VisAvg = Math.round((v4Seeing / v4Total) * 1000) / 10;
if (v6Total > 0) ipv6VisAvg = Math.round((v6Seeing / v6Total) * 1000) / 10;
// If visibility API timed out but we have prefixes, try bgproutes.io fallback
if (visTimedOut && prefixes.length > 0) {
var fallbackPrefix = prefixes.find(function(p) { return !p.prefix.includes(":"); });
if (!fallbackPrefix) fallbackPrefix = prefixes[0];
if (fallbackPrefix) {
var bgprFallback = await fetchBgproutesVisibility(fallbackPrefix.prefix);
if (bgprFallback && bgprFallback.vps_seeing > 0) {
// Estimate visibility: % of VPs seeing the prefix (assume ~300 total RIS-equivalent VPs)
var estimatedTotal = Math.max(bgprFallback.vps_seeing, 300);
ipv4VisAvg = Math.round((bgprFallback.vps_seeing / estimatedTotal) * 1000) / 10;
ipv6VisAvg = -1; // bgproutes fallback is per-prefix, not per-AF aggregate
totalRisPeersV4 = bgprFallback.vps_seeing;
console.log("[Visibility] RIPE Stat timed out, used bgproutes.io fallback for " + fallbackPrefix.prefix + ": " + bgprFallback.vps_seeing + " VPs seeing it");
} else {
ipv4VisAvg = -1;
ipv6VisAvg = -1;
console.log("[Visibility] RIPE Stat timed out and bgproutes.io fallback returned no data");
}
} else {
ipv4VisAvg = -1;
ipv6VisAvg = -1;
}
}
totalRisPeersV4 = v4Total;
totalRisPeersV6 = v6Total;
// Prefix size distribution: data.ipv4[] and data.ipv6[] arrays with {size, count}
var psdData = (prefixSizeData && prefixSizeData.data) || {};
var psV4 = (psdData.ipv4 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
var psV6 = (psdData.ipv6 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
return {
ipv4_prefixes: ipv4Prefixes.length,
ipv6_prefixes: ipv6Prefixes.length,
ipv4_visibility_avg: ipv4VisAvg,
ipv6_visibility_avg: ipv6VisAvg,
total_ris_peers_v4: totalRisPeersV4,
total_ris_peers_v6: totalRisPeersV6,
prefix_sizes_v4: psV4,
prefix_sizes_v6: psV6,
};
})();
// ============================================================
// Multi-source cross-checks (run in parallel, non-blocking)
// ============================================================
let rpkiCrossCheck = { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: null, disagreements: [], sample_size: 0, comparable_size: 0 };
let prefixCrossCheck = { ripe_stat: prefixes.length, bgp_he_net: null, agreement: null, note: "" };
let neighbourCrossCheck = { ripe_stat_total: neighbours.length, bgp_he_net_total: null };
try {
// RPKI cross-check: sample up to 5 prefixes against RIPE Validator (with 8s total timeout)
const rpkiCrossPromise = crossCheckRpki(asn, allPrefixes, rpkiStatuses);
const rpkiCrossResult = await Promise.race([
rpkiCrossPromise,
new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
]);
if (rpkiCrossResult) rpkiCrossCheck = rpkiCrossResult;
} catch (_e) { /* cross-check failed, keep defaults */ }
// bgp.he.net fetching is deliberately disabled (see the hardcoded
// Promise.resolve(null) a few hundred lines up, "bgp.he.net" timedFetch) --
// bgpHeData is therefore always null and this whole branch, along with the
// prefix/neighbour cross-checks below, never actually runs. Left in place
// (not deleted) in case bgp.he.net fetching is re-enabled later, but
// overall_confidence below must not claim a 2-source agreement that never
// happened -- see the sources:1/2 handling further down.
// Prefix count cross-check: compare RIPE Stat vs bgp.he.net
if (bgpHeData) {
const heV4 = bgpHeData.prefixes_v4 || 0;
const heV6 = bgpHeData.prefixes_v6 || 0;
const heTotal = heV4 + heV6;
if (heTotal > 0) {
prefixCrossCheck.bgp_he_net = heTotal;
const ripeStat = prefixes.length;
if (ripeStat > 0 && heTotal > 0) {
const ratio = Math.min(ripeStat, heTotal) / Math.max(ripeStat, heTotal);
prefixCrossCheck.agreement = ratio >= 0.9;
const diff = Math.abs(ripeStat - heTotal);
prefixCrossCheck.note = diff === 0
? "Exact match"
: "Difference of " + diff + " prefixes (" + Math.round((1 - ratio) * 100) + "% divergence)";
}
} else {
prefixCrossCheck.note = "bgp.he.net prefix count unavailable";
}
// Neighbour cross-check: compare RIPE Stat vs bgp.he.net peer_count
if (bgpHeData.peer_count != null) {
neighbourCrossCheck.bgp_he_net_total = bgpHeData.peer_count;
}
} else {
prefixCrossCheck.note = "bgp.he.net data unavailable";
}
// Compute overall data quality. Only push a score for a cross-check that
// actually ran (agreement_pct !== null) -- an unrun/inconclusive check must
// not silently count as a perfect 100 in the average, and confidence must
// reflect "nothing was actually cross-checked" rather than defaulting high.
const crossCheckScores = [];
if (rpkiCrossCheck.agreement_pct !== null) crossCheckScores.push(rpkiCrossCheck.agreement_pct);
// Prefix agreement: convert to percentage
if (prefixCrossCheck.bgp_he_net != null && prefixes.length > 0) {
const pfxRatio = Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net);
crossCheckScores.push(Math.round(pfxRatio * 100));
}
// Neighbour agreement
if (neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0) {
const nbrRatio = Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total);
crossCheckScores.push(Math.round(nbrRatio * 100));
}
const avgAgreement = crossCheckScores.length > 0
? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length)
: null;
const overallConfidence = avgAgreement === null ? "unknown" : avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low";
const dataQuality = {
sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"],
cross_checks: {
rpki: { sources: rpkiCrossCheck.agreement_pct !== null ? 2 : 1, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, comparable_size: rpkiCrossCheck.comparable_size, disagreements: rpkiCrossCheck.disagreements },
prefixes: { sources: prefixCrossCheck.bgp_he_net != null ? 2 : 1, agreement_pct: prefixCrossCheck.bgp_he_net != null ? Math.round((Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net || 1)) * 100) : null, ripe_stat: prefixCrossCheck.ripe_stat, bgp_he_net: prefixCrossCheck.bgp_he_net, note: prefixCrossCheck.note },
neighbours: { sources: neighbourCrossCheck.bgp_he_net_total != null ? 2 : 1, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total },
},
overall_confidence: overallConfidence,
overall_agreement_pct: avgAgreement,
};
// === IX Location Geocode Fallback ===
// Some IXPs have no facility coordinates in PeeringDB.
// Use ix_name city extraction + hard-coded IX→city map as fallback.
var ixIdsWithCoords = new Set(ixLocations.map(function(l) { return l.ix_id; }));
ixConnections.forEach(function(conn) {
if (ixIdsWithCoords.has(conn.ix_id)) return;
var name = conn.ix_name || "";
if (name) {
var words = name.toLowerCase().replace(/[^a-z\s]/g, " ").split(/\s+/).filter(Boolean);
for (var w = 0; w < words.length; w++) {
if (CITY_COORDS[words[w]]) {
ixLocations.push({ ix_id: conn.ix_id, name: name, city: words[w].charAt(0).toUpperCase() + words[w].slice(1), country: "", latitude: CITY_COORDS[words[w]][0], longitude: CITY_COORDS[words[w]][1], source: "name_geocode" });
ixIdsWithCoords.add(conn.ix_id);
return;
}
if (w < words.length - 1) {
var tw = words[w] + " " + words[w + 1];
if (CITY_COORDS[tw]) {
ixLocations.push({ ix_id: conn.ix_id, name: name, city: tw, country: "", latitude: CITY_COORDS[tw][0], longitude: CITY_COORDS[tw][1], source: "name_geocode" });
ixIdsWithCoords.add(conn.ix_id);
return;
}
}
}
}
});
// Hard-coded IX ID → city for well-known IXPs whose names don't contain city
var { IX_CITY_MAP } = require("./server/data/ix-city-map");
ixConnections.forEach(function(conn) {
if (ixIdsWithCoords.has(conn.ix_id)) return;
var city = IX_CITY_MAP[conn.ix_id];
if (city && CITY_COORDS[city]) {
ixLocations.push({ ix_id: conn.ix_id, name: conn.ix_name || ("IX " + conn.ix_id), city: city.charAt(0).toUpperCase() + city.slice(1), country: "", latitude: CITY_COORDS[city][0], longitude: CITY_COORDS[city][1], source: "ix_city_map" });
}
});
const result = {
meta: {
service: "PeerCortex",
version: "0.6.9",
query: "AS" + asn,
duration_ms: duration,
sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"],
timestamp: new Date().toISOString(),
rpki_prefixes_checked: rpkiTotal,
total_prefixes: prefixes.length,
degraded_sources: degradedSources,
},
network: {
asn: parseInt(asn),
name: net.name || overview?.holder || (bgpHeData && bgpHeData.name_from_title) || "Unknown",
aka: net.aka || "",
org_name: (net.org && net.org.name) ? net.org.name : "",
website: net.website || "",
type: net.info_type || "",
policy: net.policy_general || "",
traffic: net.info_traffic || "",
ratio: net.info_ratio || "",
scope: net.info_scope || "",
notes: net.notes ? net.notes.substring(0, 500) : "",
peeringdb_id: netId || null,
rir: rir,
country: country,
city: net.city || "",
latitude: (net.latitude != null) ? net.latitude : null,
longitude: (net.longitude != null) ? net.longitude : null,
looking_glass: net.looking_glass || "",
route_server: net.route_server || "",
info_prefixes4: net.info_prefixes4 || 0,
info_prefixes6: net.info_prefixes6 || 0,
status: net.status || "",
peeringdb_created: net.created ? net.created.slice(0, 10) : "",
peeringdb_updated: net.updated ? net.updated.slice(0, 10) : "",
},
prefixes: {
total: prefixes.length,
ipv4: prefixes.filter((p) => !p.prefix.includes(":")).length,
ipv6: prefixes.filter((p) => p.prefix.includes(":")).length,
list: prefixes.map((p) => p.prefix),
cross_check: prefixCrossCheck,
},
rpki: {
coverage_percent: rpkiCoverage,
valid: rpkiValid,
invalid: rpkiInvalid,
not_found: rpkiNotFound,
unavailable: rpkiUnavailable,
checked: rpkiTotal,
details: rpkiStatuses,
cross_check: rpkiCrossCheck,
},
neighbours: {
total: neighbours.length,
upstream_count: upstreams.length,
downstream_count: downstreams.length,
peer_count: peers.length,
upstreams: upstreams.slice(0, 20),
downstreams: downstreams.slice(0, 20),
peers: peers.slice(0, 20),
cross_check: neighbourCrossCheck,
},
ix_presence: {
total_connections: ixConnections.length,
unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length,
connections: ixConnections,
},
ix_locations: ixLocations,
facilities: {
total: facilities.length,
list: facilities,
},
routing: routingInfo,
resilience_score: computeResilienceScore(upstreams, peers, ixConnections, prefixes),
route_leak: computeRouteLeakDetection(upstreams, downstreams, peers),
bgp_he_net: bgpHeData || null,
atlas: {
total_probes: atlasProbes.length,
connected: atlasConnected.length,
disconnected: atlasProbes.length - atlasConnected.length,
anchors: atlasAnchors.length,
probes: atlasProbes.slice(0, 100).map(p => ({
id: p.id,
status: p.status_name || p.status || "Unknown",
is_anchor: p.is_anchor || false,
country: p.country_code || "",
prefix_v4: p.prefix_v4 || "",
prefix_v6: p.prefix_v6 || "",
description: p.description || "",
})),
},
data_quality: dataQuality,
source_timing: sourceTiming,
contacts: (() => {
const pocs = (pocData && pocData.data) ? pocData.data : [];
return pocs.slice(0, 20).map(p => ({
role: p.role || "",
name: p.name || "",
email: p.email || "",
url: p.url || "",
visible: p.visible || "",
}));
})(),
registration: (() => {
const events = (rdapData && rdapData.events) ? rdapData.events : [];
const created = (events.find(e => e.eventAction === "registration") || {}).eventDate || "";
const lastChg = (events.find(e => e.eventAction === "last changed") || {}).eventDate || "";
return {
created: created ? created.slice(0, 10) : "",
last_modified: lastChg ? lastChg.slice(0, 10) : "",
rir: rir || "",
handle: (rdapData && rdapData.handle) ? rdapData.handle : ("AS" + asn),
rdap_source: (rdapData && rdapData.port43) ? rdapData.port43 : "",
};
})(),
_provenance: {
prefixes: { source: "RIPE Stat announced-prefixes", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net prefix count daily" },
neighbours: { source: "RIPE Stat asn-neighbours", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net peer count daily" },
rpki: { source: "Cloudflare RPKI + RIPE Validator", validation: "cross-validated", confidence: "high", note: "Two independent RPKI sources compared" },
ix_presence: { source: "PeeringDB netixlan (local mirror)", validation: "cross-validated", confidence: "high", note: "PeeringDB mirror refreshed daily" },
facilities: { source: "PeeringDB netfac (local mirror)", validation: "single-source", confidence: "medium" },
bgp_he_net: { source: "bgp.he.net HTML scrape", validation: "single-source", confidence: "medium", note: "HTML scrape, no official API — may have parsing drift" },
atlas: { source: "RIPE Atlas API", validation: "single-source", confidence: "medium", note: "Probe availability varies by region" },
resilience_score: { source: "Computed from RIPE Stat + PeeringDB", validation: "computed", confidence: "high", note: "All inputs cross-validated daily" },
route_leak: { source: "RIPE Stat asn-neighbours heuristic", validation: "heuristic", confidence: "medium", note: "Pattern-based, not real-time — false positives possible" },
registration: { source: "RDAP (RIR registry)", validation: "single-source", confidence: "high" },
contacts: { source: "PeeringDB POC API", validation: "single-source", confidence: "medium", note: "Subject to PeeringDB rate limiting" },
},
};
// Update duration to include cross-check time
result.meta.duration_ms = Date.now() - start;
// A DB hiccup shouldn't get cached as this ASN's answer for the full 5min --
// retry soon instead of repeating a degraded result to every visitor.
cacheSet(cacheKey, result, degradedSources.length > 0 ? CACHE_TTL_LOOKUP_DEGRADED : CACHE_TTL_LOOKUP);
res.end(JSON.stringify(result, null, 2));
} catch (err) {
const duration = Date.now() - start;
res.writeHead(500);
res.end(JSON.stringify({ error: "Lookup failed", message: err.message, duration_ms: duration }));
}
return;
}
// ============================================================
// AS Relationships endpoint: /api/relationships?asn=X
// Returns upstream providers, downstream customers, and peers
// with resolved names. Based on RIPE Stat asn-neighbours.
// ============================================================
if (reqPath === "/api/relationships") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
if (!rawAsn) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
}
const cacheKey = "relationships:" + rawAsn;
const cached = cacheGet(cacheKey);
if (cached) {
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
return res.end(JSON.stringify(cached));
}
const start = Date.now();
try {
const neighbourData = await fetchRipeStatCached(
"https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn + "&lod=1",
{ timeout: 8000 }
);
const neighbours = (neighbourData && neighbourData.data && neighbourData.data.neighbours) || [];
const counts = (neighbourData && neighbourData.data && neighbourData.data.neighbour_counts) || {};
const upstreams = neighbours.filter(n => n.type === "left").sort((a,b) => (b.power||0)-(a.power||0));
const downstreams = neighbours.filter(n => n.type === "right").sort((a,b) => (b.power||0)-(a.power||0));
const peers = neighbours.filter(n => n.type === "uncertain").sort((a,b) => (b.power||0)-(a.power||0));
// Resolve AS names for top entries (upstreams + downstreams all, top 20 peers)
const toResolve = [...upstreams, ...downstreams, ...peers.slice(0, 20)];
const resolvedNames = {};
await Promise.race([
Promise.all(toResolve.map(n =>
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
.then(r => { if (r && r.data && r.data.holder) resolvedNames[n.asn] = r.data.holder; })
.catch(() => {})
)),
new Promise(r => setTimeout(r, 5000)),
]);
// ---- Threat Intelligence Enrichment ----
// Enrich neighbors with threat status from local threat_intel table
const threatMap = {};
const allNeighborsForThreat = [...upstreams, ...downstreams, ...peers];
const threatPromises = allNeighborsForThreat.slice(0, 100).map(async (n) => {
try {
const asNum = String(n.asn);
const threat = await localDb.getThreatIntel(asNum);
if (threat) {
threatMap[n.asn] = {
threat_level: threat.threat_level,
confidence_score: threat.confidence_score,
source: threat.source,
};
}
} catch (e) {
console.error(`[Relationships Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
}
});
await Promise.race([
Promise.all(threatPromises),
new Promise(r => setTimeout(r, 3000)),
]);
const fmt = n => ({
asn: n.asn,
name: resolvedNames[n.asn] || "",
power: n.power || 0,
v4_peers: n.v4_peers || 0,
v6_peers: n.v6_peers || 0,
threat_level: threatMap[n.asn]?.threat_level || null,
threat_confidence: threatMap[n.asn]?.confidence_score || null,
threat_source: threatMap[n.asn]?.source || null,
});
const result = {
asn: parseInt(rawAsn),
query_time: new Date().toISOString(),
duration_ms: Date.now() - start,
counts: {
upstreams: counts.left || upstreams.length,
downstreams: counts.right || downstreams.length,
peers_total: counts.unique || peers.length,
uncertain: counts.uncertain || peers.length,
},
upstreams: upstreams.map(fmt),
downstreams: downstreams.map(fmt),
peers: peers.slice(0, 50).map(fmt),
methodology: "RIPE Stat asn-neighbours API. left=upstream providers (carry your traffic), right=downstream customers (you carry their traffic), uncertain=lateral peers. Sorted by power score (number of prefixes seen via this relationship).",
source_url: "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn,
};
// A neighbourData fetch failure (not a genuine zero-neighbour ASN) would
// otherwise get cached as "0 upstreams/downstreams/peers" for the full 10min --
// match the existing /api/validate precedent (90s TTL for suspicious zero counts).
cacheSet(cacheKey, result, neighbourData ? 10 * 60 * 1000 : 90 * 1000);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(result, null, 2));
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "Relationships lookup failed", message: err.message }));
}
}
// ============================================================
// Compare endpoint: /api/compare?asn1=X&asn2=Y
// ============================================================
if (reqPath === "/api/compare") {
const asn1 = (url.searchParams.get("asn1") || "").replace(/[^0-9]/g, "");
const asn2 = (url.searchParams.get("asn2") || "").replace(/[^0-9]/g, "");
if (!asn1 || !asn2) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Need asn1 and asn2 parameters" }));
}
const compareCacheKey = "compare:" + asn1 + ":" + asn2;
const compareCached = cacheGet(compareCacheKey);
if (compareCached) {
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
return res.end(JSON.stringify(compareCached));
}
const start = Date.now();
try {
// ALL calls in parallel — single batch
// Phase 1: Get PDB net objects + RIPE data
const [pdb1, pdb2, nb1Data, nb2Data, pfx1Data, pfx2Data] = await Promise.all([
fetchPeeringDB("/net?asn=" + asn1),
fetchPeeringDB("/net?asn=" + asn2),
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn1, { timeout: 8000 }),
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2, { timeout: 8000 }),
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1, { timeout: 8000 }),
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2, { timeout: 8000 }),
]);
const net1 = pdb1?.data?.[0] || {};
const net2 = pdb2?.data?.[0] || {};
const netId1 = net1.id;
const netId2 = net2.id;
// Phase 2: IX + Facility using net_id (Bug 1 fix: netfac requires net_id, not asn)
const ixFacPromises = [];
ixFacPromises.push(netId1 ? fetchPeeringDB("/netixlan?net_id=" + netId1) : Promise.resolve(null));
ixFacPromises.push(netId2 ? fetchPeeringDB("/netixlan?net_id=" + netId2) : Promise.resolve(null));
ixFacPromises.push(netId1 ? fetchPeeringDB("/netfac?net_id=" + netId1) : Promise.resolve(null));
ixFacPromises.push(netId2 ? fetchPeeringDB("/netfac?net_id=" + netId2) : Promise.resolve(null));
const [ix1Data, ix2Data, fac1Data, fac2Data] = await Promise.all(ixFacPromises);
const ix1Set = new Set((ix1Data?.data || []).map((ix) => ix.ix_id));
const ix2Set = new Set((ix2Data?.data || []).map((ix) => ix.ix_id));
const ix1Names = {};
(ix1Data?.data || []).forEach((ix) => (ix1Names[ix.ix_id] = ix.name));
const ix2Names = {};
(ix2Data?.data || []).forEach((ix) => (ix2Names[ix.ix_id] = ix.name));
const commonIX = [...ix1Set].filter((id) => ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || ix2Names[id] || "" }));
const only1IX = [...ix1Set].filter((id) => !ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || "" }));
const only2IX = [...ix2Set].filter((id) => !ix1Set.has(id)).map((id) => ({ ix_id: id, name: ix2Names[id] || "" }));
const fac1Set = new Set((fac1Data?.data || []).map((f) => f.fac_id));
const fac2Set = new Set((fac2Data?.data || []).map((f) => f.fac_id));
const fac1Names = {};
(fac1Data?.data || []).forEach((f) => (fac1Names[f.fac_id] = f.name));
const fac2Names = {};
(fac2Data?.data || []).forEach((f) => (fac2Names[f.fac_id] = f.name));
const commonFac = [...fac1Set].filter((id) => fac2Set.has(id)).map((id) => ({ fac_id: id, name: fac1Names[id] || fac2Names[id] || "" }));
const nb1 = (nb1Data?.data?.neighbours || []).filter((n) => n.type === "left");
const nb2 = (nb2Data?.data?.neighbours || []).filter((n) => n.type === "left");
const up1Set = new Set(nb1.map((n) => n.asn));
const up2Set = new Set(nb2.map((n) => n.asn));
const nb1Map = {};
nb1.forEach((n) => (nb1Map[n.asn] = n.as_name || ""));
const nb2Map = {};
nb2.forEach((n) => (nb2Map[n.asn] = n.as_name || ""));
const commonUpstreams = [...up1Set]
.filter((a) => up2Set.has(a))
.map((a) => ({ asn: a, name: nb1Map[a] || nb2Map[a] || "" }));
// ---- Threat Intelligence Enrichment for Common Upstreams ----
const threatMap = {};
const threatPromises = commonUpstreams.slice(0, 50).map(async (n) => {
try {
const asNum = String(n.asn);
const threat = await localDb.getThreatIntel(asNum);
if (threat) {
threatMap[n.asn] = {
threat_level: threat.threat_level,
confidence_score: threat.confidence_score,
source: threat.source,
};
}
} catch (e) {
console.error(`[Compare Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
}
});
await Promise.race([
Promise.all(threatPromises),
new Promise(r => setTimeout(r, 2000)),
]);
// Attach threat status to upstream objects
commonUpstreams.forEach((n) => {
if (threatMap[n.asn]) {
n.threat_level = threatMap[n.asn].threat_level;
n.threat_confidence = threatMap[n.asn].confidence_score;
n.threat_source = threatMap[n.asn].source;
}
});
// Resolve names + RPKI sample (max 3+3 prefixes) all in parallel with 5s timeout
const pfx1 = (pfx1Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
const pfx2 = (pfx2Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
const [, rpki1Results, rpki2Results] = await Promise.race([
Promise.all([
commonUpstreams.length > 0 ? Promise.all(commonUpstreams.map(n =>
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
.then(r => { if (r?.data?.holder) n.name = r.data.holder; })
.catch(() => {})
)) : Promise.resolve([]),
Promise.all(pfx1.map((p) => fetchRPKIPerPrefix(asn1, p))),
Promise.all(pfx2.map((p) => fetchRPKIPerPrefix(asn2, p))),
]),
new Promise(r => setTimeout(() => r([[], [], []]), 5000)),
]);
const rpki1Valid = rpki1Results.filter((r) => r.status === "valid").length;
const rpki2Valid = rpki2Results.filter((r) => r.status === "valid").length;
const rpki1Pct = rpki1Results.length > 0 ? Math.round((rpki1Valid / rpki1Results.length) * 100) : 0;
const rpki2Pct = rpki2Results.length > 0 ? Math.round((rpki2Valid / rpki2Results.length) * 100) : 0;
const duration = Date.now() - start;
const compareResult = {
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
asn1: {
asn: parseInt(asn1),
name: net1.name || "Unknown",
ix_count: ix1Set.size,
fac_count: fac1Set.size,
upstream_count: up1Set.size,
rpki_coverage: rpki1Pct,
},
asn2: {
asn: parseInt(asn2),
name: net2.name || "Unknown",
ix_count: ix2Set.size,
fac_count: fac2Set.size,
upstream_count: up2Set.size,
rpki_coverage: rpki2Pct,
},
common_ixps: commonIX,
only_asn1_ixps: only1IX,
only_asn2_ixps: only2IX,
common_facilities: commonFac,
common_upstreams: commonUpstreams,
rpki_comparison: {
asn1_coverage: rpki1Pct,
asn2_coverage: rpki2Pct,
asn1_checked: rpki1Results.length,
asn2_checked: rpki2Results.length,
better: rpki1Pct > rpki2Pct ? "AS" + asn1 : rpki2Pct > rpki1Pct ? "AS" + asn2 : "equal",
},
};
cacheSet(compareCacheKey, compareResult, CACHE_TTL_DEFAULT);
res.end(JSON.stringify(compareResult, null, 2));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Compare failed", message: err.message }));
}
return;
}
// ============================================================
// Quick-IX endpoint: /api/quick-ix?asn=X
// Lightweight: only IX connections from PeeringDB, 1h cached
// Used by Peering Recommendations to avoid 20x full lookups
// ============================================================
if (reqPath === "/api/quick-ix") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
if (!rawAsn) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing asn parameter" }));
}
const cached = quickIxCacheGet(rawAsn);
if (cached !== undefined) {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(cached));
}
try {
const [pdbNetData, pdbIxlanData] = await Promise.all([
fetchJSON("https://www.peeringdb.com/api/net?asn=" + rawAsn + "&depth=0", { timeout: 5000 }).catch(() => null),
fetchJSON("https://www.peeringdb.com/api/netixlan?asn=" + rawAsn + "&limit=100", { timeout: 6000 }).catch(() => null),
]);
const netName = pdbNetData?.data?.[0]?.name || "";
const ixConnections = [];
if (pdbIxlanData && pdbIxlanData.data) {
pdbIxlanData.data.forEach((row) => {
ixConnections.push({ ix_id: row.ixlan_id, name: row.name || "", speed: row.speed || 0 });
});
}
// Fall back to RIPE Stat if PeeringDB returns nothing
if (ixConnections.length === 0) {
const rsStat = await fetchRipeStatCached("https://stat.ripe.net/data/ixs/data.json?resource=AS" + rawAsn, { timeout: 5000 }).catch(() => null);
const ixs = rsStat?.data?.ixs || [];
ixs.forEach((ix) => ixConnections.push({ ix_id: ix.ixp_id || 0, name: ix.name || "", speed: 0 }));
}
const result = { asn: parseInt(rawAsn), name: netName, ix_connections: ixConnections };
quickIxCacheSet(rawAsn, result);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(result));
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "quick-ix lookup failed", message: err.message }));
}
}
// ============================================================
// Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000
// ============================================================
if (reqPath === "/api/peers/find") {
const ixName = url.searchParams.get("ix") || "";
const policy = url.searchParams.get("policy") || "";
const minSpeed = parseInt(url.searchParams.get("min_speed") || "0");
const netType = url.searchParams.get("type") || "";
if (!ixName) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing ix parameter (IX name)" }));
}
const start = Date.now();
try {
// Search for IX by name
const ixSearch = await fetchPeeringDB("/ix?name__contains=" + encodeURIComponent(ixName));
const ixResults = ixSearch?.data || [];
if (ixResults.length === 0) {
return res.end(JSON.stringify({ error: "No IX found matching: " + ixName, matches: [] }));
}
// Use first matching IX
const ix = ixResults[0];
const ixId = ix.id;
// Get ixlan for this IX
const ixlanData = await fetchPeeringDB("/ixlan?ix_id=" + ixId);
const ixlans = ixlanData?.data || [];
if (ixlans.length === 0) {
return res.end(JSON.stringify({ ix: { id: ixId, name: ix.name }, matches: [] }));
}
const ixlanId = ixlans[0].id;
// Get all networks at this IX
const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
const netixlans = netixlanData?.data || [];
// Get unique net_ids
const netIds = [...new Set(netixlans.map(n => n.net_id))];
// Fetch network details in batches
const networks = [];
const batchSize = 20;
for (let i = 0; i < Math.min(netIds.length, 200); i += batchSize) {
const batch = netIds.slice(i, i + batchSize);
const batchResults = await Promise.all(
batch.map(nid => fetchPeeringDB("/net/" + nid))
);
batchResults.forEach(r => {
if (r?.data?.[0]) networks.push(r.data[0]);
});
}
// Filter and rank
let filtered = networks.map(net => {
const nix = netixlans.filter(n => n.net_id === net.id);
const maxSpeed = Math.max(...nix.map(n => n.speed || 0));
return {
asn: net.asn,
name: net.name || "",
policy: net.policy_general || "",
type: net.info_type || "",
speed_mbps: maxSpeed,
speed_gbps: maxSpeed >= 1000 ? (maxSpeed / 1000) + " Gbps" : maxSpeed + " Mbps",
traffic: net.info_traffic || "",
website: net.website || "",
peeringdb_id: net.id,
ipv4: nix[0]?.ipaddr4 || null,
ipv6: nix[0]?.ipaddr6 || null,
};
});
// Apply filters
if (policy) {
filtered = filtered.filter(n => n.policy.toLowerCase().includes(policy.toLowerCase()));
}
if (minSpeed > 0) {
filtered = filtered.filter(n => n.speed_mbps >= minSpeed);
}
if (netType) {
filtered = filtered.filter(n => n.type.toLowerCase().includes(netType.toLowerCase()));
}
// Sort by speed desc
filtered.sort((a, b) => b.speed_mbps - a.speed_mbps);
// Also find common IXPs for each match (check if they share other IXPs)
const duration = Date.now() - start;
return res.end(JSON.stringify({
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
ix: { id: ixId, name: ix.name, ixlan_id: ixlanId },
total_members: netixlans.length,
filtered_count: filtered.length,
matches: filtered.slice(0, 50),
}, null, 2));
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "Peer matching failed", message: err.message }));
}
}
// ============================================================
// Prefix Detail endpoint: /api/prefix/detail?prefix=X
// ============================================================
if (reqPath === "/api/prefix/detail") {
const prefix = url.searchParams.get("prefix") || "";
if (!prefix) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing prefix parameter" }));
}
const start = Date.now();
try {
const [routingStatus, visibility] = await Promise.all([
fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=" + encodeURIComponent(prefix)),
fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=" + encodeURIComponent(prefix)),
]);
const origins = routingStatus?.data?.origins || [];
const firstSeen = routingStatus?.data?.first_seen?.time || null;
// RPKI validation: use local PostgreSQL database (sub-10ms, zero external API calls)
let rpkiStatus = "unknown";
let rpkiRoas = [];
const originAsn = origins.length > 0 ? origins[0].asn : null;
if (originAsn) {
try {
const localRpki = await validateRPKIWithCache(originAsn, prefix);
rpkiStatus = localRpki.status;
rpkiRoas = new Array(localRpki.validating_roas); // count only, no detail
} catch (e) {
console.error("[Prefix Detail] RPKI validation error:", e.message);
rpkiStatus = "unknown";
rpkiRoas = [];
}
}
var visData = visibility?.data?.visibilities || [];
var risPeersSeeingIt = visData.length > 0 ? visData.filter(v => v.ris_peers_seeing > 0).length : 0;
var visibilitySource = "ripe_stat";
// bgproutes.io fallback if RIPE Stat visibility returned no data
if (visData.length === 0 && BGPROUTES_API_KEY) {
var bgprVis = await fetchBgproutesVisibility(prefix);
if (bgprVis && bgprVis.vps_seeing > 0) {
risPeersSeeingIt = bgprVis.vps_seeing;
visData = []; // keep empty, use risPeersSeeingIt
visibilitySource = "bgproutes.io";
}
}
// Try to get IRR data
let irrStatus = "unknown";
try {
const whoisData = await fetchRipeStatCached("https://stat.ripe.net/data/whois/data.json?resource=" + encodeURIComponent(prefix));
const records = whoisData?.data?.records || [];
if (records.length > 0) irrStatus = "found";
} catch(_e) {}
const duration = Date.now() - start;
return res.end(JSON.stringify({
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
prefix: prefix,
origins: origins.map(o => ({ asn: o.asn, prefix: o.prefix })),
rpki: { status: rpkiStatus, validating_roas: rpkiRoas.length },
irr_status: irrStatus,
visibility: { ris_peers_seeing: risPeersSeeingIt, total_probes: visData.length || risPeersSeeingIt, source: visibilitySource },
first_seen: firstSeen,
}, null, 2));
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "Prefix detail failed", message: err.message }));
}
}
// ============================================================
// IX Detail endpoint: /api/ix/detail?ix_id=X
// ============================================================
if (reqPath === "/api/ix/detail") {
const ixId = (url.searchParams.get("ix_id") || "").replace(/[^0-9]/g, "");
if (!ixId) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing ix_id parameter" }));
}
const start = Date.now();
try {
const [ixData, ixlanData] = await Promise.all([
fetchPeeringDB("/ix/" + ixId),
fetchPeeringDB("/ixlan?ix_id=" + ixId),
]);
const ix = ixData?.data?.[0] || {};
const ixlans = ixlanData?.data || [];
const ixlanId = ixlans.length > 0 ? ixlans[0].id : null;
let members = [];
if (ixlanId) {
const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
members = (netixlanData?.data || []).map(m => ({
asn: m.asn,
name: m.name || "",
speed_mbps: m.speed || 0,
speed_display: (m.speed || 0) >= 1000 ? ((m.speed || 0) / 1000) + " Gbps" : (m.speed || 0) + " Mbps",
ipv4: m.ipaddr4 || null,
ipv6: m.ipaddr6 || null,
}));
}
// Sort by speed desc for top members
const sorted = members.slice().sort((a, b) => b.speed_mbps - a.speed_mbps);
const duration = Date.now() - start;
return res.end(JSON.stringify({
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
ix: {
id: parseInt(ixId),
name: ix.name || "",
city: ix.city || "",
country: ix.country || "",
website: ix.website || "",
peeringdb_url: "https://www.peeringdb.com/ix/" + ixId,
},
total_members: members.length,
top_members_by_speed: sorted.slice(0, 20),
all_members: sorted,
}, null, 2));
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "IX detail failed", message: err.message }));
}
}
// ============================================================
// Feature 25: Topology endpoint
// ============================================================
if (reqPath === "/api/topology") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
const depth = parseInt(url.searchParams.get("depth") || "2") || 2;
if (!rawAsn) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
}
const start = Date.now();
try {
const topology = await fetchTopology(parseInt(rawAsn), depth);
// ---- Threat Intelligence Enrichment for Topology Nodes ----
const threatMap = {};
const threatPromises = topology.nodes.slice(0, 100).map(async (node) => {
try {
const asNum = String(node.asn);
const threat = await localDb.getThreatIntel(asNum);
if (threat) {
threatMap[node.asn] = {
threat_level: threat.threat_level,
confidence_score: threat.confidence_score,
source: threat.source,
};
}
} catch (e) {
console.error(`[Topology Threat Lookup] Error checking ASN ${node.asn}:`, e.message);
}
});
await Promise.race([
Promise.all(threatPromises),
new Promise(r => setTimeout(r, 3000)),
]);
// Attach threat status to node objects
topology.nodes.forEach((node) => {
if (threatMap[node.asn]) {
node.threat_level = threatMap[node.asn].threat_level;
node.threat_confidence = threatMap[node.asn].confidence_score;
node.threat_source = threatMap[node.asn].source;
}
});
topology.meta = {
query: "AS" + rawAsn, depth: depth, duration_ms: Date.now() - start,
timestamp: new Date().toISOString(), node_count: topology.nodes.length, edge_count: topology.edges.length,
};
return res.end(JSON.stringify(topology, null, 2));
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "Topology query failed", message: err.message }));
}
}
// ============================================================
// Feature 27: WHOIS endpoint
// ============================================================
if (reqPath === "/api/whois") {
const resource = url.searchParams.get("resource") || "";
if (!resource) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing resource parameter (ASN, prefix, or domain)" }));
}
const start = Date.now();
try {
const whoisResult = await fetchWhois(resource);
if (!whoisResult || typeof whoisResult !== "object") {
res.writeHead(503);
return res.end(JSON.stringify({ error: "WHOIS data temporarily unavailable" }));
}
whoisResult.meta = { duration_ms: Date.now() - start, timestamp: new Date().toISOString() };
return res.end(JSON.stringify(whoisResult, null, 2));
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "WHOIS lookup failed", message: err.message }));
}
}
// Feature 28b: Company enrichment via Wikipedia + website meta scraping
if (reqPath === "/api/enrich") {
res.setHeader("Content-Type", "application/json");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "no-store");
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
const companyName = (url.searchParams.get("name") || "").trim();
const website = (url.searchParams.get("website") || "").trim();
const UA_SCRAPE = "Mozilla/5.0 (compatible; PeerCortex/1.0; +https://peercortex.org)";
let description = null;
let wikiUrl = null;
try {
// 1. Direct Wikipedia lookup by company name
if (companyName) {
const nameLower = companyName.toLowerCase();
const isRelevant = (title, extract) => {
const titleLower = (title || "").toLowerCase();
const extractLower = (extract || "").toLowerCase();
const nameWords = nameLower.replace(/\s+(gmbh|ag|ltd|inc|llc|bv|sa|sas|oy|ab)\s*$/i, "").split(/\s+/).filter(w => w.length > 3);
const titleMatch = nameWords.some(w => titleLower.includes(w));
const netTerms = ["internet", "network", "isp", "hosting", "provider", "transit", "data center", "datacenter", "telecommunications", "telecom", "bandwidth", "peering", "routing", "autonomous system", "colocation", "colo", "fiber", "optical", "transceiver"];
const hasNetContext = netTerms.some(t => extractLower.includes(t));
return titleMatch && (hasNetContext || titleMatch);
};
// Direct title lookup — try full name first, then first word as fallback
const cleanName = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC|BV|SA|SAS|Oy|AB)$/i, "").trim();
const firstName = cleanName.split(/\s+/)[0];
const namesToTry = cleanName === firstName ? [cleanName] : [cleanName, firstName];
for (const tryName of namesToTry) {
const wikiDirect = await fetchJSON(
"https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(tryName),
{ timeout: 5000 }
);
if (wikiDirect && wikiDirect.type === "disambiguation") continue; // skip disambiguation pages
if (wikiDirect && wikiDirect.extract && wikiDirect.extract.length > 30 && isRelevant(wikiDirect.title, wikiDirect.extract)) {
description = wikiDirect.extract.replace(/\s+/g, " ").trim().slice(0, 300);
wikiUrl = wikiDirect.content_urls && wikiDirect.content_urls.desktop && wikiDirect.content_urls.desktop.page;
break;
}
}
// 2. Wikipedia search if direct lookup didn't match
if (!description) {
const searchQuery = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC)$/i, "") + " internet service provider";
const searchData = await fetchJSON(
"https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=" + encodeURIComponent(searchQuery) + "&limit=3",
{ timeout: 5000 }
);
if (searchData && Array.isArray(searchData) && searchData[1] && searchData[1].length > 0) {
const topTitle = searchData[1][0];
const wikiSearch = await fetchJSON(
"https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(topTitle),
{ timeout: 5000 }
);
if (wikiSearch && wikiSearch.extract && wikiSearch.extract.length > 30) {
if (isRelevant(wikiSearch.title, wikiSearch.extract)) {
description = wikiSearch.extract.replace(/\s+/g, " ").trim().slice(0, 300);
wikiUrl = wikiSearch.content_urls && wikiSearch.content_urls.desktop && wikiSearch.content_urls.desktop.page;
}
}
}
}
}
// 3. Fallback: scrape website meta description (follows up to 3 redirects)
function fetchPage(pageUrl, hops) {
if (hops <= 0) return Promise.resolve(null);
return new Promise((resolve) => {
const mod = pageUrl.startsWith("https") ? https : http;
const req = mod.get(pageUrl, { headers: { "User-Agent": UA_SCRAPE }, timeout: 6000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume(); // drain to free socket
const next = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, pageUrl).href;
return resolve(fetchPage(next, hops - 1));
}
let data = "";
res.on("data", (c) => { data += c; if (data.length > 40000) { req.destroy(); resolve(data); } });
res.on("end", () => resolve(data));
});
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
});
}
if (!description && website) {
let wsUrl = website;
if (!wsUrl.startsWith("http")) wsUrl = "https://" + wsUrl;
const aboutUrl = wsUrl.replace(/\/$/, "") + "/about";
const tryUrls = [aboutUrl, wsUrl];
for (const tryUrl of tryUrls) {
const resp = await fetchPage(tryUrl, 3);
if (!resp) continue;
const metaPatterns = [
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']{20,500})["']/i,
/<meta[^>]+content=["']([^"']{20,500})["'][^>]+name=["']description["']/i,
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']{20,500})["']/i,
/<meta[^>]+content=["']([^"']{20,500})["'][^>]+property=["']og:description["']/i,
];
let matched = false;
for (const pat of metaPatterns) {
const m = resp.match(pat);
if (m && m[1]) {
description = m[1].replace(/&#10;|&#10003;|&amp;/g, " ").replace(/\s+/g, " ").trim().slice(0, 300);
matched = true;
break;
}
}
if (matched) break;
}
}
} catch (_e) {
// Return whatever we have
}
return res.end(JSON.stringify({ asn: rawAsn, description, wiki_url: wikiUrl }));
}
// Feature 28: Submarine Cable overlay (TeleGeography proxy)
// ── Submarine Cables map data ─────────────────────────────────
if (reqPath === '/api/submarine-cables') {
return proxyToApiServer(req, res, req.url);
}
// ── Global datacenter/IXP map (PeeringDB proxy) ───────────────
if (reqPath === '/api/global-infra') {
return proxyToApiServer(req, res, req.url);
}
// ── Changelog page ─────────────────────────────────────────
if (reqPath === '/changelog') {
try {
const md = fs.readFileSync('/opt/peercortex-app/CHANGELOG.md', 'utf8');
const lines = md.split('\n');
let html = '';
for (const line of lines) {
if (line.startsWith('## ')) {
html += `<h2 style="font-family:var(--serif);font-size:1.4rem;font-weight:800;margin:2rem 0 .5rem;border-top:2px solid var(--text);padding-top:1rem">${line.slice(3)}</h2>`;
} else if (line.startsWith('### ')) {
html += `<h3 style="font-family:var(--body);font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:1rem 0 .4rem">${line.slice(4)}</h3>`;
} else if (line.startsWith('- **')) {
const m = line.replace(/^- \*\*(.+?)\*\*(.*)$/, '<strong>$1</strong>$2');
html += `<p style="font-family:var(--body);font-size:.85rem;margin:.2rem 0;padding-left:1rem;border-left:2px solid var(--border)">· ${m}</p>`;
} else if (line.startsWith('- ')) {
html += `<p style="font-family:var(--body);font-size:.82rem;margin:.15rem 0;color:var(--muted);padding-left:1rem">· ${line.slice(2)}</p>`;
} else if (line.startsWith('# ')) {
html += `<h1 style="font-family:var(--serif);font-size:2rem;font-weight:900;margin-bottom:.25rem">${line.slice(2)}</h1>`;
}
}
const page = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>PeerCortex Changelog</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
:root{--bg:#F5F2EC;--text:#1C1917;--muted:#57534E;--border:#C9C3B6;--serif:'Playfair Display',Georgia,serif;--body:'Source Serif 4',Georgia,serif;--mono:'IBM Plex Mono',monospace;--purple:#B83A1B}
body{font-family:var(--body);background:var(--bg);color:var(--text);max-width:760px;margin:0 auto;padding:2rem}
a{color:var(--purple);text-decoration:none}
.back{font-family:var(--mono);font-size:.72rem;color:var(--muted);margin-bottom:2rem;display:block}
body.dark{--bg:#0f0f0f;--text:#e8e4dc;--muted:#a09890;--border:#333}
</style></head><body>
<a href="/" class="back">← peercortex.org</a>
${html}
<p style="margin-top:3rem;font-family:var(--mono);font-size:.6rem;color:var(--muted)">PeerCortex · v0.5.0 · Open Source · MIT</p>
</body></html>`;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.writeHead(200);
return res.end(page);
} catch(e) {
res.writeHead(500); return res.end('Changelog not available');
}
}
// ── BGP Community Decoder ────────────────────────────────────
if (reqPath === '/api/communities') {
return proxyToApiServer(req, res, req.url);
}
// ── IRR Audit ─────────────────────────────────────────────────
if (reqPath === '/api/irr-audit') {
return proxyToApiServer(req, res, req.url);
}
// ── AS-SET Expander ───────────────────────────────────────────
if (reqPath === '/api/asset-expand') {
return proxyToApiServer(req, res, req.url);
}
// ── Routing History (prefix table via RIPE Stat routing-history) ──
if (reqPath === '/api/rpki-history') {
return proxyToApiServer(req, res, req.url);
}
// ── AS-PATH Visualizer (RIPE Stat looking-glass) ────────────────
if (reqPath === '/api/aspath') {
return proxyToApiServer(req, res, req.url);
}
// ── Looking Glass (RIPE Stat) ─────────────────────────────────
if (reqPath === '/api/looking-glass') {
return proxyToApiServer(req, res, req.url);
}
// ── IXP Peering Matrix ────────────────────────────────────────
if (reqPath === '/api/ix-matrix') {
return proxyToApiServer(req, res, req.url);
}
// ── CORS preflight for all /api/webhooks + /api/hijacks ──────
if ((reqPath.startsWith('/api/webhooks') || reqPath.startsWith('/api/hijacks') || reqPath === '/api/hijack-subscribe') && req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.writeHead(204); return res.end();
}
// ── Webhook: Register ─────────────────────────────────────────
// POST /api/webhooks?asn=X body: { endpoint_url, timeout_ms?, max_retries? }
if (reqPath === '/api/webhooks' && req.method === 'POST') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
const params = new URL(req.url, 'http://localhost').searchParams;
const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn query param required'})); }
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const { endpoint_url, timeout_ms, max_retries } = JSON.parse(body || '{}');
if (!endpoint_url || !endpoint_url.startsWith('http')) {
res.writeHead(400); return res.end(JSON.stringify({error:'endpoint_url required (http/https)'}));
}
const subs = loadWebhookSubs();
const exists = subs.find(s => s.asn === asn && s.endpoint_url === endpoint_url);
if (exists) { res.writeHead(200); return res.end(JSON.stringify({id: exists.id, already_exists: true, secret_key: exists.secret})); }
const webhookToken = crypto.randomBytes(32).toString('hex');
const entry = {
id: crypto.randomBytes(8).toString('hex'),
asn, endpoint_url,
secret: webhookToken,
timeout_ms: timeout_ms || 10000,
max_retries: max_retries || 5,
active: true,
created_at: new Date().toISOString(),
last_triggered_at: null,
failure_count: 0
};
subs.push(entry);
saveWebhookSubs(subs);
// Also ensure this ASN is in hijack monitoring
const hijackSubs = loadHijackSubs();
if (!hijackSubs.find(s => s.asn === asn)) {
checkHijacksForAsn(asn).then(prefixes => {
// null (lookup failed) becomes [] here so this record's shape stays
// consistent; runHijackCheck retries the real baseline next cycle.
hijackSubs.push({ asn, prefixes: prefixes || [], subscribed: new Date().toISOString() });
saveHijackSubs(hijackSubs);
});
}
res.writeHead(201);
res.end(JSON.stringify({ id: entry.id, asn, endpoint_url, secret_key: secret, created_at: entry.created_at, note: 'Store secret_key safely — it signs all webhook payloads' }));
} catch(e) { res.writeHead(400); res.end(JSON.stringify({error: e.message})); }
});
return;
}
// ── Webhook: List ─────────────────────────────────────────────
// GET /api/webhooks?asn=X
if (reqPath === '/api/webhooks' && req.method === 'GET') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
const params = new URL(req.url, 'http://localhost').searchParams;
const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
const subs = loadWebhookSubs();
const result = (asn ? subs.filter(s => s.asn === asn) : subs)
.map(s => ({ id: s.id, asn: s.asn, endpoint_url: s.endpoint_url, active: s.active, failure_count: s.failure_count, last_triggered_at: s.last_triggered_at, created_at: s.created_at }));
res.writeHead(200);
return res.end(JSON.stringify({ webhooks: result, total: result.length }));
}
// ── Webhook: Delete ───────────────────────────────────────────
// DELETE /api/webhooks/:id
if (reqPath.startsWith('/api/webhooks/') && req.method === 'DELETE' && !reqPath.includes('/test')) {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
const id = reqPath.split('/')[3];
const subs = loadWebhookSubs();
const idx = subs.findIndex(s => s.id === id);
if (idx === -1) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); }
subs.splice(idx, 1);
saveWebhookSubs(subs);
res.writeHead(200);
return res.end(JSON.stringify({ deleted: true, id }));
}
// ── Webhook: Test ─────────────────────────────────────────────
// POST /api/webhooks/:id/test
if (reqPath.match(/^\/api\/webhooks\/[^/]+\/test$/) && req.method === 'POST') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
const id = reqPath.split('/')[3];
const subs = loadWebhookSubs();
const sub = subs.find(s => s.id === id);
if (!sub) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); }
const testPayload = { event: 'test', asn: sub.asn, message: 'PeerCortex webhook test — if you receive this, delivery works!', timestamp: new Date().toISOString() };
const start = Date.now();
deliverWebhook(sub, testPayload, 1).then(result => {
res.writeHead(result.ok ? 200 : 502);
res.end(JSON.stringify({ ok: result.ok, response_time_ms: Date.now() - start, status: result.status, error: result.error || null }));
});
return;
}
// ── Hijack Events: List ───────────────────────────────────────
// GET /api/hijacks?asn=X&limit=50&resolved=false
if (reqPath === '/api/hijacks' && req.method === 'GET') {
const params = new URL(req.url, 'http://localhost').searchParams;
const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
const limit = Math.min(parseInt(params.get('limit') || '50', 10), 200);
const resolvedFilter = params.get('resolved');
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
let events = loadHijackAlerts();
if (asn) events = events.filter(a => String(a.asn) === asn);
if (resolvedFilter === 'false') events = events.filter(a => !a.resolved);
if (resolvedFilter === 'true') events = events.filter(a => a.resolved);
const total = events.length;
events = events.slice(-limit).reverse();
res.writeHead(200);
return res.end(JSON.stringify({ total, events }));
}
// ── Hijack Events: Resolve ────────────────────────────────────
// POST /api/hijacks/:id/resolve body: { resolution_notes? }
if (reqPath.match(/^\/api\/hijacks\/[^/]+\/resolve$/) && req.method === 'POST') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
const id = reqPath.split('/')[3];
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
const alerts = loadHijackAlerts();
const alert = alerts.find(a => a.id === id);
if (!alert) { res.writeHead(404); return res.end(JSON.stringify({error:'event not found'})); }
alert.resolved = true;
alert.resolved_at = new Date().toISOString();
try { const { resolution_notes } = JSON.parse(body || '{}'); if (resolution_notes) alert.resolution_notes = resolution_notes; } catch(_){}
saveHijackAlerts(alerts);
res.writeHead(200);
res.end(JSON.stringify({ resolved: true, id, resolved_at: alert.resolved_at }));
});
return;
}
// ── Hijack Subscribe (legacy, kept for backwards compatibility) ─
if (reqPath === '/api/hijack-subscribe' && req.method === 'POST') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
let body = '';
req.on('data', c => body += c);
req.on('end', async () => {
try {
const { asn } = JSON.parse(body || '{}');
const asnNum = String(asn || '').replace(/[^0-9]/g,'');
if (!asnNum) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
const subs = loadHijackSubs();
const exists = subs.find(s => s.asn === asnNum);
if (!exists) {
const prefixes = await checkHijacksForAsn(asnNum);
// prefixes may be null if the lookup failed just now -- store an empty
// array so this record shape stays consistent; runHijackCheck's own
// "!sub.prefixes.length" check will retry establishing the real
// baseline on its next cycle rather than treating this as permanent.
subs.push({ asn: asnNum, prefixes: prefixes || [], subscribed: new Date().toISOString() });
saveHijackSubs(subs);
}
const sub = subs.find(s => s.asn === asnNum) || { prefixes: [] };
res.writeHead(200);
res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: (sub.prefixes || []).length }));
} catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); }
});
return;
}
// ── Hijack Alerts (legacy endpoint, kept for backwards compatibility) ─
if (reqPath.startsWith('/api/hijack-alerts')) {
const params = new URL(req.url, 'http://localhost').searchParams;
const asn = (params.get('asn') || '').replace(/[^0-9]/g,'');
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
const allAlerts = loadHijackAlerts();
const alerts = asn ? allAlerts.filter(a => String(a.asn) === asn) : allAlerts;
const subs = loadHijackSubs();
const sub = subs.find(s => s.asn === asn);
res.writeHead(200);
return res.end(JSON.stringify({ asn, alerts: alerts.slice(-50), monitoring: !!sub, prefix_count: sub ? sub.prefixes.length : 0 }));
}
// ── Hijack Alerts (legacy read) ───────────────────────────────
// src/routes/hijack-alerts.ts registers /api/webhooks + /api/hijacks under
// the same paths this file already serves above (file-backed, live).
// Deliberately NOT proxied here — that would shadow working data with an
// empty Postgres-backed implementation. Not a gap, just two feature
// branches that ended up naming their routes the same thing.
// ── Changelog JSON API ────────────────────────────────────────
if (reqPath === '/changelog-data') {
return proxyToApiServer(req, res, req.url);
}
// ── bio-rd RIB routes ─────────────────────────────────────────
if (reqPath.startsWith('/api/rib/')) {
return proxyToApiServer(req, res, req.url);
}
// ── Prefix Changes ──────────────────────────────────────────────
if (reqPath === '/api/prefix-changes') {
return proxyToApiServer(req, res, req.url);
}
// ============================================================
// FEATURE 2: PDF Export
// GET /api/export/pdf?asn=X&format=report|executive|technical
// GET /api/export/pdf/compare?asn1=X&asn2=Y
// ============================================================
// Helper: fetch JSON from internal endpoint
async function fetchInternal(path) {
return new Promise((resolve, reject) => {
const opts = { hostname: '127.0.0.1', port: PORT, path, method: 'GET' };
const req2 = http.request(opts, res2 => {
let body = '';
res2.on('data', c => body += c);
res2.on('end', () => {
try { resolve(JSON.parse(body)); }
catch(e) { reject(new Error('JSON parse failed: ' + body.slice(0, 200))); }
});
});
req2.on('error', reject);
req2.setTimeout(30000, () => { req2.destroy(); reject(new Error('Internal request timeout')); });
req2.end();
});
}
if (reqPath === '/api/export/pdf' && req.method === 'GET') {
const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, '');
const format = ['executive', 'technical', 'report'].includes(url.searchParams.get('format'))
? url.searchParams.get('format') : 'report';
if (!rawAsn) {
res.writeHead(400, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: 'Missing asn parameter' }));
}
const cacheKey = `pdf:${rawAsn}:${format}`;
const cached = pdfCacheGet(cacheKey);
if (cached) {
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
return res.end(cached);
}
try {
const [validateData, aspaData] = await Promise.all([
fetchInternal(`/api/validate?asn=${rawAsn}`),
fetchInternal(`/api/aspa?asn=${rawAsn}`).catch(() => null),
]);
if (validateData.error) {
res.writeHead(400, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: validateData.error }));
}
const html = buildPdfHtml(validateData, aspaData, format);
const pdfBuf = await renderHtmlToPdf(html);
pdfCacheSet(cacheKey, pdfBuf);
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'Content-Length': pdfBuf.length });
return res.end(pdfBuf);
} catch (err) {
console.error('[PDF] Error:', err.message);
res.writeHead(503, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
}
}
if (reqPath === '/api/export/pdf/compare' && req.method === 'GET') {
const asn1 = (url.searchParams.get('asn1') || '').replace(/[^0-9]/g, '');
const asn2 = (url.searchParams.get('asn2') || '').replace(/[^0-9]/g, '');
if (!asn1 || !asn2) {
res.writeHead(400, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: 'Missing asn1 or asn2 parameter' }));
}
if (asn1 === asn2) {
res.writeHead(400, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: 'asn1 and asn2 must be different' }));
}
const cacheKey = `pdf:compare:${Math.min(+asn1,+asn2)}:${Math.max(+asn1,+asn2)}`;
const cached = pdfCacheGet(cacheKey);
if (cached) {
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
return res.end(cached);
}
try {
const [v1, a1, l1, v2, a2, l2] = await Promise.all([
fetchInternal(`/api/validate?asn=${asn1}`),
fetchInternal(`/api/aspa?asn=${asn1}`).catch(() => null),
fetchInternal(`/api/lookup?asn=${asn1}`).catch(() => null),
fetchInternal(`/api/validate?asn=${asn2}`),
fetchInternal(`/api/aspa?asn=${asn2}`).catch(() => null),
fetchInternal(`/api/lookup?asn=${asn2}`).catch(() => null),
]);
if (v1.error || v2.error) {
res.writeHead(400, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: v1.error || v2.error }));
}
const html = buildComparisonPdfHtml(v1, a1, l1, v2, a2, l2);
const pdfBuf = await renderHtmlToPdf(html);
pdfCacheSet(cacheKey, pdfBuf);
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'Content-Length': pdfBuf.length });
return res.end(pdfBuf);
} catch (err) {
console.error('[PDF] Error:', err.message);
res.writeHead(503, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
}
}
// CORS preflight for PDF export (already handled globally above, belt-and-suspenders)
if (reqPath.startsWith('/api/export/') && req.method === 'OPTIONS') {
res.writeHead(204);
return res.end();
}
// ============================================================
// FEATURE 3: ASPA Adoption Tracker — API + Dashboard
// GET /aspa-adoption → dashboard HTML
// GET /api/aspa-adoption-stats?period=7d|30d|1y → JSON trend
// GET /api/aspa-adoption-stats/export?format=csv|json&period=30d
// ============================================================
if (reqPath === '/aspa-adoption') {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.writeHead(200);
return res.end(buildAspaAdoptionDashboard());
}
if (reqPath === '/api/aspa-adoption-stats' && req.method === 'GET') {
const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
? url.searchParams.get('period') : '30d';
const trend = getAdoptionTrend(period);
const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2];
// Linear regression forecast
function forecast(data, daysAhead) {
if (data.length < 2) return null;
const n = data.length;
const xs = data.map((_, i) => i);
const ys = data.map(d => d.coverage_pct_atlas);
const xMean = xs.reduce((a,b)=>a+b,0)/n;
const yMean = ys.reduce((a,b)=>a+b,0)/n;
const num = xs.reduce((s,x,i)=>s+(x-xMean)*(ys[i]-yMean),0);
const den = xs.reduce((s,x)=>s+(x-xMean)**2,0);
if (den===0) return yMean;
const slope = num/den;
return Math.max(0, Math.min(100, Math.round((yMean + slope*(n-1+daysAhead))*100)/100));
}
const trend30 = getAdoptionTrend('30d');
const result = {
period,
current: {
date: latest?.date,
aspa_objects: latest?.aspa_objects ?? rpkiAspaMap.size,
roa_count: latest?.roa_count,
atlas_asns_total: latest?.atlas_asns_total ?? 0,
atlas_asns_with_aspa: latest?.atlas_asns_with_aspa ?? 0,
coverage_pct_atlas: latest?.coverage_pct_atlas ?? 0,
delta_from_previous: prev ? (latest?.aspa_objects ?? 0) - prev.aspa_objects : 0,
},
trend: trend.map(s => ({
date: s.date,
aspa_objects: s.aspa_objects,
coverage_pct_atlas: s.coverage_pct_atlas,
atlas_asns_total: s.atlas_asns_total,
})),
forecast: {
predicted_90d: forecast(trend30, 90),
confidence: trend30.length >= 10 ? 0.75 : 0.5,
method: 'linear_regression',
based_on_samples: trend30.length,
},
meta: {
total_snapshots: aspaAdoptionDailyHistory.length,
last_updated: latest?.date ?? null,
denominator: 'atlas_visible_asns',
source: 'rpki_cloudflare_feed',
}
};
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
return res.end(JSON.stringify(result, null, 2));
}
if (reqPath === '/api/aspa-adoption-stats/export' && req.method === 'GET') {
const fmt = url.searchParams.get('format') === 'csv' ? 'csv' : 'json';
const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
? url.searchParams.get('period') : '30d';
const data = getAdoptionTrend(period);
if (fmt === 'csv') {
const header = 'date,aspa_objects,roa_count,atlas_asns_total,atlas_asns_with_aspa,coverage_pct_atlas,aspa_delta\n';
const rows = data.map(s =>
`${s.date},${s.aspa_objects},${s.roa_count},${s.atlas_asns_total},${s.atlas_asns_with_aspa},${s.coverage_pct_atlas},${s.aspa_delta??0}`
).join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.csv"`);
res.writeHead(200);
return res.end(header + rows);
}
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.json"`);
res.writeHead(200);
return res.end(JSON.stringify({ period, count: data.length, data }, null, 2));
}
// GET /api/ipv6-adoption-stats?refresh=1
if (reqPath === '/api/ipv6-adoption-stats' && req.method === 'GET') {
const force = url.searchParams.get('refresh') === '1';
try {
const data = await fetchIpv6AdoptionStats(force);
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
return res.end(JSON.stringify(data, null, 2));
} catch (err) {
res.writeHead(503);
return res.end(JSON.stringify({ error: 'IPv6 stats unavailable', message: err.message }));
}
}
// 404
res.writeHead(404);
res.end(
JSON.stringify({
error: "Not found. Endpoints: /api/health, /api/validate?asn=X, /api/lookup?asn=X, /api/aspa?asn=X, /api/aspa/verify?asn=X, /api/bgproutes?asn=X, /api/compare?asn1=X&asn2=Y, /api/peers/find?ix=NAME, /api/prefix/detail?prefix=X, /api/ix/detail?ix_id=X, /api/export/pdf?asn=X&format=report|executive|technical, /api/export/pdf/compare?asn1=X&asn2=Y, /api/aspa-adoption-stats?period=30d, /api/ipv6-adoption-stats",
})
);
});
const { atlasState, fetchAllAtlasProbes } = require("./server/atlas-probes");
// ============================================================
// PeeringDB Org → Country Cache (for Lia's Paradise)
// ============================================================
let pdbOrgCountryMap = new Map(); // org_id → { country, name }
function fetchPdbOrgCountries() {
var cacheFile = require("path").join(__dirname, ".pdb-org-cache.json");
var fs = require("fs");
// Try disk cache first (valid for 24h)
try {
var stat = fs.statSync(cacheFile);
var ageHours = (Date.now() - stat.mtimeMs) / 3600000;
if (ageHours < 24) {
var cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
pdbOrgCountryMap = new Map(Object.entries(cached));
console.log("[PDB-ORG] Loaded " + pdbOrgCountryMap.size + " orgs from disk cache (" + Math.round(ageHours) + "h old)");
return Promise.resolve();
}
} catch (_) { /* no cache or invalid */ }
console.log("[PDB-ORG] Fetching PeeringDB org countries (fresh)...");
return new Promise(function(resolve) {
var chunks = [];
var req = require("https").get("https://www.peeringdb.com/api/org?status=ok&depth=0", {
headers: {
"User-Agent": UA,
"Authorization": PEERINGDB_API_KEY ? "Api-Key " + PEERINGDB_API_KEY : undefined,
},
timeout: 120000,
}, function(res) {
if (res.statusCode !== 200) {
console.error("[PDB-ORG] HTTP " + res.statusCode + " — using stale cache or empty");
resolve();
return;
}
res.on("data", function(chunk) { chunks.push(chunk); });
res.on("end", function() {
try {
var body = Buffer.concat(chunks).toString("utf8");
var data = JSON.parse(body);
if (data && data.data) {
pdbOrgCountryMap = new Map();
var cacheObj = {};
data.data.forEach(function(o) {
if (o.id && o.country) {
pdbOrgCountryMap.set(o.id, { country: o.country, name: o.name || "" });
cacheObj[o.id] = { country: o.country, name: o.name || "" };
}
});
// Save to disk cache
try { fs.writeFileSync(cacheFile, JSON.stringify(cacheObj)); } catch (_) {}
console.log("[PDB-ORG] Loaded " + pdbOrgCountryMap.size + " org→country mappings (cached to disk)");
}
} catch (e) {
console.error("[PDB-ORG] Parse error:", e.message);
}
resolve();
});
});
req.on("error", function(e) {
console.error("[PDB-ORG] Fetch error:", e.message);
resolve();
});
req.on("timeout", function() {
console.error("[PDB-ORG] Timeout after 120s");
req.destroy();
resolve();
});
});
}
const PORT = process.env.PORT || 3101;
// ============================================================
// Startup Sequence — load disk caches first, then fetch fresh data
// ============================================================
// Phase 0: Load disk caches for fast restart (instant)
roaStore.loadFromDisk("/opt/peercortex-app/.roa-cache.json");
pdbSourceCache.loadFromDisk("/opt/peercortex-app/.pdb-source-cache.json");
loadRipeStatCacheFromDisk("/opt/peercortex-app/.ripe-stat-cache.json");
// Phase 1: Fetch fresh RPKI feed (ASPA + ROA) + Atlas probes + PDB org countries + MANRS participants
ensureManrsCache(); // fire-and-forget, 24h cache
Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => {
// Re-record ASPA adoption snapshot now that Atlas data is fully loaded
recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count, rpkiAspaMap);
server.listen(PORT, "0.0.0.0", () => {
console.log("PeerCortex v0.6.5 running on http://0.0.0.0:" + PORT);
console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured"));
console.log("PeeringDB API key: " + (PEERINGDB_API_KEY ? "configured" : "NOT configured"));
console.log("RPKI ASPA objects: " + rpkiAspaMap.size);
console.log("ROA store: " + roaStore.count + " entries (" + (roaStore.ready ? "ready" : "loading...") + ")");
console.log("PDB source cache: net=" + pdbSourceCache.net.size + " ix=" + pdbSourceCache.netixlan.size + " fac=" + pdbSourceCache.netfac.size);
console.log("RIPE Stat cache: " + ripeStatCache.size + " entries");
});
});
// ============================================================
// bio-rd RIB WebSocket — live route streaming on /ws/rib
// ============================================================
let WebSocketServer = null;
try { WebSocketServer = require('ws').Server; } catch(_e) {}
if (WebSocketServer) {
const ribWss = new WebSocketServer({ server, path: '/ws/rib' });
ribWss.on('connection', function(ws) {
let cancelStream = null;
ws.on('message', function(raw) {
try {
const msg = JSON.parse(raw);
if (msg.type === 'rib-subscribe') {
if (cancelStream) { cancelStream(); cancelStream = null; }
if (!risClient) {
ws.send(JSON.stringify({ type: 'error', error: 'bio-rd RIS not configured' }));
return;
}
const router = msg.router || 'default';
cancelStream = risClient.observeRib(
router,
'default',
function(update) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(update)); },
function(err) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: err.message })); }
);
}
} catch(e) {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: e.message }));
}
});
ws.on('close', function() {
if (cancelStream) { cancelStream(); cancelStream = null; }
});
});
console.log('[bio-rd] RIB WebSocket server listening on /ws/rib');
} else {
console.log('[bio-rd] WebSocket server skipped (ws package not installed)');
}
// ============================================================
// Refresh timers — jittered to avoid thundering herd
// ============================================================
// RPKI feed (ASPA + ROA): every 4h ± 5min jitter
setInterval(() => {
fetchRpkiAspaFeed();
}, 4 * 60 * 60 * 1000 + Math.floor(Math.random() * 10 * 60 * 1000) - 5 * 60 * 1000);
// Atlas probe cache: every 12h ± 10min jitter
setInterval(function() {
fetchAllAtlasProbes();
}, 12 * 60 * 60 * 1000 + Math.floor(Math.random() * 20 * 60 * 1000) - 10 * 60 * 1000);
// Disk cache persistence: every 30 minutes
setInterval(function() {
pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json");
saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json");
}, 30 * 60 * 1000);
// Save caches on graceful shutdown
process.on("SIGTERM", function() {
console.log("[SHUTDOWN] Saving caches to disk...");
pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json");
saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json");
roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
process.exit(0);
});
process.on("SIGINT", function() {
console.log("[SHUTDOWN] Saving caches to disk...");
pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json");
saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json");
roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
process.exit(0);
});