PeerCortex/server.js
Rene Fichtmueller b31a911719 feat: BGP hijack alerts, PDF export (3 formats), ASPA + IPv6 adoption tracker
- BGP Hijack Alerting: HMAC-SHA256 signed webhooks, exponential backoff,
  6h dedup, /api/webhooks + /api/hijacks endpoints
- PDF Export (3 formats): Executive 3p / Technical 8p (full tables, check
  scores) / Full Report 10p (ASPA analysis + data provenance); 5min cache
- Comparison PDF: side-by-side ASN diff with shared IXPs + facilities
- ASPA Adoption Tracker: daily snapshots, trend history, linear regression
  forecast, /api/aspa-adoption-stats
- IPv6 Adoption per RIR: all 5 RIR delegation files, 24h cache,
  /api/ipv6-adoption-stats
- UI: two-tab adoption overlay (ASPA + IPv6 charts), PDF download buttons
  in action bar and comparison panel
- Bump version v0.6.1 → v0.6.5, add runtime cache files to .gitignore
2026-04-30 01:50:04 +02:00

7027 lines
318 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");
// ── 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
const envPath = "/opt/peercortex-app/.env";
try {
const envContent = fs.readFileSync(envPath, "utf8");
envContent.split("\n").forEach((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return;
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.substring(0, eqIdx).trim();
const val = trimmed.substring(eqIdx + 1).trim();
if (!process.env[key]) process.env[key] = val;
}
});
} catch (_e) {
console.warn("Warning: Could not read .env file at", envPath);
}
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 = process.env.PEERINGDB_API_KEY || "";
const PEERINGDB_API_URL = process.env.PEERINGDB_API_URL || "https://www.peeringdb.com/api";
// ── Local PeeringDB SQLite (peeringdb-py sync, refreshed daily by cron) ──────
const PEERINGDB_LOCAL_PATH = process.env.PEERINGDB_LOCAL_PATH || "/opt/peeringdb-data/peeringdb.sqlite3";
let _pdbLocal = null;
function getPdbLocal() {
if (_pdbLocal) return _pdbLocal;
try {
const BetterSqlite3 = require("better-sqlite3");
if (!fs.existsSync(PEERINGDB_LOCAL_PATH)) return null;
_pdbLocal = new BetterSqlite3(PEERINGDB_LOCAL_PATH, { readonly: true, fileMustExist: true });
console.log("[PeeringDB-local] SQLite opened:", PEERINGDB_LOCAL_PATH);
return _pdbLocal;
} catch (e) {
console.warn("[PeeringDB-local] Could not open SQLite:", e.message);
return null;
}
}
// Map API path → SQLite result in { data: [...] } format, emulating the live PDB REST API.
function queryPeeringDBLocal(path) {
const db = getPdbLocal();
if (!db) return null;
try {
// /net?asn=X
const netAsnMatch = path.match(/^\/net\?asn=(\d+)/);
if (netAsnMatch) {
const rows = db.prepare(
"SELECT n.*, o.name AS org_name FROM peeringdb_network n " +
"LEFT JOIN peeringdb_organization o ON n.org_id = o.id " +
"WHERE n.asn = ? AND n.status = 'ok'"
).all(parseInt(netAsnMatch[1]));
return { data: rows };
}
// /net?status=ok&depth=0 (coverage endpoint — all networks)
if (path === "/net?status=ok&depth=0" || path.startsWith("/net?status=ok")) {
const rows = db.prepare(
"SELECT id, asn, name, aka, website, info_prefixes4, info_prefixes6, " +
"info_type, info_traffic, info_unicast, info_ipv6, policy_general, org_id " +
"FROM peeringdb_network WHERE status = 'ok' ORDER BY asn"
).all();
return { data: rows };
}
// /netixlan?net_id=X&limit=... or /netixlan?asn=X&limit=...
const netixlanNetId = path.match(/\/netixlan\?net_id=(\d+)/);
if (netixlanNetId) {
const rows = db.prepare(
"SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " +
"ni.operational, ni.bfd_support, il.id AS ixlan_id, " +
"ix.id AS ix_id, ix.name, ix.city, ix.country " +
"FROM peeringdb_network_ixlan ni " +
"LEFT JOIN peeringdb_ixlan il ON ni.ixlan_id = il.id " +
"LEFT JOIN peeringdb_ix ix ON il.ix_id = ix.id " +
"WHERE ni.net_id = ? AND ni.status = 'ok'"
).all(parseInt(netixlanNetId[1]));
return { data: rows };
}
const netixlanAsn = path.match(/\/netixlan\?asn=(\d+)/);
if (netixlanAsn) {
const rows = db.prepare(
"SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " +
"ni.operational, ni.bfd_support, il.id AS ixlan_id, " +
"ix.id AS ix_id, ix.name, ix.city, ix.country " +
"FROM peeringdb_network_ixlan ni " +
"LEFT JOIN peeringdb_ixlan il ON ni.ixlan_id = il.id " +
"LEFT JOIN peeringdb_ix ix ON il.ix_id = ix.id " +
"WHERE ni.asn = ? AND ni.status = 'ok'"
).all(parseInt(netixlanAsn[1]));
return { data: rows };
}
// /netixlan?ixlan_id=X
const netixlanIxlanId = path.match(/\/netixlan\?ixlan_id=(\d+)/);
if (netixlanIxlanId) {
const rows = db.prepare(
"SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " +
"n.name AS net_name " +
"FROM peeringdb_network_ixlan ni " +
"LEFT JOIN peeringdb_network n ON ni.net_id = n.id " +
"WHERE ni.ixlan_id = ? AND ni.status = 'ok'"
).all(parseInt(netixlanIxlanId[1]));
return { data: rows };
}
// /netfac?net_id=X
const netfacNetId = path.match(/\/netfac\?net_id=(\d+)/);
if (netfacNetId) {
const rows = db.prepare(
"SELECT nf.id, nf.net_id, f.id AS fac_id, f.name, f.city, f.state, " +
"f.country, f.latitude, f.longitude, f.website " +
"FROM peeringdb_network_facility nf " +
"LEFT JOIN peeringdb_facility f ON nf.fac_id = f.id " +
"WHERE nf.net_id = ? AND nf.status = 'ok'"
).all(parseInt(netfacNetId[1]));
return { data: rows };
}
// /fac?id__in=X,Y,Z&fields=...
const facIdIn = path.match(/\/fac\?id__in=([\d,]+)/);
if (facIdIn) {
const ids = facIdIn[1].split(",").map(Number).filter(Boolean);
if (ids.length === 0) return { data: [] };
const placeholders = ids.map(() => "?").join(",");
const rows = db.prepare(
"SELECT id, name, city, country, latitude, longitude, website " +
"FROM peeringdb_facility WHERE id IN (" + placeholders + ") AND status = 'ok'"
).all(...ids);
return { data: rows };
}
// /ixfac?ix_id__in=X,Y,Z
const ixfacIxIdIn = path.match(/\/ixfac\?ix_id__in=([\d,]+)/);
if (ixfacIxIdIn) {
const ids = ixfacIxIdIn[1].split(",").map(Number).filter(Boolean);
if (ids.length === 0) return { data: [] };
const placeholders = ids.map(() => "?").join(",");
const rows = db.prepare(
"SELECT ixf.id, ixf.ix_id, ixf.fac_id, f.latitude, f.longitude, f.city, f.country " +
"FROM peeringdb_ix_facility ixf " +
"LEFT JOIN peeringdb_facility f ON ixf.fac_id = f.id " +
"WHERE ixf.ix_id IN (" + placeholders + ") AND ixf.status = 'ok'"
).all(...ids);
return { data: rows };
}
// /ix?name__contains=X
const ixNameContains = path.match(/\/ix\?name__contains=([^&]+)/);
if (ixNameContains) {
const term = "%" + decodeURIComponent(ixNameContains[1]) + "%";
const rows = db.prepare(
"SELECT id, name, name_long, city, country, website, region_continent " +
"FROM peeringdb_ix WHERE (name LIKE ? OR name_long LIKE ?) AND status = 'ok' LIMIT 20"
).all(term, term);
return { data: rows };
}
// /ixlan?ix_id=X
const ixlanIxId = path.match(/\/ixlan\?ix_id=(\d+)/);
if (ixlanIxId) {
const rows = db.prepare(
"SELECT id, ix_id, name, rs_asn, arp_sponge, mtu FROM peeringdb_ixlan " +
"WHERE ix_id = ? AND status = 'ok'"
).all(parseInt(ixlanIxId[1]));
return { data: rows };
}
// /net/X (single network by PDB id)
const netById = path.match(/^\/net\/(\d+)$/);
if (netById) {
const row = db.prepare(
"SELECT n.*, o.name AS org_name FROM peeringdb_network n " +
"LEFT JOIN peeringdb_organization o ON n.org_id = o.id " +
"WHERE n.id = ? AND n.status = 'ok'"
).get(parseInt(netById[1]));
return row ? { data: [row] } : { data: [] };
}
return null; // path not handled locally — fall through to live API
} catch (e) {
console.warn("[PeeringDB-local] Query error for", path, ":", e.message);
return null;
}
}
const FEEDBACK_TOKEN = process.env.FEEDBACK_TOKEN || "changeme-set-in-env";
const FEEDBACK_FILE = "/opt/peercortex-app/feedback.json";
const VISITORS_FILE = "/opt/peercortex-app/visitors.json";
// ── SMTP / Email ──────────────────────────────────────────────
const SMTP_HOST = 'mail.fichtmueller.org';
const SMTP_PORT = 587;
const SMTP_USER = process.env.SMTP_USER;
const SMTP_PASS = process.env.SMTP_PASS;
const MAIL_TO = 'peercortex@context-x.org';
const MAIL_FROM = 'PeerCortex Feedback <rene@fichtmueller.org>';
function sendFeedbackMail(entry) {
return new Promise(function(resolve, reject) {
var tls = require('tls');
var net = require('net');
var b64 = function(s) { return Buffer.from(s).toString('base64'); };
var CRLF = '\r\n';
var body = 'Category : ' + entry.category + CRLF +
'Name : ' + entry.name + CRLF +
'ASN : ' + (entry.asn || '-') + CRLF +
'Time : ' + entry.timestamp + CRLF + CRLF +
entry.message + CRLF + CRLF + '-' + CRLF + 'PeerCortex Feedback';
var subj = '[PeerCortex Feedback] ' + entry.category + (entry.asn ? ' - AS' + entry.asn : '');
var msg = 'From: ' + MAIL_FROM + CRLF +
'To: ' + MAIL_TO + CRLF +
'Subject: ' + subj + CRLF +
'MIME-Version: 1.0' + CRLF +
'Content-Type: text/plain; charset=UTF-8' + CRLF + CRLF +
body;
var socket = net.connect(SMTP_PORT, SMTP_HOST);
var tlsSocket = null;
var buf = '';
var step = 0;
var done = false;
function send(line) {
var s = tlsSocket || socket;
s.write(line + CRLF);
}
function onData(data) {
buf += data.toString();
var lines = buf.split(CRLF);
buf = lines.pop();
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
var code = parseInt(line.slice(0, 3));
if (isNaN(code) || line[3] === '-') continue;
if (step === 0 && code === 220) { send('EHLO peercortex.org'); step = 1; }
else if (step === 1 && code === 250) { send('STARTTLS'); step = 2; }
else if (step === 2 && code === 220) {
tlsSocket = tls.connect({ socket: socket, servername: SMTP_HOST, rejectUnauthorized: false }, function() {
tlsSocket.on('data', onData);
send('EHLO peercortex.org');
step = 3;
});
tlsSocket.on('error', function(e) { if (!done) { done = true; reject(e); } });
}
else if (step === 3 && code === 250) { send('AUTH LOGIN'); step = 4; }
else if (step === 4 && code === 334) { send(b64(SMTP_USER)); step = 5; }
else if (step === 5 && code === 334) { send(b64(SMTP_PASS)); step = 6; }
else if (step === 6 && code === 235) { send('MAIL FROM:<' + SMTP_USER + '>'); step = 7; }
else if (step === 7 && code === 250) { send('RCPT TO:<' + MAIL_TO + '>'); step = 8; }
else if (step === 8 && code === 250) { send('DATA'); step = 9; }
else if (step === 9 && code === 354) { send(msg + CRLF + '.'); step = 10; }
else if (step === 10 && code === 250) { send('QUIT'); if (!done) { done = true; resolve(); } }
else if (code >= 400) { if (!done) { done = true; reject(new Error('SMTP ' + code + ': ' + line)); } }
}
}
socket.on('data', onData);
socket.on('error', function(e) { if (!done) { done = true; reject(e); } });
setTimeout(function() { if (!done) { done = true; reject(new Error('SMTP timeout')); } }, 15000);
});
}
// ── SMTP / Email ──────────────────────────────────────────────
function loadVisitors() {
try { return JSON.parse(fs.readFileSync(VISITORS_FILE, "utf8")); } catch (_) { return { hashes: [] }; }
}
function trackVisitor(req) {
const ip = (req.headers["x-forwarded-for"] || "").split(",")[0].trim() || (req.socket && req.socket.remoteAddress) || "";
const hash = crypto.createHash("sha256").update(ip + "peercortex-salt-2026").digest("hex");
const data = loadVisitors();
if (!data.hashes.includes(hash)) {
data.hashes.push(hash);
try { fs.writeFileSync(VISITORS_FILE, JSON.stringify(data)); } catch (_) {}
}
return data.hashes.length;
}
// ═══════════════════════════════════════════════════════════════
// PEERCORTEX v0.6.1 — New Features
// ═══════════════════════════════════════════════════════════════
// ── BGP Community Database ─────────────────────────────────────
const BGP_COMMUNITY_DB = {
'65535:666': { name:'BLACKHOLE', desc:'RFC 7999 — Null-route this prefix', type:'rfc' },
'65535:65281':{ name:'NO_EXPORT', desc:'RFC 1997 — Do not export to EBGP peers', type:'rfc' },
'65535:65282':{ name:'NO_ADVERTISE', desc:'RFC 1997 — Do not advertise to any peer', type:'rfc' },
'65535:65283':{ name:'NO_EXPORT_SUBCONFED', desc:'RFC 1997 — No export to sub-AS', type:'rfc' },
// Lumen/CenturyLink 3356
'3356:2': { name:'Lumen Peer', desc:'Lumen — Learned from settlement-free peer', type:'carrier', asn:3356 },
'3356:3': { name:'Lumen Customer', desc:'Lumen — Learned from customer', type:'carrier', asn:3356 },
'3356:100':{ name:'Lumen Blackhole', desc:'Lumen — RTBH trigger', type:'carrier', asn:3356 },
// NTT 2914
'2914:420':{ name:'NTT Peer', desc:'NTT — Settlement-free peer route', type:'carrier', asn:2914 },
'2914:421':{ name:'NTT Customer', desc:'NTT — Downstream customer route', type:'carrier', asn:2914 },
'2914:666':{ name:'NTT Blackhole', desc:'NTT — RTBH trigger', type:'carrier', asn:2914 },
// Cogent 174
'174:21000':{ name:'Cogent Peer', desc:'Cogent — Learned from peer', type:'carrier', asn:174 },
'174:22000':{ name:'Cogent Customer', desc:'Cogent — Learned from customer', type:'carrier', asn:174 },
'174:666': { name:'Cogent Blackhole', desc:'Cogent — RTBH trigger', type:'carrier', asn:174 },
// HE 6939
'6939:7000':{ name:'HE RTBH', desc:'Hurricane Electric — Remotely triggered blackhole', type:'carrier', asn:6939 },
// Telia 1299
'1299:35000':{ name:'Telia RTBH', desc:'Telia — Remotely triggered blackhole', type:'carrier', asn:1299 },
'1299:3000': { name:'Telia Peer', desc:'Telia — Learned from peer', type:'carrier', asn:1299 },
// DTAG 3320
'3320:1278':{ name:'DTAG Peer', desc:'Deutsche Telekom — Peering route', type:'carrier', asn:3320 },
'3320:2001':{ name:'DTAG Customer', desc:'Deutsche Telekom — Customer route', type:'carrier', asn:3320 },
'3320:9900':{ name:'DTAG Blackhole', desc:'Deutsche Telekom — RTBH trigger', type:'carrier', asn:3320 },
// Cloudflare 13335
'13335:10000':{ name:'CF Customer', desc:'Cloudflare — Customer route', type:'carrier', asn:13335 },
'13335:10010':{ name:'CF Peering', desc:'Cloudflare — Learned via peering', type:'carrier', asn:13335 },
'13335:20050':{ name:'CF Blackhole', desc:'Cloudflare — RTBH trigger', type:'carrier', asn:13335 },
// Zayo 6461
'6461:9000':{ name:'Zayo Blackhole', desc:'Zayo — RTBH trigger', type:'carrier', asn:6461 },
// DE-CIX 6695
'6695:1000':{ name:'DE-CIX RS', desc:'DE-CIX Frankfurt — Route server export', type:'ixp', asn:6695 },
'6695:1001':{ name:'DE-CIX RS peer', desc:'DE-CIX — Received from route server peer', type:'ixp', asn:6695 },
// AMS-IX 1200
'1200:100': { name:'AMS-IX RS', desc:'AMS-IX — Route server export', type:'ixp', asn:1200 },
// LINX 5459
'5459:1001':{ name:'LINX RS', desc:'LINX — Route server export', type:'ixp', asn:5459 },
// Seabone/TI 6762
'6762:30': { name:'Seabone Customer', desc:'Telecom Italia Seabone — Customer route', type:'carrier', asn:6762 },
// Turkcell 9121
'9121:666': { name:'Turkcell BH', desc:'Turkcell — RTBH trigger', type:'carrier', asn:9121 },
};
function decodeCommunities(communityList) {
if (!Array.isArray(communityList)) return [];
return communityList.map(c => {
const key = Array.isArray(c) ? c.join(':') : String(c);
const known = BGP_COMMUNITY_DB[key];
return { raw: key, known: known || null };
});
}
// ── Hijack Monitoring ──────────────────────────────────────────
// ============================================================
// FEATURE 1: BGP Hijack Alerting + Webhooks
// ============================================================
const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : __dirname);
const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json';
const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json';
const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.json';
const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json';
function loadHijackSubs() { try { return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE,'utf8')); } catch(_){ return []; } }
function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALERTS_FILE,'utf8')); } catch(_){ return []; } }
function loadWebhookSubs() { try { return JSON.parse(fs.readFileSync(WEBHOOK_SUBS_FILE,'utf8')); } catch(_){ return []; } }
function saveWebhookSubs(s) { try { fs.writeFileSync(WEBHOOK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} }
function saveHijackAlerts(a) { try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(a.slice(-1000), null, 2)); } catch(_){} }
function saveHijackSubs(s) { try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} }
// Generate HMAC-SHA256 signature for webhook payloads
function webhookSignature(secret, payload) {
return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
}
// Classify hijack severity
function classifyHijackSeverity(unexpected, missing, rpkiInvalid) {
if (rpkiInvalid > 0 || unexpected.length >= 5) return 'CRITICAL';
if (unexpected.length >= 2 || missing.length >= 3) return 'HIGH';
if (unexpected.length >= 1 || missing.length >= 1) return 'MEDIUM';
return 'LOW';
}
// Deliver webhook with exponential backoff (max 5 retries)
async function deliverWebhook(sub, payload, attempt) {
attempt = attempt || 1;
const payloadStr = JSON.stringify(payload);
const sig = webhookSignature(sub.secret, payloadStr);
return new Promise((resolve) => {
try {
const parsed = new URL(sub.endpoint_url);
const lib = parsed.protocol === 'https:' ? https : http;
const options = {
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payloadStr),
'X-PeerCortex-Signature': sig,
'X-PeerCortex-Event': 'hijack_detected',
'User-Agent': 'PeerCortex-Webhook/1.0'
},
timeout: 10000
};
const req = lib.request(options, (res) => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ ok: true, status: res.statusCode });
} else if (attempt < 5) {
const delay = Math.pow(2, attempt) * 1000;
setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay);
} else {
resolve({ ok: false, status: res.statusCode, error: 'max_retries' });
}
});
});
req.on('error', () => {
if (attempt < 5) {
const delay = Math.pow(2, attempt) * 1000;
setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay);
} else {
resolve({ ok: false, error: 'connection_failed' });
}
});
req.on('timeout', () => { req.destroy(); });
req.write(payloadStr);
req.end();
} catch(e) {
resolve({ ok: false, error: e.message });
}
});
}
// Fire webhooks for all subscribers of an ASN when hijack detected
async function notifyWebhooks(asn, alert) {
const subs = loadWebhookSubs().filter(s => String(s.asn) === String(asn) && s.active !== false);
for (const sub of subs) {
const payload = {
event: 'hijack_detected',
asn: asn,
alert: alert,
timestamp: new Date().toISOString(),
source: 'PeerCortex BGP Monitor'
};
deliverWebhook(sub, payload, 1).then(result => {
if (!result.ok) console.warn(`[WEBHOOK] Delivery failed for AS${asn}${sub.endpoint_url}: ${result.error || result.status}`);
});
}
}
async function checkHijacksForAsn(asn) {
try {
const url = `https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}`;
const data = await fetchJSON(url, { timeout: 8000 });
const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix);
return prefixes;
} catch (_) { return []; }
}
async function runHijackCheck() {
const subs = loadHijackSubs();
if (!subs.length) return;
const alerts = loadHijackAlerts();
let changed = false;
for (const sub of subs) {
const current = await checkHijacksForAsn(sub.asn);
const baseline = new Set(sub.prefixes || []);
const unexpected = current.filter(p => baseline.size > 0 && !baseline.has(p));
const missing = [...baseline].filter(p => !current.includes(p));
if (unexpected.length || missing.length) {
// Dedup: only alert if no alert in last 6 hours for this ASN
const sixHoursAgo = Date.now() - 6 * 60 * 60 * 1000;
const recentAlert = alerts.find(a => a.asn === sub.asn && new Date(a.ts).getTime() > sixHoursAgo);
if (!recentAlert) {
const severity = classifyHijackSeverity(unexpected, missing, 0);
const alert = {
id: crypto.randomBytes(8).toString('hex'),
asn: sub.asn, ts: new Date().toISOString(),
severity, unexpected, missing, resolved: false,
msg: `BGP anomaly for AS${sub.asn}: ${unexpected.length} unexpected prefix(es), ${missing.length} missing`
};
alerts.push(alert);
changed = true;
notifyWebhooks(sub.asn, alert);
}
}
// Set baseline on first monitoring
if (!sub.prefixes || !sub.prefixes.length) {
sub.prefixes = current;
changed = true;
}
}
if (changed) { saveHijackAlerts(alerts); saveHijackSubs(subs); }
}
// Run hijack check every 30 minutes
setInterval(runHijackCheck, 30 * 60 * 1000);
// ============================================================
// FEATURE 3: ASPA Adoption Tracker
// ============================================================
/**
* Load persisted ASPA adoption history from disk.
* Returns array of snapshot objects (up to 365 entries).
*/
function loadAspaAdoptionHistory() {
try {
const raw = JSON.parse(fs.readFileSync(ASPA_ADOPTION_FILE, 'utf8'));
return Array.isArray(raw) ? raw : [];
} catch(_) { return []; }
}
/**
* Save ASPA adoption history to disk (keep last 365 entries).
*/
function saveAspaAdoptionHistory(history) {
try {
const trimmed = history.slice(-365);
fs.writeFileSync(ASPA_ADOPTION_FILE, JSON.stringify(trimmed, null, 2), 'utf8');
} catch(e) {
console.error('[ASPA-ADOPT] Save failed:', e.message);
}
}
/** In-memory adoption history (persisted on each new snapshot) */
let aspaAdoptionDailyHistory = loadAspaAdoptionHistory();
/**
* Take a new adoption snapshot and persist it.
* Called whenever the RPKI feed is refreshed.
* @param {number} aspaCount — total ASPA objects in rpkiAspaMap
* @param {number} roaCount — total ROAs in roaStore
*/
function recordAspaAdoptionSnapshot(aspaCount, roaCount) {
const now = new Date();
const date = now.toISOString().slice(0, 10); // "YYYY-MM-DD"
// Compute how many Atlas-visible ASNs have ASPA
let atlasTotal = 0;
let atlasWithAspa = 0;
if (typeof atlasProbeCache !== 'undefined' && atlasProbeCache?.asns_with_probes) {
atlasTotal = atlasProbeCache.asns_with_probes.length;
// rpkiAspaMap is keyed by integer ASN
atlasWithAspa = atlasProbeCache.asns_with_probes.filter(a => rpkiAspaMap.has(Number(a))).length;
}
const coveragePct = atlasTotal > 0
? Math.round((atlasWithAspa / atlasTotal) * 10000) / 100 // 2 decimal places
: 0;
// Compute delta from previous snapshot
const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
const delta = prev ? aspaCount - prev.aspa_objects : 0;
const snapshot = {
date,
ts: now.getTime(),
aspa_objects: aspaCount,
roa_count: roaCount,
atlas_asns_total: atlasTotal,
atlas_asns_with_aspa: atlasWithAspa,
coverage_pct_atlas: coveragePct,
aspa_delta: delta,
method: 'rpki_cloudflare_feed',
};
// Only store one snapshot per date (overwrite if same day)
const existingIdx = aspaAdoptionDailyHistory.findIndex(s => s.date === date);
if (existingIdx >= 0) {
aspaAdoptionDailyHistory[existingIdx] = snapshot;
} else {
aspaAdoptionDailyHistory.push(snapshot);
}
saveAspaAdoptionHistory(aspaAdoptionDailyHistory);
console.log(`[ASPA-ADOPT] Snapshot ${date}: ${aspaCount} objects, ${coveragePct}% Atlas coverage (${atlasWithAspa}/${atlasTotal})`);
return snapshot;
}
/**
* Get adoption trend for a given period string ('7d', '30d', '90d', '1y').
*/
function getAdoptionTrend(period) {
const days = period === '7d' ? 7 : period === '30d' ? 30 : period === '90d' ? 90 : 365;
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
return aspaAdoptionDailyHistory.filter(s => s.ts >= cutoff);
}
/**
* Build the ASPA adoption dashboard HTML.
*/
function buildAspaAdoptionDashboard() {
const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
const trend30 = getAdoptionTrend('30d');
const trend7 = getAdoptionTrend('7d');
// Forecast: simple linear regression over last 30 days
function linearForecast(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, yMean + slope * (n - 1 + daysAhead)));
}
const forecast90d = linearForecast(trend30, 90);
const change7d = trend7.length >= 2
? (trend7[trend7.length-1].aspa_objects - trend7[0].aspa_objects)
: 0;
const trendLabels = trend30.map(d => d.date);
const trendValues = trend30.map(d => d.coverage_pct_atlas);
const asnValues = trend30.map(d => d.aspa_objects);
const coverageNow = latest?.coverage_pct_atlas ?? 0;
const aspaNow = latest?.aspa_objects ?? 0;
const atlasTotal = latest?.atlas_asns_total ?? 0;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>ASPA Adoption Tracker · PeerCortex</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,'Segoe UI',system-ui,sans-serif;background:#f5f3ff;color:#1e1b4b;min-height:100vh}
nav{background:#4f46e5;color:#fff;padding:14px 24px;display:flex;align-items:center;gap:12px}
nav .logo{font-size:16px;font-weight:700;letter-spacing:-0.5px}
nav .logo span{opacity:0.7;font-weight:400}
nav a{color:rgba(255,255,255,0.8);text-decoration:none;font-size:13px;margin-left:16px}
nav a:hover{color:#fff}
.container{max-width:1100px;margin:0 auto;padding:24px 20px}
h1{font-size:22px;font-weight:700;color:#3730a3;margin-bottom:4px}
.subtitle{font-size:13px;color:#6b7280;margin-bottom:24px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:28px}
.card{background:#fff;border:1px solid #e0e7ff;border-radius:12px;padding:18px 20px;box-shadow:0 1px 3px rgba(79,70,229,.08)}
.card .label{font-size:10px;color:#6b7280;text-transform:uppercase;letter-spacing:0.7px;margin-bottom:6px}
.card .value{font-size:28px;font-weight:700;color:#4f46e5;line-height:1}
.card .unit{font-size:11px;color:#9ca3af;margin-top:4px}
.card.green .value{color:#16a34a}
.card.amber .value{color:#d97706}
.chart-wrap{background:#fff;border:1px solid #e0e7ff;border-radius:12px;padding:20px;margin-bottom:24px;box-shadow:0 1px 3px rgba(79,70,229,.08)}
.chart-wrap h2{font-size:14px;font-weight:600;color:#3730a3;margin-bottom:16px}
.chart-row{display:grid;grid-template-columns:2fr 1fr;gap:20px;margin-bottom:24px}
.export-bar{display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap}
.btn{padding:8px 16px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;text-decoration:none;border:none}
.btn-primary{background:#4f46e5;color:#fff}
.btn-outline{background:#fff;color:#4f46e5;border:1.5px solid #4f46e5}
.btn:hover{opacity:0.9}
.forecast-box{background:linear-gradient(135deg,#4f46e5,#6366f1);color:#fff;border-radius:12px;padding:18px 22px}
.forecast-box .label{font-size:10px;opacity:0.8;text-transform:uppercase;letter-spacing:0.7px;margin-bottom:6px}
.forecast-box .value{font-size:32px;font-weight:700;line-height:1}
.forecast-box .unit{font-size:11px;opacity:0.7;margin-top:4px}
.meta{font-size:11px;color:#9ca3af;margin-top:24px;text-align:center}
@media(max-width:700px){.chart-row{grid-template-columns:1fr}}
</style>
</head>
<body>
<nav>
<span class="logo">⬡ PeerCortex <span>Network Intelligence</span></span>
<a href="/">Dashboard</a>
<a href="/aspa-adoption">ASPA Tracker</a>
</nav>
<div class="container">
<h1>ASPA Adoption Tracker</h1>
<p class="subtitle">Global ASPA deployment trends · Powered by RPKI Cloudflare feed · Updated every ~4 hours</p>
<div class="grid">
<div class="card ${coverageNow >= 15 ? 'green' : 'amber'}">
<div class="label">Atlas Coverage</div>
<div class="value">${coverageNow.toFixed(1)}%</div>
<div class="unit">of Atlas-visible ASNs</div>
</div>
<div class="card">
<div class="label">ASPA Objects</div>
<div class="value">${aspaNow.toLocaleString()}</div>
<div class="unit">total in RPKI</div>
</div>
<div class="card">
<div class="label">Atlas ASNs</div>
<div class="value">${atlasTotal.toLocaleString()}</div>
<div class="unit">sampled denominator</div>
</div>
<div class="card ${change7d > 0 ? 'green' : ''}">
<div class="label">7-Day Delta</div>
<div class="value">${change7d > 0 ? '+' : ''}${change7d}</div>
<div class="unit">new ASPA objects</div>
</div>
<div class="card">
<div class="label">Data Points</div>
<div class="value">${aspaAdoptionDailyHistory.length}</div>
<div class="unit">history snapshots</div>
</div>
</div>
<div class="export-bar">
<a class="btn btn-primary" href="/api/aspa-adoption-stats/export?format=json&period=30d">↓ JSON (30d)</a>
<a class="btn btn-outline" href="/api/aspa-adoption-stats/export?format=csv&period=30d">↓ CSV (30d)</a>
<a class="btn btn-outline" href="/api/aspa-adoption-stats/export?format=csv&period=1y">↓ CSV (1y)</a>
<a class="btn btn-outline" href="/api/export/pdf?asn=0&format=report" style="display:none">PDF Report</a>
</div>
<div class="chart-row">
<div class="chart-wrap">
<h2>Atlas Coverage % (last 30 days)</h2>
<canvas id="coverageChart" height="120"></canvas>
</div>
<div class="forecast-box">
<div class="label">90-Day Forecast</div>
<div class="value">${forecast90d !== null ? forecast90d.toFixed(1) + '%' : '—'}</div>
<div class="unit">linear projection from 30-day trend</div>
<div style="margin-top:16px;font-size:11px;opacity:0.8">
Based on ${trend30.length} data points.<br/>
Current: ${coverageNow.toFixed(1)}%
</div>
</div>
</div>
<div class="chart-wrap">
<h2>Total ASPA Objects in RPKI (last 30 days)</h2>
<canvas id="asnChart" height="80"></canvas>
</div>
<div class="meta">
Last snapshot: ${latest?.date ?? 'none'} · Denominator: ${atlasTotal.toLocaleString()} Atlas ASNs ·
<a href="/api/aspa-adoption-stats?period=30d" style="color:#4f46e5">JSON API</a>
</div>
</div>
<script>
const labels = ${JSON.stringify(trendLabels)};
const covData = ${JSON.stringify(trendValues)};
const asnData = ${JSON.stringify(asnValues)};
const commonOpts = {
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: '#f0eeff' }, ticks: { maxRotation: 45, font: { size: 10 } } },
y: { grid: { color: '#f0eeff' }, ticks: { font: { size: 10 } }, beginAtZero: false }
}
};
new Chart(document.getElementById('coverageChart'), {
type: 'line',
data: {
labels,
datasets: [{
data: covData,
borderColor: '#4f46e5',
backgroundColor: 'rgba(79,70,229,0.08)',
fill: true,
tension: 0.3,
pointRadius: 3,
}]
},
options: { ...commonOpts, scales: { ...commonOpts.scales, y: { ...commonOpts.scales.y, ticks: { callback: v => v + '%', font: { size: 10 } } } } }
});
new Chart(document.getElementById('asnChart'), {
type: 'bar',
data: {
labels,
datasets: [{
data: asnData,
backgroundColor: 'rgba(79,70,229,0.6)',
borderColor: '#4f46e5',
borderWidth: 1,
}]
},
options: commonOpts
});
</script>
</body>
</html>`;
}
// ============================================================
// FEATURE 4: IPv6 Adoption per RIR
// ============================================================
// In-memory cache: { data, ts }
let ipv6StatsCache = null;
const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h
const RIR_DELEGATION_URLS = {
RIPE: 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest',
ARIN: 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest',
APNIC: 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest',
AFRINIC: 'https://ftp.afrinic.net/stats/afrinic/delegated-afrinic-extended-latest',
LACNIC: 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest',
};
/**
* Fetch and parse one RIR delegation file.
* Returns { ipv4, ipv6, asns } count objects.
*/
function fetchRirDelegation(rir, url) {
return new Promise((resolve) => {
const result = { rir, ipv4: 0, ipv6: 0, asns: 0, ipv4_countries: new Set(), ipv6_countries: new Set() };
const mod = url.startsWith('https') ? require('https') : require('http');
const req = mod.get(url, { headers: { 'User-Agent': 'PeerCortex/1.0 (+https://peercortex.org)' }, timeout: 20000 }, res => {
if (res.statusCode !== 200) { resolve(result); return; }
let buf = '';
res.on('data', d => { buf += d; });
res.on('end', () => {
for (const line of buf.split('\n')) {
if (line.startsWith('#') || line.startsWith(rir.toLowerCase() + '|*') || !line.includes('|')) continue;
const parts = line.split('|');
if (parts.length < 7) continue;
const type = parts[2];
const status = parts[6].trim();
const cc = parts[1];
if (status !== 'allocated' && status !== 'assigned') continue;
if (type === 'ipv4') { result.ipv4++; result.ipv4_countries.add(cc); }
else if (type === 'ipv6') { result.ipv6++; result.ipv6_countries.add(cc); }
else if (type === 'asn') { result.asns++; }
}
resolve(result);
});
});
req.on('error', () => resolve(result));
req.on('timeout', () => { req.destroy(); resolve(result); });
});
}
/**
* Fetch all 5 RIR delegation files and aggregate IPv6 stats.
* Cached for 24 hours.
*/
async function fetchIpv6AdoptionStats(force) {
if (!force && ipv6StatsCache && (Date.now() - ipv6StatsCache.ts) < IPV6_STATS_TTL) {
return ipv6StatsCache.data;
}
console.log('[IPv6] Fetching RIR delegation stats...');
const results = await Promise.all(
Object.entries(RIR_DELEGATION_URLS).map(([rir, url]) => fetchRirDelegation(rir, url))
);
const rirs = results.map(r => ({
rir: r.rir,
ipv4_records: r.ipv4,
ipv6_records: r.ipv6,
asn_records: r.asns,
ipv6_pct: (r.ipv4 + r.ipv6) > 0
? Math.round((r.ipv6 / (r.ipv4 + r.ipv6)) * 10000) / 100
: 0,
countries_with_ipv6: r.ipv6_countries.size,
countries_with_ipv4: r.ipv4_countries.size,
}));
const total_ipv4 = rirs.reduce((s, r) => s + r.ipv4_records, 0);
const total_ipv6 = rirs.reduce((s, r) => s + r.ipv6_records, 0);
const data = {
fetched_at: new Date().toISOString(),
global_ipv6_pct: total_ipv4 + total_ipv6 > 0
? Math.round((total_ipv6 / (total_ipv4 + total_ipv6)) * 10000) / 100
: 0,
total_ipv4_records: total_ipv4,
total_ipv6_records: total_ipv6,
by_rir: rirs,
};
ipv6StatsCache = { data, ts: Date.now() };
console.log(`[IPv6] Done. Global IPv6%: ${data.global_ipv6_pct}% across ${rirs.length} RIRs`);
return data;
}
// Pre-fetch on startup (non-blocking)
fetchIpv6AdoptionStats().catch(() => {});
const UA = "PeerCortex/0.5.0 (+https://peercortex.org)";
// Static geocode cache for major networking cities (fallback when PDB facility coords missing)
const CITY_COORDS = {
"amsterdam": [52.3676, 4.9041], "london": [51.5074, -0.1278], "frankfurt": [50.1109, 8.6821],
"paris": [48.8566, 2.3522], "stockholm": [59.3293, 18.0686], "zurich": [47.3769, 8.5417],
"berlin": [52.5200, 13.4050], "hamburg": [53.5511, 9.9937], "munich": [48.1351, 11.5820],
"vienna": [48.2082, 16.3738], "prague": [50.0755, 14.4378], "warsaw": [52.2297, 21.0122],
"copenhagen": [55.6761, 12.5683], "oslo": [59.9139, 10.7522], "helsinki": [60.1699, 24.9384],
"milan": [45.4642, 9.1900], "madrid": [40.4168, -3.7038], "lisbon": [38.7223, -9.1393],
"dublin": [53.3498, -6.2603], "brussels": [50.8503, 4.3517], "bucharest": [44.4268, 26.1025],
"sofia": [42.6977, 23.3219], "athens": [37.9838, 23.7275], "istanbul": [41.0082, 28.9784],
"moscow": [55.7558, 37.6173], "mumbai": [19.0760, 72.8777], "singapore": [1.3521, 103.8198],
"hong kong": [22.3193, 114.1694], "tokyo": [35.6762, 139.6503], "sydney": [-33.8688, 151.2093],
"los angeles": [34.0522, -118.2437], "new york": [40.7128, -74.0060], "chicago": [41.8781, -87.6298],
"dallas": [32.7767, -96.7970], "miami": [25.7617, -80.1918], "ashburn": [39.0438, -77.4874],
"seattle": [47.6062, -122.3321], "san jose": [37.3382, -121.8863], "toronto": [43.6532, -79.3832],
"sao paulo": [-23.5505, -46.6333], "johannesburg": [-26.2041, 28.0473], "meppel": [52.6966, 6.1940],
"manchester": [53.4808, -2.2426], "marseille": [43.2965, 5.3698], "dusseldorf": [51.2277, 6.7735],
"nuremberg": [49.4521, 11.0767], "tallinn": [59.4370, 24.7536], "riga": [56.9496, 24.1052],
"auckland": [-36.8485, 174.7633], "wellington": [-41.2865, 174.7762], "denver": [39.7392, -104.9903],
"atlanta": [33.7490, -84.3880], "portland": [45.5152, -122.6784], "vancouver": [49.2827, -123.1207],
"montreal": [45.5017, -73.5673], "mexico city": [19.4326, -99.1332], "seoul": [37.5665, 126.9780],
"taipei": [25.0330, 121.5654], "bangkok": [13.7563, 100.5018], "jakarta": [-6.2088, 106.8456],
"scotland": [55.9533, -3.1883], "edinburgh": [55.9533, -3.1883],
};
// ============================================================
// 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' || v.status === 'unknown').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();
}
}
// ============================================================
// Task 6: In-memory cache with TTL + Rate Limiting
// ============================================================
const responseCache = new Map();
function cacheGet(key) {
const entry = responseCache.get(key);
if (!entry) return null;
if (Date.now() > entry.expires) {
responseCache.delete(key);
return null;
}
return entry.data;
}
function cacheSet(key, data, ttlMs) {
responseCache.set(key, { data, expires: Date.now() + ttlMs });
// Evict old entries periodically (keep cache under 500 entries)
if (responseCache.size > 500) {
const now = Date.now();
for (const [k, v] of responseCache) {
if (now > v.expires) responseCache.delete(k);
}
}
}
// ── Tier-1 ASN Set (used for route leak heuristics) ─────────────────────────
const TIER1_ASNS = new Set([
174, // Cogent
209, // CenturyLink/Lumen
286, // KPN
701, // Verizon/UUNET
702, // Verizon
1239, // Sprint
1273, // Vodafone
1280, // Internet Systems Consortium
1299, // Arelion (Telia)
2914, // NTT
3257, // GTT
3320, // Deutsche Telekom
3356, // Lumen (Level3)
3491, // PCCW
5511, // Orange
6453, // TATA
6461, // Zayo
6762, // Telecom Italia Sparkle
7018, // AT&T
7473, // SingTel
12956, // Telxius
]);
const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes
const CACHE_TTL_ASPA = 4 * 60 * 60 * 1000; // 4 hours
const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes
const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 minutes
// ============================================================
// RDAP Cache — prevents 429 flooding on LACNIC/AFRINIC/APNIC/ARIN
// ============================================================
const rdapCache = new Map(); // key: asn string, value: { data, ts }
const RDAP_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
function rdapCacheGet(asn) {
const e = rdapCache.get(String(asn));
if (e && (Date.now() - e.ts) < RDAP_CACHE_TTL) return e.data;
return undefined; // undefined = not cached, null = cached miss
}
function rdapCacheSet(asn, data) {
if (rdapCache.size > 5000) {
rdapCache.delete(rdapCache.keys().next().value);
}
rdapCache.set(String(asn), { data, ts: Date.now() });
}
// ============================================================
// WHOIS Cache — 24h TTL, prevents repeated RDAP hammering
// ============================================================
const whoisCache = new Map(); // key: asn string, value: { data, ts }
const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000;
function whoisCacheGet(asn) {
const e = whoisCache.get(String(asn));
if (e && (Date.now() - e.ts) < WHOIS_CACHE_TTL) return e.data;
return undefined;
}
function whoisCacheSet(asn, data) {
if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value);
whoisCache.set(String(asn), { data, ts: Date.now() });
}
// ============================================================
// Quick-IX Cache — 1h TTL, for Peering Recommendations
// ============================================================
const quickIxCache = new Map(); // key: asn string, value: { data, ts }
const QUICK_IX_CACHE_TTL = 60 * 60 * 1000;
function quickIxCacheGet(asn) {
const e = quickIxCache.get(String(asn));
if (e && (Date.now() - e.ts) < QUICK_IX_CACHE_TTL) return e.data;
return undefined;
}
function quickIxCacheSet(asn, data) {
if (quickIxCache.size > 2000) quickIxCache.delete(quickIxCache.keys().next().value);
quickIxCache.set(String(asn), { data, ts: Date.now() });
}
// ============================================================
// bgproutes.io Vantage Points Cache — 1h TTL, prevents 429
// ============================================================
let bgproutesVpCache = null;
let bgproutesVpCacheTs = 0;
const BGPROUTES_VP_TTL = 60 * 60 * 1000; // 1 hour
// ============================================================
// bgproutes + ASPA result caches — 15min TTL, prevent re-hitting slow APIs
// ============================================================
const bgproutesResultCache = new Map();
const aspaResultCache = new Map();
const validateResultCache = new Map();
const RESULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes
function resultCacheGet(map, key) {
const e = map.get(String(key));
const ttl = e && e.ttl ? e.ttl : RESULT_CACHE_TTL;
if (e && (Date.now() - e.ts) < ttl) return e.data;
return undefined;
}
function resultCacheSet(map, key, data, ttlMs) {
if (map.size > 2000) map.delete(map.keys().next().value);
map.set(String(key), { data, ts: Date.now(), ttl: ttlMs || RESULT_CACHE_TTL });
}
// ============================================================
// MANRS Participants Cache (scraped from public HTML page, 24h TTL)
// ============================================================
let manrsAsnSet = null; // Set<string> of member ASNs
let manrsLastFetch = 0;
let manrsFetching = false;
const MANRS_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
async function ensureManrsCache() {
const now = Date.now();
if (manrsAsnSet && (now - manrsLastFetch) < MANRS_CACHE_TTL) return;
if (manrsFetching) {
// Wait up to 8s for in-progress fetch
for (let i = 0; i < 80; i++) {
await new Promise(r => setTimeout(r, 100));
if (manrsAsnSet) return;
}
return;
}
manrsFetching = true;
try {
const html = await new Promise((resolve, reject) => {
const opts = { hostname: "www.manrs.org", path: "/netops/participants/", method: "GET", timeout: 15000,
headers: { "User-Agent": UA, "Accept": "text/html" } };
const req = https.request(opts, res => {
let body = "";
res.on("data", d => { body += d; });
res.on("end", () => resolve(body));
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("MANRS fetch timeout")); });
req.end();
});
// Extract ASNs from <td class="asns">267490</td> — may contain multiple space-separated ASNs
const set = new Set();
const re = /<td[^>]*class="asns"[^>]*>([\d\s,]+)<\/td>/gi;
let m;
while ((m = re.exec(html)) !== null) {
m[1].split(/[\s,]+/).forEach(a => { const n = a.trim(); if (n) set.add(n); });
}
if (set.size > 0) {
manrsAsnSet = set;
manrsLastFetch = Date.now();
console.log("[MANRS] Loaded " + set.size + " participant ASNs from manrs.org");
}
} catch (e) {
console.warn("[MANRS] Failed to fetch participants:", e.message);
} finally {
manrsFetching = false;
}
}
function checkManrsMembership(asn) {
if (!manrsAsnSet) return { status: "info", participant: "unknown", message: "MANRS data not yet loaded", note: "https://www.manrs.org/netops/participants/" };
const isMember = manrsAsnSet.has(String(asn));
return {
status: isMember ? "pass" : "fail",
participant: isMember,
member_count: manrsAsnSet.size,
note: isMember ? "Confirmed MANRS Network Operator participant" : "Not listed as MANRS participant — https://www.manrs.org/beamanrs/",
};
}
// ============================================================
// Infrastructure overlay caches
let subCableCache = null; // TeleGeography submarine cables (24h)
let globalFacCache = null; // PeeringDB global facilities (24h)
// RPKI ASPA + ROA Cache from Cloudflare RPKI JSON feed
// ============================================================
const rpkiAspaMap = new Map(); // customer_asid -> Set<provider_asn>
let rpkiAspaLastFetch = 0;
let rpkiAspaFetching = false;
// ============================================================
// Local ROA Store — validates prefixes without RIPE Stat API calls
// Parses ~400k ROAs from the same Cloudflare RPKI feed used for ASPA
// Uses sorted arrays + binary search for O(log n) lookups (~0.1ms per prefix)
// ============================================================
const roaStore = {
v4Entries: [], // [{start, end, asn, prefixLen, maxLen}] sorted by start
v6Entries: [], // [{prefixHex, prefixLen, asn, maxLen}] sorted by prefixHex
ready: false,
count: 0,
lastBuild: 0,
// Parse IPv4 prefix string to 32-bit unsigned integer
_ipv4ToUint32(ip) {
const parts = ip.split(".");
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
},
// Build ROA store from Cloudflare feed roas array
build(roas) {
const v4 = [];
const v6 = [];
for (let i = 0; i < roas.length; i++) {
const r = roas[i];
const asn = typeof r.asn === "string" ? parseInt(r.asn.replace("AS", "")) : Number(r.asn);
const prefix = r.prefix;
const maxLen = r.maxLength || r.maxPrefixLength || 0;
if (!prefix || !asn) continue;
const slashIdx = prefix.indexOf("/");
if (slashIdx < 0) continue;
const prefixLen = parseInt(prefix.substring(slashIdx + 1));
const addr = prefix.substring(0, slashIdx);
if (prefix.indexOf(":") >= 0) {
// IPv6 — store as zero-padded hex string for sorting
const expanded = this._expandIPv6(addr);
if (expanded) {
v6.push({ prefixHex: expanded, prefixLen, asn, maxLen: maxLen || prefixLen });
}
} else {
// IPv4 — store as numeric range
const start = this._ipv4ToUint32(addr);
const hostBits = 32 - prefixLen;
const end = (start | ((1 << hostBits) - 1)) >>> 0;
v4.push({ start, end, asn, prefixLen, maxLen: maxLen || prefixLen });
}
}
// Sort for binary search
v4.sort((a, b) => a.start - b.start || a.prefixLen - b.prefixLen);
v6.sort((a, b) => a.prefixHex < b.prefixHex ? -1 : a.prefixHex > b.prefixHex ? 1 : a.prefixLen - b.prefixLen);
this.v4Entries = v4;
this.v6Entries = v6;
this.count = v4.length + v6.length;
this.ready = true;
this.lastBuild = Date.now();
},
// Expand IPv6 address to 32-char hex for reliable sorting
_expandIPv6(addr) {
try {
let groups = addr.split("::");
let left = groups[0] ? groups[0].split(":") : [];
let right = groups.length > 1 && groups[1] ? groups[1].split(":") : [];
const missing = 8 - left.length - right.length;
const mid = [];
for (let i = 0; i < missing; i++) mid.push("0000");
const all = [...left, ...mid, ...right];
return all.map(g => g.padStart(4, "0")).join("");
} catch (_e) {
return null;
}
},
// Validate a prefix against the local ROA store
// Returns: {prefix, status: "valid"|"invalid"|"not_found", validating_roas: N}
validate(asn, prefix) {
if (!this.ready) return null; // Signal caller to use fallback
const asnNum = typeof asn === "string" ? parseInt(asn.replace("AS", "")) : Number(asn);
const slashIdx = prefix.indexOf("/");
if (slashIdx < 0) return { prefix, status: "not_found", validating_roas: 0 };
const prefixLen = parseInt(prefix.substring(slashIdx + 1));
const addr = prefix.substring(0, slashIdx);
if (prefix.indexOf(":") >= 0) {
return this._validateV6(asnNum, addr, prefixLen, prefix);
}
return this._validateV4(asnNum, addr, prefixLen, prefix);
},
_validateV4(asn, addr, prefixLen, prefix) {
const queryStart = this._ipv4ToUint32(addr);
const entries = this.v4Entries;
// Binary search: find rightmost entry where start <= queryStart
let lo = 0, hi = entries.length - 1;
let insertionPoint = -1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (entries[mid].start <= queryStart) {
insertionPoint = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
// Scan backwards from insertion point to find covering ROAs
// ROAs are sorted by start, so we scan back while start could still cover our prefix
const matched = [];
const unmatchedAs = [];
for (let i = insertionPoint; i >= 0; i--) {
const e = entries[i];
// If this ROA's network start is too far left, no more matches possible
if (queryStart - e.start > 0x01000000) break; // heuristic: skip if > /8 away
// Check if query prefix is contained within this ROA
if (e.start <= queryStart && queryStart <= e.end && prefixLen >= e.prefixLen) {
if (prefixLen <= e.maxLen) {
if (e.asn === asn) {
matched.push(e);
} else {
unmatchedAs.push(e);
}
}
// prefixLen > maxLen → too specific, invalid if ASN matches
else if (e.asn === asn) {
unmatchedAs.push(e); // ASN matches but length exceeds maxLen
}
}
}
if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length };
if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length };
return { prefix, status: "not_found", validating_roas: 0 };
},
_validateV6(asn, addr, prefixLen, prefix) {
const queryHex = this._expandIPv6(addr);
if (!queryHex) return { prefix, status: "not_found", validating_roas: 0 };
const entries = this.v6Entries;
// Binary search for approximate position
let lo = 0, hi = entries.length - 1;
let insertionPoint = -1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (entries[mid].prefixHex <= queryHex) {
insertionPoint = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
const matched = [];
const unmatchedAs = [];
// Scan backwards from insertion point
for (let i = insertionPoint; i >= 0 && i > insertionPoint - 500; i--) {
const e = entries[i];
// Check if query prefix is covered by this ROA entry
// A covering ROA has a shorter or equal prefix length and its network prefix matches
if (e.prefixLen <= prefixLen) {
// Compare the first prefixLen bits (in hex chars: prefixLen/4 chars, rounded up)
const hexChars = Math.ceil(e.prefixLen / 4);
if (queryHex.substring(0, hexChars) === e.prefixHex.substring(0, hexChars)) {
if (prefixLen <= e.maxLen) {
if (e.asn === asn) matched.push(e);
else unmatchedAs.push(e);
} else if (e.asn === asn) {
unmatchedAs.push(e);
}
}
}
// Stop if we're too far away
if (queryHex.substring(0, 4) !== e.prefixHex.substring(0, 4)) break;
}
if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length };
if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length };
return { prefix, status: "not_found", validating_roas: 0 };
},
// Persist to disk for fast restart
saveToDisk(filePath) {
try {
const data = JSON.stringify({
ts: this.lastBuild,
v4Count: this.v4Entries.length,
v6Count: this.v6Entries.length,
v4: this.v4Entries,
v6: this.v6Entries,
});
fs.writeFileSync(filePath, data);
console.log("[ROA] Saved " + this.count + " ROAs to disk");
} catch (e) {
console.warn("[ROA] Disk save failed:", e.message);
}
},
// Load from disk cache (returns true if loaded)
loadFromDisk(filePath) {
try {
if (!fs.existsSync(filePath)) return false;
const raw = fs.readFileSync(filePath, "utf8");
const data = JSON.parse(raw);
// Only use if less than 6 hours old
if (Date.now() - data.ts > 6 * 60 * 60 * 1000) return false;
this.v4Entries = data.v4;
this.v6Entries = data.v6;
this.count = data.v4Count + data.v6Count;
this.lastBuild = data.ts;
this.ready = true;
console.log("[ROA] Loaded " + this.count + " ROAs from disk cache");
return true;
} catch (e) {
console.warn("[ROA] Disk load failed:", e.message);
return false;
}
},
};
// ============================================================
// PeeringDB Source Cache (L2) — net/netixlan/netfac per ASN
// Eliminates redundant PDB API calls under load
// ============================================================
const pdbSourceCache = {
net: new Map(), // key: asn string → {data, ts}
netixlan: new Map(), // key: net_id string → {data, ts}
netfac: new Map(), // key: net_id string → {data, ts}
facCoords: new Map(), // key: fac_id string → {lat, lon, ts}
TTL_NET: 6 * 60 * 60 * 1000, // 6 hours
TTL_IXFAC: 6 * 60 * 60 * 1000, // 6 hours
TTL_COORDS: 7 * 24 * 60 * 60 * 1000, // 7 days
MAX_NET: 5000,
MAX_IXFAC: 5000,
MAX_COORDS: 10000,
hits: 0,
misses: 0,
get(type, key) {
const map = this[type];
const ttl = type === "facCoords" ? this.TTL_COORDS : (type === "net" ? this.TTL_NET : this.TTL_IXFAC);
const entry = map.get(String(key));
if (!entry) { this.misses++; return null; }
if (Date.now() - entry.ts > ttl) { map.delete(String(key)); this.misses++; return null; }
this.hits++;
return entry.data;
},
set(type, key, data) {
const map = this[type];
const max = type === "facCoords" ? this.MAX_COORDS : (type === "net" ? this.MAX_NET : this.MAX_IXFAC);
if (map.size >= max) {
// Evict oldest entry (Map preserves insertion order)
map.delete(map.keys().next().value);
}
map.set(String(key), { data, ts: Date.now() });
},
// Disk persistence
saveToDisk(filePath) {
try {
const serialize = (map) => {
const obj = {};
for (const [k, v] of map) obj[k] = v;
return obj;
};
const data = JSON.stringify({
ts: Date.now(),
net: serialize(this.net),
netixlan: serialize(this.netixlan),
netfac: serialize(this.netfac),
facCoords: serialize(this.facCoords),
});
fs.writeFileSync(filePath, data);
console.log("[PDB-CACHE] Saved to disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")");
} catch (e) {
console.warn("[PDB-CACHE] Disk save failed:", e.message);
}
},
loadFromDisk(filePath) {
try {
if (!fs.existsSync(filePath)) return false;
const raw = fs.readFileSync(filePath, "utf8");
const data = JSON.parse(raw);
const now = Date.now();
const load = (map, obj, ttl) => {
for (const [k, v] of Object.entries(obj || {})) {
if (now - v.ts < ttl) map.set(k, v);
}
};
load(this.net, data.net, this.TTL_NET);
load(this.netixlan, data.netixlan, this.TTL_IXFAC);
load(this.netfac, data.netfac, this.TTL_IXFAC);
load(this.facCoords, data.facCoords, this.TTL_COORDS);
console.log("[PDB-CACHE] Loaded from disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")");
return true;
} catch (e) {
console.warn("[PDB-CACHE] Disk load failed:", e.message);
return false;
}
},
};
// ============================================================
// RIPE Stat Source Cache + Semaphore (L2)
// Prevents 429 rate-limiting by throttling + caching responses
// ============================================================
const ripeStatCache = new Map(); // key: "endpoint:resource" → {data, ts}
const RIPE_STAT_CACHE_MAX = 2000;
const RIPE_STAT_TTL = {
"announced-prefixes": 15 * 60 * 1000,
"asn-neighbours": 15 * 60 * 1000,
"as-overview": 60 * 60 * 1000,
"rir-stats-country": 24 * 60 * 60 * 1000,
"visibility": 15 * 60 * 1000,
"prefix-size-distribution": 60 * 60 * 1000,
"abuse-contact-finder": 24 * 60 * 60 * 1000,
"blocklist": 60 * 60 * 1000,
"reverse-dns-consistency": 60 * 60 * 1000,
"routing-status": 15 * 60 * 1000,
"bgp-updates": 15 * 60 * 1000,
"maxmind-geo-lite-pfx": 24 * 60 * 60 * 1000,
"looking-glass": 15 * 60 * 1000,
"whois": 24 * 60 * 60 * 1000,
"rpki-validation": 6 * 60 * 60 * 1000,
};
// Counting semaphore — limits concurrent RIPE Stat requests
class Semaphore {
constructor(max) { this.max = max; this.current = 0; this.queue = []; }
acquire() {
if (this.current < this.max) { this.current++; return Promise.resolve(); }
return new Promise((resolve) => this.queue.push(resolve));
}
release() {
this.current--;
if (this.queue.length > 0) { this.current++; this.queue.shift()(); }
}
}
const ripeStatSemaphore = new Semaphore(15);
// Cached + throttled RIPE Stat fetch
async function fetchRipeStatCached(url, options) {
// Extract endpoint name from URL for TTL lookup
const match = url.match(/\/data\/([^/]+)\//);
const endpoint = match ? match[1] : "default";
const resourceMatch = url.match(/resource=([^&]+)/);
const resource = resourceMatch ? resourceMatch[1] : url;
const cacheKey = endpoint + ":" + resource;
// Check cache
const cached = ripeStatCache.get(cacheKey);
const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000;
if (cached && (Date.now() - cached.ts) < ttl) {
return cached.data;
}
// Throttle via semaphore
await ripeStatSemaphore.acquire();
try {
// Double-check cache after acquiring semaphore (another request may have filled it)
const cached2 = ripeStatCache.get(cacheKey);
if (cached2 && (Date.now() - cached2.ts) < ttl) {
return cached2.data;
}
const result = await fetchJSON(url, options);
// Only cache successful results — never cache null (failed/rate-limited responses)
// Caching null causes cascading failures: retry hits cache, returns null again
if (result !== null) {
if (ripeStatCache.size >= RIPE_STAT_CACHE_MAX) {
ripeStatCache.delete(ripeStatCache.keys().next().value);
}
ripeStatCache.set(cacheKey, { data: result, ts: Date.now() });
}
return result;
} finally {
ripeStatSemaphore.release();
}
}
// Cached + throttled RIPE Stat with one retry on failure
async function fetchRipeStatCachedWithRetry(url, options) {
const result = await fetchRipeStatCached(url, options);
if (result !== null) return result;
await new Promise(r => setTimeout(r, 1500));
return fetchRipeStatCached(url, options);
}
// RIPE Stat cache disk persistence (skip null entries)
function saveRipeStatCacheToDisk(filePath) {
try {
const obj = {};
for (const [k, v] of ripeStatCache) {
if (v.data !== null) obj[k] = v;
}
fs.writeFileSync(filePath, JSON.stringify({ ts: Date.now(), entries: obj }));
console.log("[RIPE-CACHE] Saved " + Object.keys(obj).length + " entries to disk");
} catch (e) {
console.warn("[RIPE-CACHE] Disk save failed:", e.message);
}
}
function loadRipeStatCacheFromDisk(filePath) {
try {
if (!fs.existsSync(filePath)) return false;
const raw = fs.readFileSync(filePath, "utf8");
const data = JSON.parse(raw);
const now = Date.now();
for (const [k, v] of Object.entries(data.entries || {})) {
const match = k.match(/^([^:]+):/);
const endpoint = match ? match[1] : "default";
const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000;
if (now - v.ts < ttl) {
ripeStatCache.set(k, v);
}
}
console.log("[RIPE-CACHE] Loaded " + ripeStatCache.size + " entries from disk");
return true;
} catch (e) {
console.warn("[RIPE-CACHE] Disk load failed:", e.message);
return false;
}
}
function fetchRpkiAspaFeed() {
if (rpkiAspaFetching) return Promise.resolve();
rpkiAspaFetching = true;
console.log("[RPKI] Fetching Cloudflare RPKI feed (ASPA + ROA)...");
return new Promise((resolve) => {
const options = {
headers: { "User-Agent": UA },
timeout: 120000,
};
https.get("https://rpki.cloudflare.com/rpki.json", options, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
const parsed = JSON.parse(data);
// Load ASPA objects
const aspas = parsed.aspas || [];
rpkiAspaMap.clear();
aspas.forEach((a) => {
const customerAsid = Number(a.customer_asid);
const providers = (a.providers || []).map(Number);
rpkiAspaMap.set(customerAsid, new Set(providers));
});
// Load ROA objects into local store (eliminates RIPE Stat per-prefix calls)
const roas = parsed.roas || [];
roaStore.build(roas);
roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
// Track ASPA adoption — persist to disk + update in-memory trend
recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count);
rpkiAspaLastFetch = Date.now();
console.log("[RPKI] Loaded " + rpkiAspaMap.size + " ASPA objects + " + roaStore.count + " ROAs from Cloudflare feed");
} catch (e) {
console.error("[RPKI] Failed to parse RPKI feed:", e.message);
}
rpkiAspaFetching = false;
resolve();
});
}).on("error", (e) => {
console.error("[RPKI] Fetch failed:", e.message);
rpkiAspaFetching = false;
resolve();
});
});
}
// Ensure ASPA + ROA cache is fresh
async function ensureAspaCache() {
if (Date.now() - rpkiAspaLastFetch > 4 * 60 * 60 * 1000) {
await fetchRpkiAspaFeed();
}
}
// Lookup ASPA object for a given ASN from the RPKI feed cache
function lookupAspaFromRpki(asn) {
const asnNum = Number(asn);
if (rpkiAspaMap.has(asnNum)) {
const providers = rpkiAspaMap.get(asnNum);
return { exists: true, providers: [...providers].sort((a, b) => a - b) };
}
return { exists: false, providers: [] };
}
// PeeringDB semaphore — limits concurrent PDB requests to avoid 429 rate-limits
const pdbSemaphore = new Semaphore(5);
// PeeringDB authenticated fetch helper — tries local SQLite first, falls back to live API
async function fetchPeeringDB(path, options) {
// Try local SQLite (instant, no rate-limits) — skip large "all networks" calls to live API
const localResult = queryPeeringDBLocal(path);
if (localResult !== null) return localResult;
// Fallback: live PeeringDB API (throttled via semaphore)
const url = PEERINGDB_API_URL + path;
const headers = { "User-Agent": UA };
if (PEERINGDB_API_KEY) {
headers["Authorization"] = "Api-Key " + PEERINGDB_API_KEY;
}
await pdbSemaphore.acquire();
try {
return await fetchJSON(url, { ...options, headers: { ...(options && options.headers || {}), ...headers } });
} finally {
pdbSemaphore.release();
}
}
// PeeringDB fetch with exponential backoff retries (handles rate-limits under concurrent load).
// Up to 3 attempts: immediate → 2s → 5s. Returns null only after all attempts exhausted.
async function fetchPeeringDBWithRetry(path, options) {
const delays = [2000, 5000];
let result = await fetchPeeringDB(path, options);
for (let i = 0; i < delays.length && result === null; i++) {
await new Promise(r => setTimeout(r, delays[i]));
result = await fetchPeeringDB(path, options);
}
return result;
}
// Generic JSON fetch with one retry — for sources that occasionally fail under load (RIPE Stat, Atlas)
async function fetchJSONWithRetry(url, options) {
const result = await fetchJSON(url, options);
if (result !== null) return result;
await new Promise(r => setTimeout(r, 1000));
return fetchJSON(url, options);
}
// bgproutes.io visibility fallback helper
// Queries the RIB endpoint to estimate prefix visibility across vantage points
function fetchBgproutesVisibility(prefix) {
if (!BGPROUTES_API_KEY) return Promise.resolve(null);
const url = BGPROUTES_API_URL + "/rib?prefix=" + encodeURIComponent(prefix) + "&prefix_match=exact";
return fetchJSON(url, {
timeout: 15000,
headers: {
"Authorization": "Bearer " + BGPROUTES_API_KEY,
"User-Agent": UA,
},
}).then(function(data) {
if (!data || !data.data) return null;
// data.data should be an array of RIB entries from different vantage points
var entries = Array.isArray(data.data) ? data.data : (data.data.entries || data.data.routes || []);
var vpSet = new Set();
entries.forEach(function(e) {
if (e.vantage_point || e.vp || e.collector || e.peer_asn) {
vpSet.add(e.vantage_point || e.vp || e.collector || e.peer_asn);
}
});
return { vps_seeing: vpSet.size, total_entries: entries.length, source: "bgproutes.io" };
}).catch(function() { return null; });
}
// Rate limiting: max 60 requests per minute per IP
const rateLimitMap = new Map();
const RATE_LIMIT_WINDOW = 60 * 1000;
const RATE_LIMIT_MAX = 60;
function checkRateLimit(ip) {
const now = Date.now();
let entry = rateLimitMap.get(ip);
if (!entry || now > entry.windowStart + RATE_LIMIT_WINDOW) {
entry = { windowStart: now, count: 0 };
rateLimitMap.set(ip, entry);
}
entry.count++;
// Clean old entries periodically
if (rateLimitMap.size > 1000) {
for (const [k, v] of rateLimitMap) {
if (now > v.windowStart + RATE_LIMIT_WINDOW) rateLimitMap.delete(k);
}
}
return entry.count <= RATE_LIMIT_MAX;
}
function fetchJSON(url, options) {
const timeoutMs = (options && options.timeout) || 8000;
return new Promise((resolve) => {
const reqOptions = {
headers: { "User-Agent": UA, ...(options && options.headers ? options.headers : {}) },
timeout: timeoutMs,
};
const timer = setTimeout(() => resolve(null), timeoutMs + 500);
https
.get(url, reqOptions, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
clearTimeout(timer);
if (res.statusCode === 429) {
console.warn("[PDB] Rate limited (429):", url.substring(0, 80));
return resolve(null);
}
try {
resolve(JSON.parse(data));
} catch (_e) {
resolve(null);
}
});
})
.on("timeout", () => { clearTimeout(timer); resolve(null); })
.on("error", () => { clearTimeout(timer); resolve(null); });
});
}
function fetchHTML(url, options) {
return new Promise((resolve) => {
const reqOptions = {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
...(options && options.headers ? options.headers : {}),
},
};
const lib = url.startsWith("https") ? require("https") : require("http");
lib
.get(url, reqOptions, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return fetchHTML(res.headers.location, options).then(resolve);
}
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve(data));
})
.on("error", () => resolve(null));
});
}
function postJSON(url, body, options) {
return new Promise((resolve) => {
const data = JSON.stringify(body);
const parsed = new URL(url);
const timeout = (options && options.timeout) || 10000;
const reqOptions = {
hostname: parsed.hostname,
port: parsed.port || 443,
path: parsed.pathname + parsed.search,
method: "POST",
headers: {
"User-Agent": UA,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(data),
...(options && options.headers ? options.headers : {}),
},
};
let done = false;
const timer = setTimeout(() => { if (!done) { done = true; req.destroy(); resolve(null); } }, timeout);
const req = https.request(reqOptions, (res) => {
let chunks = "";
res.on("data", (chunk) => (chunks += chunk));
res.on("end", () => {
if (done) return;
done = true;
clearTimeout(timer);
try {
resolve(JSON.parse(chunks));
} catch (_e) {
resolve(null);
}
});
});
req.on("error", () => { if (!done) { done = true; clearTimeout(timer); resolve(null); } });
req.write(data);
req.end();
});
}
async function resolveASNames(providers) {
// Batch resolve AS names via RIPE Stat AS overview API
const batchSize = 10;
for (let i = 0; i < providers.length; i += batchSize) {
const batch = providers.slice(i, i + batchSize);
const results = await Promise.all(
batch.map(p =>
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + p.asn)
.then(r => ({ asn: p.asn, name: r?.data?.holder || "" }))
.catch(() => ({ asn: p.asn, name: "" }))
)
);
results.forEach(r => {
const provider = providers.find(p => p.asn === r.asn);
if (provider && r.name) provider.name = r.name;
});
}
return providers;
}
// RPKI per-prefix validation — uses local ROA store (instant, no API calls)
// Falls back to RIPE Stat only if ROA store is not yet loaded (cold start)
function fetchRPKIPerPrefix(asn, prefix) {
// Try local ROA store first (sub-millisecond)
const local = roaStore.validate(asn, prefix);
if (local !== null) return Promise.resolve(local);
// Fallback: RIPE Stat API (only during cold start before first feed load)
return fetchRipeStatCached(
"https://stat.ripe.net/data/rpki-validation/data.json?resource=AS" +
asn + "&prefix=" + encodeURIComponent(prefix)
).then((r) => {
const status = r?.data?.status || "not_found";
const validating = r?.data?.validating_roas || [];
return { prefix, status, validating_roas: validating.length };
});
}
// Validate RPKI for a prefix — local ROA store (instant) or RIPE Stat fallback
async function validateRPKIWithCache(asn, prefix) {
try {
return await fetchRPKIPerPrefix(asn, prefix);
} catch (_e) {
return { prefix, status: "not_found", validating_roas: 0 };
}
}
// ============================================================
// RFC-Compliant ASPA Verification Engine
// ============================================================
// Check if AS path contains AS_SET segments (curly braces indicate sets)
function hasAsSet(asPath) {
if (typeof asPath === "string") {
return asPath.includes("{") || asPath.includes("}");
}
return false;
}
// Hop Check function (core of ASPA verification)
// aspaStore = Map<number, Set<number>> (CAS -> provider set)
function hopCheck(asI, asJ, aspaStore) {
const providers = aspaStore.get(asI);
if (!providers) return "NoAttestation";
return providers.has(asJ) ? "ProviderPlus" : "NotProviderPlus";
}
// Collapse AS path prepends (remove consecutive duplicates)
function collapsePrepends(path) {
return path.filter((as, i) => i === 0 || as !== path[i - 1]);
}
// Upstream Verification (RFC Section 6.1)
function verifyUpstream(asPath, aspaStore, rawPathStr) {
if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
const collapsed = collapsePrepends(asPath);
if (collapsed.length <= 1) return { result: "Valid", reason: "Single-hop path" };
const hops = [];
let hasNoAttestation = false;
for (let i = 1; i < collapsed.length; i++) {
const check = hopCheck(collapsed[i - 1], collapsed[i], aspaStore);
hops.push({
from: collapsed[i - 1],
to: collapsed[i],
result: check,
});
if (check === "NotProviderPlus") {
return { result: "Invalid", reason: "Hop AS" + collapsed[i - 1] + " -> AS" + collapsed[i] + " is NotProviderPlus", hops };
}
if (check === "NoAttestation") hasNoAttestation = true;
}
return {
result: hasNoAttestation ? "Unknown" : "Valid",
reason: hasNoAttestation ? "Some hops lack ASPA attestation" : "All hops verified as ProviderPlus",
hops,
};
}
// Downstream Verification (RFC Section 6.2)
function verifyDownstream(asPath, aspaStore, rawPathStr) {
if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
const collapsed = collapsePrepends(asPath);
const N = collapsed.length;
if (N <= 2) return { result: "Valid", reason: "Path length <= 2" };
const hops = [];
for (let i = 1; i < N; i++) {
hops.push({
from: collapsed[i - 1],
to: collapsed[i],
result: hopCheck(collapsed[i - 1], collapsed[i], aspaStore),
});
}
// Find u_min: first index where forward hop is NotProviderPlus
let uMin = N + 1;
for (let u = 1; u < N; u++) {
if (hopCheck(collapsed[u - 1], collapsed[u], aspaStore) === "NotProviderPlus") {
uMin = u;
break;
}
}
// Find v_max: last index where reverse hop is NotProviderPlus
let vMax = 0;
for (let v = N - 2; v >= 0; v--) {
if (hopCheck(collapsed[v + 1], collapsed[v], aspaStore) === "NotProviderPlus") {
vMax = v;
break;
}
}
if (uMin <= vMax) {
return { result: "Invalid", reason: "uMin(" + uMin + ") <= vMax(" + vMax + "): valley detected", hops };
}
// Compute up-ramp K
let K = 0;
for (let i = 1; i < N; i++) {
if (hopCheck(collapsed[i - 1], collapsed[i], aspaStore) === "ProviderPlus") {
K = i;
} else {
break;
}
}
// Compute down-ramp L
let L = N - 1;
for (let j = N - 2; j >= 0; j--) {
if (hopCheck(collapsed[j + 1], collapsed[j], aspaStore) === "ProviderPlus") {
L = j;
} else {
break;
}
}
const gap = L - K;
if (gap <= 1) {
return { result: "Valid", reason: "Valid up-down path (K=" + K + ", L=" + L + ")", hops };
}
return { result: "Unknown", reason: "Gap between up-ramp and down-ramp (K=" + K + ", L=" + L + ", gap=" + gap + ")", hops };
}
// Valley Detection: scan path for up-down-up pattern (route leak indicator)
function detectValleys(asPath, aspaStore) {
const collapsed = collapsePrepends(asPath);
if (collapsed.length < 4) return [];
const valleys = [];
// Walk the path and look at relationship transitions
const relationships = [];
for (let i = 1; i < collapsed.length; i++) {
const fwd = hopCheck(collapsed[i - 1], collapsed[i], aspaStore);
const rev = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
let rel = "unknown";
if (fwd === "ProviderPlus") rel = "customer-to-provider";
else if (rev === "ProviderPlus") rel = "provider-to-customer";
else if (fwd === "NotProviderPlus" && rev === "NotProviderPlus") rel = "peer-to-peer";
relationships.push({ from: collapsed[i - 1], to: collapsed[i], rel });
}
// Detect c2p -> p2c -> c2p pattern
for (let i = 0; i < relationships.length - 2; i++) {
if (
relationships[i].rel === "customer-to-provider" &&
relationships[i + 1].rel === "provider-to-customer" &&
relationships[i + 2].rel === "customer-to-provider"
) {
valleys.push({
position: i,
path_segment: [
relationships[i].from,
relationships[i].to,
relationships[i + 1].to,
relationships[i + 2].to,
].map((a) => "AS" + a),
description:
"Route leak: AS" + relationships[i].from + " -> AS" + relationships[i].to +
" (c2p) -> AS" + relationships[i + 1].to +
" (p2c) -> AS" + relationships[i + 2].to + " (c2p)",
});
}
}
return valleys;
}
// Build ASPA store from detected provider relationships
function buildAspaStore(detectedProviders, targetAsn) {
const store = new Map();
// Add the target ASN's providers
if (detectedProviders.length > 0) {
const providerSet = new Set(detectedProviders.map((p) => p.asn));
store.set(targetAsn, providerSet);
}
return store;
}
// Calculate ASPA Readiness Score (0-100)
function calculateAspaReadinessScore(params) {
const { rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct } = params;
// ROA coverage (0-25 points)
const roaScore = Math.round((Math.min(rpkiCoverage, 100) / 100) * 25);
// ASPA object exists (0-25 points)
const aspaScore = aspaObjectExists ? 25 : 0;
// Provider completeness (0-25 points)
const provScore = Math.round((Math.min(providerCompleteness, 100) / 100) * 25);
// Path validation results (0-25 points)
const pathScore = Math.round((Math.min(pathValidationPct, 100) / 100) * 25);
return {
total: roaScore + aspaScore + provScore + pathScore,
breakdown: {
roa_coverage: { score: roaScore, max: 25, value: rpkiCoverage },
aspa_object: { score: aspaScore, max: 25, value: aspaObjectExists },
provider_completeness: { score: provScore, max: 25, value: providerCompleteness },
path_validation: { score: pathScore, max: 25, value: pathValidationPct },
},
};
}
// ============================================================
// Feature 30: RIPE NCC RPKI Validator cross-check (max 5 prefixes)
// ============================================================
async function fetchRipeRpkiValidator(asn, prefix) {
try {
const encoded = encodeURIComponent(prefix);
const url = "https://rpki-validator.ripe.net/api/v1/validity/AS" + asn + "/" + encoded;
const result = await fetchJSON(url, { timeout: 5000 });
if (result && result.validated_route) {
return {
prefix: prefix,
validity: result.validated_route.validity || {},
state: (result.validated_route.validity && result.validated_route.validity.state) || "unknown",
};
}
return { prefix: prefix, state: "unknown", error: "no_data" };
} catch (_e) {
return { prefix: prefix, state: "error", error: "timeout_or_unavailable" };
}
}
// Cross-check a sample of prefixes against RIPE RPKI Validator (max 5, in parallel)
async function crossCheckRpki(asn, prefixes, localResults) {
const sample = prefixes.slice(0, 5);
if (sample.length === 0) return { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: 100, disagreements: [], sample_size: 0 };
const ripeResults = await Promise.all(
sample.map((pfx) => fetchRipeRpkiValidator(asn, pfx))
);
const localMap = new Map();
for (const lr of localResults) {
localMap.set(lr.prefix, lr.status);
}
let cloudflareValid = 0;
let ripeValid = 0;
let agreements = 0;
const disagreements = [];
for (let i = 0; i < sample.length; i++) {
const pfx = sample[i];
const cfStatus = localMap.get(pfx) || "not_found";
const ripeState = ripeResults[i].state;
const cfIsValid = cfStatus === "valid";
const ripeIsValid = ripeState === "valid" || ripeState === "VALID";
if (cfIsValid) cloudflareValid++;
if (ripeIsValid) ripeValid++;
// Skip comparison if RIPE returned error/unknown
if (ripeState === "error" || ripeState === "unknown") {
agreements++; // Don't count failed lookups as disagreements
continue;
}
if (cfIsValid === ripeIsValid) {
agreements++;
} else {
disagreements.push({
prefix: pfx,
cloudflare: cfStatus,
ripe: ripeState,
});
}
}
const agreementPct = sample.length > 0 ? Math.round((agreements / sample.length) * 100) : 100;
return { cloudflare_valid: cloudflareValid, ripe_valid: ripeValid, agreement_pct: agreementPct, disagreements: disagreements, sample_size: sample.length };
}
// ============================================================
// Feature 24: bgp.he.net Integration
// ============================================================
async function fetchBgpHeNet(asn) {
try {
const html = await fetchHTML("https://bgp.he.net/AS" + asn);
if (!html) return null;
const result = {};
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
if (titleMatch) result.title = titleMatch[1].trim();
const peerMatch = html.match(/BGP\s+Peers\s+Observed\s*\(all\)\s*:\s*(\d[\d,]*)/i) || html.match(/Observed\s+Peers[^<]*<[^>]*>\s*(\d+)/i);
if (peerMatch) result.peer_count = parseInt(peerMatch[1].replace(/,/g, ''));
const countryMatch = html.match(/Country[^<]*<[^>]*>[^<]*<[^>]*>\s*<[^>]*>([^<]+)/i);
if (countryMatch) result.country = countryMatch[1].trim();
// Extract 2-letter country code from href="/country/XX"
const ccMatch = html.match(/href="\/country\/([A-Z]{2})"/i);
if (ccMatch) result.country_code = ccMatch[1].toUpperCase();
// Extract clean AS name from title: "AS12345 Some Name - bgp.he.net" → "Some Name"
if (titleMatch) {
const rawTitle = titleMatch[1].trim();
const nameFromTitle = rawTitle.replace(/^AS\d+\s+/i, '').replace(/\s+-\s+bgp\.he\.net.*$/i, '').trim();
if (nameFromTitle && !nameFromTitle.toLowerCase().includes('bgp.he.net')) {
result.name_from_title = nameFromTitle;
}
}
const lgMatch = html.match(/Looking\s+Glass[^<]*<[^>]*href="([^"]+)"/i);
if (lgMatch) result.looking_glass = lgMatch[1];
const descMatch = html.match(/AS\s+Name[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
if (descMatch) result.description = descMatch[1].trim();
const irrMatch = html.match(/IRR\s+Record[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
if (irrMatch) result.irr_record = irrMatch[1].trim();
// bgp.he.net format: "Prefixes Originated (v4): 147<br/>" or "Prefixes v4 ... <td>147"
const v4Match = html.match(/Prefixes\s+Originated\s*\(v4\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v4[^<]*<[^>]*>\s*(\d+)/i);
if (v4Match) result.prefixes_v4 = parseInt(v4Match[1].replace(/,/g, ''));
const v6Match = html.match(/Prefixes\s+Originated\s*\(v6\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v6[^<]*<[^>]*>\s*(\d+)/i);
if (v6Match) result.prefixes_v6 = parseInt(v6Match[1].replace(/,/g, ''));
const allMatch = html.match(/Prefixes\s+Originated\s*\(all\)\s*:\s*(\d[\d,]*)/i);
if (allMatch) result.prefixes_all = parseInt(allMatch[1].replace(/,/g, ''));
result.source_url = "https://bgp.he.net/AS" + asn;
return result;
} catch (_e) {
return null;
}
}
// ============================================================
// Feature 25: Topology / AS-Relationships
// ============================================================
async function fetchTopology(targetAsn, depth) {
const maxDepth = Math.min(depth || 2, 3);
const nodes = new Map();
const edges = [];
async function fetchNeighboursForAsn(asn, currentDepth) {
if (nodes.has(asn) && nodes.get(asn).depth <= currentDepth) return;
const [data, overview] = await Promise.all([
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn),
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn),
]);
const name = overview?.data?.holder || "";
const neighbours = data?.data?.neighbours || [];
const upstreams = neighbours.filter((n) => n.type === "left");
const downstreams = neighbours.filter((n) => n.type === "right");
const peers = neighbours.filter((n) => n.type === "uncertain" || n.type === "peer");
const nodeType = asn === targetAsn ? "target" : currentDepth === 1 ? "direct" : "indirect";
nodes.set(asn, { asn, name, type: nodeType, depth: currentDepth });
upstreams.forEach((n) => {
if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "upstream", depth: currentDepth + 1 });
edges.push({ from: n.asn, to: asn, relationship: "provider-to-customer" });
});
downstreams.forEach((n) => {
if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "downstream", depth: currentDepth + 1 });
edges.push({ from: asn, to: n.asn, relationship: "provider-to-customer" });
});
peers.slice(0, 10).forEach((n) => {
if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "peer", depth: currentDepth + 1 });
edges.push({ from: asn, to: n.asn, relationship: "peer" });
});
if (currentDepth < maxDepth && upstreams.length > 0) {
const top5 = upstreams.sort((a, b) => (b.power || 0) - (a.power || 0)).slice(0, 5);
await Promise.all(top5.map((u) => fetchNeighboursForAsn(u.asn, currentDepth + 1)));
}
}
await fetchNeighboursForAsn(targetAsn, 0);
const edgeSet = new Set();
const uniqueEdges = edges.filter((e) => {
const key = e.from + "-" + e.to + "-" + e.relationship;
if (edgeSet.has(key)) return false;
edgeSet.add(key);
return true;
});
return { nodes: [...nodes.values()], edges: uniqueEdges, target_asn: targetAsn, depth: maxDepth };
}
// ============================================================
// Feature 27: WHOIS via RIPE DB
// ============================================================
// ── Resilience Score ─────────────────────────────────────────────────────────
// Weighted: Transit Diversity 30%, Peering Breadth 25%, IXP Presence 20%, Path Redundancy 25%
// Hard cap at 5.0 when single transit provider detected.
// Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB.
function computeResilienceScore(upstreams, peers, ixConnections, prefixes) {
const upstreamCount = upstreams.length;
const peerCount = peers.length;
const ixCount = [...new Set(ixConnections.map(c => c.ix_id).filter(Boolean))].length;
const prefixCount = prefixes.length;
// Transit Diversity (0-10)
let transitRaw = 0;
if (upstreamCount === 0) transitRaw = 0;
else if (upstreamCount === 1) transitRaw = 2;
else if (upstreamCount === 2) transitRaw = 5;
else if (upstreamCount <= 4) transitRaw = 7;
else transitRaw = 10;
// Peering Breadth (0-10)
let peeringRaw = 0;
if (peerCount >= 100) peeringRaw = 10;
else if (peerCount >= 50) peeringRaw = 8;
else if (peerCount >= 20) peeringRaw = 6;
else if (peerCount >= 5) peeringRaw = 4;
else if (peerCount >= 1) peeringRaw = 2;
// IXP Presence (0-10)
let ixpRaw = 0;
if (ixCount >= 10) ixpRaw = 10;
else if (ixCount >= 6) ixpRaw = 8;
else if (ixCount >= 3) ixpRaw = 6;
else if (ixCount >= 1) ixpRaw = 4;
// Path Redundancy (0-10) — proxy: prefix diversity + upstream + IXP combination
let pathRaw = 0;
if (upstreamCount >= 2 && ixCount >= 1) pathRaw = 10;
else if (upstreamCount >= 2) pathRaw = 7;
else if (ixCount >= 2) pathRaw = 6;
else if (upstreamCount === 1) pathRaw = 3;
else if (prefixCount > 0) pathRaw = 1;
const weighted =
transitRaw * 0.30 +
peeringRaw * 0.25 +
ixpRaw * 0.20 +
pathRaw * 0.25;
const singleTransitCap = upstreamCount === 1;
let score = Math.round(weighted * 10) / 10;
if (singleTransitCap) score = Math.min(score, 5.0);
score = Math.max(1.0, Math.min(10.0, score));
// Only return null if truly no data at all
if (upstreamCount === 0 && peerCount === 0 && ixCount === 0 && prefixCount === 0) {
return null;
}
return {
score,
breakdown: {
transit_diversity: { raw: transitRaw, weighted: Math.round(transitRaw * 0.30 * 10) / 10, upstream_count: upstreamCount },
peering_breadth: { raw: peeringRaw, weighted: Math.round(peeringRaw * 0.25 * 10) / 10, peer_count: peerCount },
ixp_presence: { raw: ixpRaw, weighted: Math.round(ixpRaw * 0.20 * 10) / 10, unique_ixps: ixCount },
path_redundancy: { raw: pathRaw, weighted: Math.round(pathRaw * 0.25 * 10) / 10, prefix_count: prefixCount },
},
single_transit_cap_applied: singleTransitCap,
_provenance: {
source: "RIPE Stat asn-neighbours + PeeringDB netixlan",
validation: "cross-validated",
confidence: "high",
note: "All inputs independently validated daily against external sources",
},
};
}
// ── Route Leak Detection ─────────────────────────────────────────────────────
// Heuristic: detects suspicious routing relationships using RIPE Stat neighbour data.
// NOT real-time. False positives possible for large networks with many Tier-1 relationships.
// Confidence: MEDIUM — pattern-based, not path-level analysis.
function computeRouteLeakDetection(upstreams, downstreams, peers) {
const upstreamAsns = new Set(upstreams.map(n => n.asn));
const downstreamAsns = new Set(downstreams.map(n => n.asn));
const tier1Upstreams = upstreams.filter(n => TIER1_ASNS.has(n.asn));
const tier1Downstreams = downstreams.filter(n => TIER1_ASNS.has(n.asn));
const patterns = [];
// Pattern A: Tier-1 appearing as BOTH upstream AND downstream → sandwich candidate
const sandwich = tier1Upstreams.filter(n => downstreamAsns.has(n.asn));
sandwich.forEach(n => {
patterns.push({
type: "sandwich_candidate",
asn: n.asn,
name: n.name,
description: `AS${n.asn} (${n.name}) appears as both upstream and downstream — possible route leak vector`,
});
});
// Pattern B: Tier-1 as downstream (re-originating routes to Tier-1s)
tier1Downstreams.forEach(n => {
if (!upstreamAsns.has(n.asn)) {
patterns.push({
type: "tier1_downstream",
asn: n.asn,
name: n.name,
description: `AS${n.asn} (${n.name}) is a downstream — unusual for a Tier-1, may indicate leaked routes`,
});
}
});
const detected = patterns.length > 0;
return {
detected,
patterns,
tier1_upstream_count: tier1Upstreams.length,
tier1_downstream_count: tier1Downstreams.length,
_provenance: {
source: "RIPE Stat asn-neighbours",
validation: "heuristic",
confidence: "medium",
note: "Pattern-based detection only. Not real-time (15-min RIPE RIS snapshot). False positives possible for large networks with legitimate Tier-1 relationships.",
},
};
}
async function fetchWhois(resource) {
const result = { resource, type: null, data: null, error: null };
try {
const trimmed = resource.trim();
if (/^(AS)?\d+$/i.test(trimmed)) {
result.type = "aut-num";
const asn = trimmed.replace(/^AS/i, "");
// Check cache first
const cached = whoisCacheGet(asn);
if (cached !== undefined) {
result.data = cached;
if (!cached) result.error = "Not found in any RIR database (cached)";
return result;
}
// Try RIPE first
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=AS" + asn + "&type-filter=aut-num&source=ripe", { timeout: 5000 }).catch(() => null);
if (ripeData && ripeData.objects && ripeData.objects.object) {
const obj = ripeData.objects.object[0];
const attrs = obj.attributes?.attribute || [];
const parsed = {};
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
result.data = {
aut_num: (parsed["aut-num"] || [])[0] || "",
as_name: (parsed["as-name"] || [])[0] || "",
descr: parsed["descr"] || [],
org: (parsed["org"] || [])[0] || "",
admin_c: parsed["admin-c"] || [],
tech_c: parsed["tech-c"] || [],
mnt_by: parsed["mnt-by"] || [],
status: (parsed["status"] || [])[0] || "",
created: (parsed["created"] || [])[0] || "",
last_modified: (parsed["last-modified"] || [])[0] || "",
source: (parsed["source"] || [])[0] || "",
import: parsed["import"] || [],
export: parsed["export"] || [],
remarks: parsed["remarks"] || [],
};
whoisCacheSet(asn, result.data);
}
// If RIPE didn't find it, try all other RIRs via RDAP in parallel (3s timeout)
if (!result.data) {
const rdapEndpoints = [
{ name: "APNIC", url: "https://rdap.apnic.net/autnum/" + asn },
{ name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + asn },
{ name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + asn },
{ name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn },
];
const rdapResults = await Promise.all(rdapEndpoints.map((ep) =>
fetchJSON(ep.url, { timeout: 3000 }).then((d) => {
if (!d || d.errorCode || !d.handle) return null;
return { source: ep.name, data: d };
}).catch(() => null)
));
const found = rdapResults.find((r) => r !== null);
if (found) {
const d = found.data;
const remarks = (d.remarks || []).map((r) => (r.description || []).join(" "));
const entities = d.entities || [];
const adminContacts = entities.filter((e) => (e.roles || []).includes("administrative")).map((e) => e.handle || "");
const techContacts = entities.filter((e) => (e.roles || []).includes("technical")).map((e) => e.handle || "");
const events = d.events || [];
const created = (events.find((e) => e.eventAction === "registration") || {}).eventDate || "";
const lastMod = (events.find((e) => e.eventAction === "last changed") || {}).eventDate || "";
result.data = {
aut_num: "AS" + asn,
as_name: d.name || "",
descr: remarks,
org: (entities.find((e) => (e.roles || []).includes("registrant")) || {}).handle || "",
admin_c: adminContacts,
tech_c: techContacts,
mnt_by: [],
status: (d.status || []).join(", "),
created: created,
last_modified: lastMod,
source: found.source + " (RDAP)",
import: [],
export: [],
remarks: remarks,
};
whoisCacheSet(asn, result.data);
} else {
result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)";
whoisCacheSet(asn, null); // cache miss to avoid repeated hammering
}
}
} else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) {
result.type = "inetnum";
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=inetnum,inet6num");
if (ripeData && ripeData.objects && ripeData.objects.object) {
const results = ripeData.objects.object.map((obj) => {
const attrs = obj.attributes?.attribute || [];
const parsed = {};
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
return {
inetnum: (parsed["inetnum"] || parsed["inet6num"] || [])[0] || "",
netname: (parsed["netname"] || [])[0] || "",
descr: parsed["descr"] || [],
country: (parsed["country"] || [])[0] || "",
org: (parsed["org"] || [])[0] || "",
admin_c: parsed["admin-c"] || [],
tech_c: parsed["tech-c"] || [],
mnt_by: parsed["mnt-by"] || [],
status: (parsed["status"] || [])[0] || "",
created: (parsed["created"] || [])[0] || "",
last_modified: (parsed["last-modified"] || [])[0] || "",
source: (parsed["source"] || [])[0] || "",
};
});
result.data = results.length === 1 ? results[0] : results;
} else { result.error = "Not found in RIPE DB"; }
} else {
result.type = "domain";
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=domain");
if (ripeData && ripeData.objects && ripeData.objects.object) {
const obj = ripeData.objects.object[0];
const attrs = obj.attributes?.attribute || [];
const parsed = {};
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
result.data = {
domain: (parsed["domain"] || [])[0] || "",
descr: parsed["descr"] || [],
admin_c: parsed["admin-c"] || [],
tech_c: parsed["tech-c"] || [],
zone_c: parsed["zone-c"] || [],
nserver: parsed["nserver"] || [],
mnt_by: parsed["mnt-by"] || [],
created: (parsed["created"] || [])[0] || "",
last_modified: (parsed["last-modified"] || [])[0] || "",
source: (parsed["source"] || [])[0] || "",
};
} else { result.error = "Not found in RIPE DB"; }
}
} catch (err) { result.error = err.message; }
return result;
}
// ============================================================
// 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 (!atlasProbeCache) {
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(atlasProbeCache, 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 (!atlasProbeCache) {
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(atlasProbeCache.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 and ASPA metrics
if (reqPath === "/api/health") {
const mem = process.memoryUsage();
const roaAge = roaStore.lastBuild ? Math.floor((Date.now() - roaStore.lastBuild) / 60000) : -1;
const aspaAge = rpkiAspaLastFetch ? Math.floor((Date.now() - rpkiAspaLastFetch) / 60000) : -1;
const pdbTotal = pdbSourceCache.hits + pdbSourceCache.misses;
const status = roaStore.ready && aspaAge < 300 ? "ok" : "degraded";
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 },
},
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,
},
})
);
}
// ============================================================
// 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)
const aspaStore = new Map();
// Add the target ASN's RPKI-declared providers
if (aspaObjectExists) {
aspaStore.set(targetAsn, new Set(aspaDeclaredProviders));
} else {
// Fallback: use detected providers for path verification
const providerSet = new Set(detectedProviders.map((p) => p.asn));
aspaStore.set(targetAsn, providerSet);
}
// 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);
}
}
// Also add reverse relationships for providers we know about
// (each provider has the target as customer)
detectedProviders.forEach((p) => {
if (!aspaStore.has(p.asn)) {
aspaStore.set(p.asn, new Set());
}
});
// 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)));
const rpkiValid = rpkiResults.filter((r) => r.status === "valid").length;
const rpkiCoverage = rpkiResults.length > 0 ? Math.round((rpkiValid / rpkiResults.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,
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_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 }));
}
}
}
// ============================================================
// bgproutes.io endpoint: /api/bgproutes?asn=X (or prefix=X)
// ============================================================
if (reqPath === "/api/bgproutes") {
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 cacheKeyBgr = rawAsn || prefix;
const cachedBgr = resultCacheGet(bgproutesResultCache, cacheKeyBgr);
if (cachedBgr !== undefined) {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(cachedBgr));
}
const start = Date.now();
try {
const result = { meta: { timestamp: new Date().toISOString() }, vantage_points: null, routes: null };
// Use module-level vantage_points cache (1h TTL) to prevent 429 flooding
let vpData = null;
if (bgproutesVpCache && (Date.now() - bgproutesVpCacheTs) < BGPROUTES_VP_TTL) {
vpData = bgproutesVpCache;
} else {
vpData = await fetchJSON(BGPROUTES_API_URL + "/vantage_points", {
headers: { "x-api-key": BGPROUTES_API_KEY },
timeout: 10000,
});
if (vpData && !vpData.error) { bgproutesVpCache = vpData; bgproutesVpCacheTs = Date.now(); }
}
if (vpData && !vpData.error) {
const vpList = vpData?.data?.bgp || (Array.isArray(vpData) ? vpData : vpData.data || []);
const readyVPs = Array.isArray(vpList) ? vpList.filter((vp) => !vp.status || (Array.isArray(vp.status) && vp.status.includes("ready"))) : [];
result.vantage_points = {
count: readyVPs.length,
total: Array.isArray(vpList) ? vpList.length : 0,
list: readyVPs.slice(0, 20).map((vp) => ({
id: vp.id,
asn: vp.asn,
ip: vp.ip,
source: vp.source || "",
org_name: vp.org_name || "",
country: vp.org_country || vp.country || "",
rib_v4: vp.rib_size_v4 || 0,
rib_v6: vp.rib_size_v6 || 0,
})),
};
} else {
result.vantage_points = { count: 0, error: "Could not fetch vantage points" };
}
let ribSuccess = false;
const readyVPsForRib = result.vantage_points && result.vantage_points.list
? result.vantage_points.list.filter((vp) => vp.rib_v4 > 500000).slice(0, 1)
: [];
if (readyVPsForRib.length > 0) {
const vpId = readyVPsForRib[0].id;
const now = new Date().toISOString().replace(/\.\d+Z$/, "");
const ribBody = {
vp_bgp_ids: String(vpId),
date: now,
return_aspath: true,
return_rov_status: true,
return_aspa_status: true,
};
if (prefix) {
ribBody.prefix_exact_match = prefix;
} else if (rawAsn) {
ribBody.aspath_regexp = rawAsn + "$";
}
try {
const ribData = await postJSON(BGPROUTES_API_URL + "/rib", ribBody, {
headers: { "x-api-key": BGPROUTES_API_KEY },
timeout: 6000,
});
if (ribData && ribData.data) {
const bgpData = ribData.data.bgp || {};
const vpRoutes = bgpData[String(vpId)] || {};
const routeEntries = Object.entries(vpRoutes).map(([pfx, arr]) => {
const asPath = Array.isArray(arr) ? arr[0] || "" : "";
const rovStatus = Array.isArray(arr) ? arr[2] || "" : "";
const aspaStatus = Array.isArray(arr) ? arr[3] || "" : "";
return {
prefix: pfx,
as_path: asPath,
rov_status: (function(rs) {
var parts = rs.split(",").map(function(s) { return s === "V" ? "valid" : s === "I" ? "invalid" : s === "U" ? "unknown" : s; });
if (parts.indexOf("invalid") >= 0) return "invalid";
if (parts.indexOf("unknown") >= 0) return "unknown";
if (parts.indexOf("valid") >= 0) return "valid";
return parts[0] || "unknown";
})(rovStatus),
aspa_status: (function(as) {
var parts = as.split(",").map(function(s) { return s === "V" ? "valid" : s === "I" ? "invalid" : s === "U" ? "unknown" : s; });
if (parts.indexOf("invalid") >= 0) return "invalid";
if (parts.indexOf("unknown") >= 0) return "unknown";
if (parts.indexOf("valid") >= 0) return "valid";
return parts[0] || "unknown";
})(aspaStatus),
};
});
if (routeEntries.length > 0) {
result.routes = {
count: routeEntries.length,
vp_used: { id: vpId, org: readyVPsForRib[0].org_name, country: readyVPsForRib[0].country },
sample: routeEntries.slice(0, 20),
};
ribSuccess = true;
}
}
} catch (_e) {}
}
if (!ribSuccess) {
result.routes = {
status: "unavailable",
message: readyVPsForRib.length === 0
? "No ready VPs with sufficient RIB size found"
: "bgproutes.io: VPs available but RIB query returned no data for this ASN",
};
}
result.meta.duration_ms = Date.now() - start;
resultCacheSet(bgproutesResultCache, cacheKeyBgr, result);
return res.end(JSON.stringify(result, null, 2));
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "bgproutes.io 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);
var bogonResult = {
status: bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail",
bogon_prefixes: bogonPrefixes,
bogon_asns_in_paths: bogonAsns,
total_prefixes_checked: allPrefixes.length,
};
// Phase 2: All API-dependent validations in parallel
var validationPromises = {};
// 12. IRR Validation
validationPromises.irr = fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) {
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 withRoa = rpkiResults.filter(function(r) { return r.status === "valid"; });
var coverage = rpkiResults.length > 0 ? Math.round((withRoa.length / rpkiResults.length) * 100) : 0;
var overSpecific = rpkiResults.filter(function(r) {
var mask = parseInt((r.prefix || "").split("/")[1] || "0");
return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid";
});
return {
status: coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail",
coverage_pct: coverage,
total_checked: rpkiResults.length,
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) {
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) {
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, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) };
}).catch(function() { return { prefix: pfx, listed: false, error: true }; });
})
).then(function(results) {
var listedPrefixes = results.filter(function(r) { return r.listed; });
return { status: listedPrefixes.length === 0 ? "pass" : "fail", checked: results.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) {
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 withRdns = results.filter(function(r) { return r.has_rdns; });
var coverage = results.length > 0 ? Math.round((withRdns.length / results.length) * 100) : 0;
// Include details of what failed
var failedPrefixes = results.filter(function(r) { return !r.has_rdns && !r.error; }).map(function(r) { return r.prefix; });
return { status: coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail", coverage_pct: coverage, checked: results.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() {
return { status: "fail", 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 hasRoa = results.some(function(r) { return r.status === "valid" || r.validating_roas > 0; });
return { status: hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: results.length, roa_count: results.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", fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 8000 })),
timedFetch("RIPE Stat Neighbours", fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 8000 })),
timedFetch("RIPE Stat Overview", fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn)),
timedFetch("RIPE Stat RIR", fetchRipeStatCached("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn)),
timedFetch("RIPE Atlas", fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500")),
timedFetch("bgp.he.net", fetchBgpHeNet(asn)),
timedFetch("RIPE Stat Visibility", fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=AS" + asn, { timeout: 8000 })),
timedFetch("RIPE Stat PrefixSize", fetchRipeStatCached("https://stat.ripe.net/data/prefix-size-distribution/data.json?resource=AS" + asn)),
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);
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 rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid").length;
const rpkiTotal = rpkiStatuses.length;
const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0;
const 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);
const 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);
const 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))]);
}
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 = new Set(["US","CA","AI","AG","BS","BB","BZ","VG","KY","DM","DO","GD","GP","HT","JM","MQ","MS","PR","KN","LC","VC","TT","TC","VI","UM"]);
const APNIC_CC = new Set(["AU","NZ","JP","CN","KR","IN","HK","SG","TW","VN","TH","ID","MY","PK","BD","LK","NP","PH","AF","KH","LA","MM","MN","BT","BN","FJ","PG","WS","TO","VU","SB","KI","NR","TV","FM","MH","PW","CK","NU","TK","WF","PF","NC","GU","MP","AS","CC","CX","HM","NF"]);
const LACNIC_CC = new Set(["BR","AR","MX","CO","CL","PE","VE","EC","UY","BO","PY","CU","GT","HN","SV","NI","CR","PA","GY","SR","GF","AW","CW","SX","BQ","AN"]);
const AFRINIC_CC = new Set(["ZA","NG","KE","EG","GH","TZ","UG","MA","CI","SN","ZM","ZW","AO","MZ","CM","ET","SD","MG","DZ","TN","LY","RW","NA","BW","MW","ML","BF","NE","GN","TD","SO","LS","SZ","ER","DJ","GM","SL","LR","TG","BJ","GW","CF","CG","CD","GQ","ST","KM","MR","SC","MU","RE","CV","BU","SS","EH"]);
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: 100, disagreements: [], sample_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 */ }
// 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
const crossCheckScores = [];
// RPKI agreement
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)
: 100;
const overallConfidence = 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: 2, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, disagreements: rpkiCrossCheck.disagreements },
prefixes: { sources: 2, 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: 2, 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 = { 60: "zurich", 2601: "meppel", 24: "london", 35: "moscow", 15: "chicago", 11: "seattle", 387: "dublin", 171: "warsaw", 168: "bucharest", 71: "milan", 66: "vienna", 62: "prague", 1: "ashburn" };
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,
},
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,
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;
cacheSet(cacheKey, result, 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)),
]);
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,
});
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,
};
cacheSet(cacheKey, result, 10 * 60 * 1000); // 10 min cache
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] || "" }));
// 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 ROA store (instant) instead of RIPE Stat API call
let rpkiStatus = "unknown";
let rpkiRoas = [];
const originAsn = origins.length > 0 ? origins[0].asn : null;
if (originAsn) {
await ensureAspaCache();
const localRpki = roaStore.validate(originAsn, prefix);
if (localRpki) {
rpkiStatus = localRpki.status;
rpkiRoas = new Array(localRpki.validating_roas); // count only, no detail
} else {
// Fallback to RIPE Stat if ROA store not ready
const rpkiValid = await fetchRipeStatCached("https://stat.ripe.net/data/rpki-validation/data.json?resource=" + encodeURIComponent(prefix));
rpkiStatus = rpkiValid?.data?.status || "unknown";
rpkiRoas = rpkiValid?.data?.validating_roas || [];
}
}
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);
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)
if (reqPath === "/api/submarine-cables") {
const CABLE_TTL = 24 * 60 * 60 * 1000;
if (subCableCache && Date.now() - subCableCache.ts < CABLE_TTL) {
res.setHeader("Content-Type", "application/json");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "public, max-age=86400");
return res.end(subCableCache.data);
}
const cableData = await fetchJSONWithRetry("https://www.submarinecablemap.com/api/v3/cable/cable-geo.json", { timeout: 30000 });
if (cableData) {
subCableCache = { ts: Date.now(), data: JSON.stringify(cableData) };
res.setHeader("Content-Type", "application/json");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "public, max-age=86400");
return res.end(subCableCache.data);
}
res.writeHead(503);
return res.end(JSON.stringify({ error: "Submarine cable data unavailable" }));
}
// Feature 29: Global datacenter/IXP map (PeeringDB proxy)
if (reqPath === "/api/global-infra") {
const FAC_TTL = 24 * 60 * 60 * 1000;
if (globalFacCache && Date.now() - globalFacCache.ts < FAC_TTL) {
res.setHeader("Content-Type", "application/json");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "public, max-age=86400");
return res.end(globalFacCache.data);
}
const [facData, ixData] = await Promise.all([
fetchJSONWithRetry(PEERINGDB_API_URL + "/fac?depth=1&limit=3000", { timeout: 30000 }),
fetchJSONWithRetry(PEERINGDB_API_URL + "/ix?depth=1&limit=1000", { timeout: 30000 }),
]);
const facs = (facData && facData.data || [])
.filter(f => f.latitude && f.longitude)
.map(f => ({ id: f.id, name: f.name, city: f.city, country: f.country, lat: +f.latitude, lng: +f.longitude }));
const ixps = (ixData && ixData.data || [])
.filter(ix => ix.city && ix.country)
.map(ix => ({ id: ix.id, name: ix.name, city: ix.city, country: ix.country, website: ix.website }));
const result = JSON.stringify({ facs, ixps });
globalFacCache = { ts: Date.now(), data: result };
res.setHeader("Content-Type", "application/json");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "public, max-age=86400");
return res.end(result);
}
// ── 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') {
const params = new URL(req.url, 'http://localhost').searchParams;
const asn = params.get('asn') || '';
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=3600');
try {
const url = `https://stat.ripe.net/data/bgp-state/data.json?resource=AS${asn.replace('AS','')}`;
const data = await fetchJSON(url, { timeout: 6000 });
const rawComms = [];
if (data && data.data && data.data.bgp_state) {
for (const entry of data.data.bgp_state.slice(0, 50)) {
if (entry.community) rawComms.push(...entry.community);
}
}
const unique = [...new Set(rawComms.map(c => Array.isArray(c) ? c.join(':') : String(c)))];
const decoded = unique.map(k => ({ raw: k, known: BGP_COMMUNITY_DB[k] || null }));
res.writeHead(200);
return res.end(JSON.stringify({ asn, communities: decoded, db_size: Object.keys(BGP_COMMUNITY_DB).length }));
} catch(e) {
res.writeHead(200);
return res.end(JSON.stringify({ asn, communities: [], error: e.message }));
}
}
// ── IRR Audit ─────────────────────────────────────────────────
if (reqPath.startsWith('/api/irr-audit')) {
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', 'public, max-age=1800');
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
try {
// Use NLNOG IRR Explorer — covers RIPE, ARIN, APNIC, RPKI, and all major IRR databases
const nlnogData = await fetchJSONWithRetry(
'https://irrexplorer.nlnog.net/api/prefixes/asn/AS' + asn,
{ timeout: 20000 }
);
const prefixes = nlnogData && nlnogData.directOrigin || [];
var irrRoutes = [];
var irrDetails = [];
var goodCount = 0;
var warnCount = 0;
var errorCount = 0;
for (var i = 0; i < prefixes.length; i++) {
var pfx = prefixes[i];
var hasIrr = pfx.irrRoutes && Object.keys(pfx.irrRoutes).length > 0;
var sources = hasIrr ? Object.keys(pfx.irrRoutes) : [];
var cat = pfx.categoryOverall || 'unknown';
if (hasIrr) irrRoutes.push(pfx.prefix);
irrDetails.push({
prefix: pfx.prefix,
irr_sources: sources,
rpki_status: pfx.rpkiRoutes && pfx.rpkiRoutes.length ? pfx.rpkiRoutes[0].rpkiStatus : 'not-found',
category: cat,
messages: (pfx.messages || []).map(function(m){ return m.text; })
});
if (cat === 'success') goodCount++;
else if (cat === 'warning') warnCount++;
else errorCount++;
}
var actualPfx = prefixes.map(function(p){ return p.prefix; });
var inBgpNotIrr = actualPfx.filter(function(p){ return !irrRoutes.includes(p); });
var score = actualPfx.length ? Math.round(irrRoutes.length / actualPfx.length * 100) : 0;
res.writeHead(200);
return res.end(JSON.stringify({
asn: asn,
irr_routes: irrRoutes,
actual_prefixes: actualPfx,
in_irr_not_bgp: [],
in_bgp_not_irr: inBgpNotIrr,
score: score,
details: irrDetails,
summary: { good: goodCount, warning: warnCount, error: errorCount, total: prefixes.length },
source: 'NLNOG IRR Explorer'
}));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── AS-SET Expander ───────────────────────────────────────────
if (reqPath.startsWith('/api/asset-expand')) {
const params = new URL(req.url, 'http://localhost').searchParams;
const setName = params.get('set') || '';
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=3600');
if (!setName) { res.writeHead(400); return res.end(JSON.stringify({error:'set required (e.g. AS-FLEXOPTIX)'})); }
try {
async function expandSet(name, depth, visited) {
if (depth > 4 || visited.has(name)) return { asns: [], sets: [] };
visited.add(name);
const url = `https://rest.db.ripe.net/search.json?query-string=${encodeURIComponent(name)}&type-filter=as-set&flags=no-referenced`;
const data = await fetchJSONWithRetry(url, { timeout: 10000 });
const asns = [], sets = [];
if (data && data.objects && data.objects.object) {
for (const obj of data.objects.object) {
const attrs = obj.attributes && obj.attributes.attribute || [];
for (const a of attrs) {
if (a.name === 'members') {
for (const m of (a.value || '').split(/[,\s]+/).filter(Boolean)) {
if (/^AS\d+$/i.test(m)) asns.push(m.toUpperCase());
else if (m.startsWith('AS-')) { sets.push(m); }
}
}
}
}
}
for (const sub of sets.slice(0,10)) {
const sub_r = await expandSet(sub, depth+1, visited);
asns.push(...sub_r.asns);
}
return { asns: [...new Set(asns)], sets };
}
const visited = new Set();
const result = await expandSet(setName.toUpperCase(), 0, visited);
result.asns.sort((a,b) => parseInt(a.slice(2)) - parseInt(b.slice(2)));
res.writeHead(200);
return res.end(JSON.stringify({ set: setName.toUpperCase(), count: result.asns.length, asns: result.asns, sub_sets: result.sets }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── Routing History (prefix table via RIPE Stat routing-history) ──
if (reqPath.startsWith('/api/rpki-history')) {
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', 'public, max-age=3600');
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
try {
const url = 'https://stat.ripe.net/data/routing-history/data.json?resource=AS' + asn + '&max_rows=100';
const data = await fetchJSON(url, { timeout: 6000 });
const byOrigin = data && data.data && data.data.by_origin || [];
// Flatten: each origin entry has prefixes[]
var prefixes = [];
for (var i = 0; i < byOrigin.length; i++) {
var orig = byOrigin[i];
if (orig.prefixes) {
for (var j = 0; j < orig.prefixes.length; j++) {
prefixes.push(orig.prefixes[j]);
}
}
}
res.writeHead(200);
return res.end(JSON.stringify({ asn: asn, prefixes: prefixes, source: 'RIPE Stat routing-history' }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── AS-PATH Visualizer (RIPE Stat looking-glass) ────────────────
if (reqPath.startsWith('/api/aspath')) {
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', 'public, max-age=300');
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
try {
// Use RIPE Stat announced-prefixes to get prefixes, then looking-glass for paths
var annUrl = 'https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS' + asn;
var annData = await fetchJSON(annUrl, { timeout: 5000 });
var announced = annData && annData.data && annData.data.prefixes || [];
var prefix = announced.length > 0 ? announced[0].prefix : null;
if (!prefix) {
res.writeHead(200);
return res.end(JSON.stringify({ asn: asn, paths: [], source: 'RIPE Stat' }));
}
// Get looking-glass data for the first announced prefix
var lgUrl = 'https://stat.ripe.net/data/looking-glass/data.json?resource=' + encodeURIComponent(prefix);
var lgData = await fetchJSON(lgUrl, { timeout: 6000 });
var rrcs = lgData && lgData.data && lgData.data.rrcs || [];
var paths = [];
var seen = new Set();
for (var i = 0; i < rrcs.length && paths.length < 10; i++) {
var rrc = rrcs[i];
var peers = rrc.peers || [];
for (var j = 0; j < peers.length && paths.length < 10; j++) {
var p = peers[j];
var pathStr = (p.as_path || '').trim();
if (pathStr && !seen.has(pathStr)) {
seen.add(pathStr);
paths.push({
path: pathStr,
prefix: prefix,
rrc: rrc.rrc + ' (' + (rrc.location||'') + ')',
peer_asn: p.asn_origin || ''
});
}
}
}
res.writeHead(200);
return res.end(JSON.stringify({ asn: asn, paths: paths, prefix: prefix, source: 'RIPE Stat looking-glass · ' + prefix }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── Looking Glass (RIPE Stat) ─────────────────────────────────
if (reqPath.startsWith('/api/looking-glass')) {
const params = new URL(req.url, 'http://localhost').searchParams;
const resource = params.get('prefix') || params.get('asn') || '';
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
if (!resource) { res.writeHead(400); return res.end(JSON.stringify({error:'prefix or asn required'})); }
try {
const url = `https://stat.ripe.net/data/looking-glass/data.json?resource=${encodeURIComponent(resource)}`;
const data = await fetchJSON(url, { timeout: 6000 });
const rrcs = data && data.data && data.data.rrcs || [];
const results = rrcs.slice(0, 15).map(rrc => ({
rrc: rrc.rrc,
location: rrc.location,
peers: (rrc.peers || []).slice(0,5).map(p => ({
asn: p.asn_origin,
as_path: p.as_path,
community: p.community,
next_hop: p.next_hop,
}))
}));
res.writeHead(200);
return res.end(JSON.stringify({ resource, rrcs: results, total_rrcs: rrcs.length }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── IXP Peering Matrix ────────────────────────────────────────
if (reqPath.startsWith('/api/ix-matrix')) {
const params = new URL(req.url, 'http://localhost').searchParams;
const ixId = (params.get('ix_id') || '').replace(/[^0-9]/g,'');
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=3600');
if (!ixId) { res.writeHead(400); return res.end(JSON.stringify({error:'ix_id required'})); }
try {
const [netixData, ixData] = await Promise.all([
fetchJSONWithRetry(`${PEERINGDB_API_URL}/netixlan?ix_id=${ixId}&depth=1&limit=200`, { timeout: 15000 }),
fetchJSONWithRetry(`${PEERINGDB_API_URL}/ix/${ixId}`, { timeout: 10000 }),
]);
const ix = ixData && ixData.data && ixData.data[0];
const members = (netixData && netixData.data || []).map(m => ({
asn: m.asn, name: m.name, speed: m.speed, ipaddr4: m.ipaddr4, ipaddr6: m.ipaddr6, policy: m.policy_general
}));
members.sort((a,b) => (b.speed||0) - (a.speed||0));
res.writeHead(200);
return res.end(JSON.stringify({ ix_id: ixId, ix_name: ix && ix.name, ix_city: ix && ix.city, members, member_count: members.length }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── 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 => {
hijackSubs.push({ asn, 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);
subs.push({ asn: asnNum, 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 }));
}
// ── Changelog JSON API ────────────────────────────────────────
if (reqPath === '/changelog-data') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=3600');
try {
const md = fs.readFileSync('/opt/peercortex-app/CHANGELOG.md', 'utf8');
const entries = [];
let current = null;
let currentSection = null;
for (const line of md.split('\n')) {
// Support both ## [0.6.x] — date AND ## v0.6.x — date
const vMatch = line.match(/^## (?:v|\[)?([\d.]+)\]? — (.+)/);
if (vMatch) {
if (current) entries.push(current);
current = { version: vMatch[1], date: vMatch[2].trim(), sections: [] };
currentSection = null;
continue;
}
const sMatch = line.match(/^### (.+)/);
if (sMatch && current) {
currentSection = { name: sMatch[1], items: [] };
current.sections.push(currentSection);
continue;
}
const iMatch = line.match(/^- (.+)/);
if (iMatch && currentSection) {
currentSection.items.push(iMatch[1]);
}
}
if (current) entries.push(current);
res.writeHead(200);
return res.end(JSON.stringify(entries));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── bio-rd RIB: prefix lookup ──────────────────────────────────
if (reqPath === '/api/rib/prefix') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); }
const params = new URL(req.url, 'http://localhost').searchParams;
const prefix = params.get('prefix') || '';
const routerParam = params.get('router') || 'default';
if (!prefix) { res.writeHead(400); return res.end(JSON.stringify({ error: 'prefix required' })); }
try {
const t0 = Date.now();
const routers = await risClient.getRouters();
const routerName = (routerParam === 'default' && routers.length > 0) ? routers[0] : routerParam;
const [routes, longer] = await Promise.all([
risClient.lpm(routerName, prefix),
risClient.getLonger(routerName, prefix),
]);
res.writeHead(200);
return res.end(JSON.stringify({
prefix,
router: routerName,
routes: routes || [],
moreSpecifics: (longer || []).slice(0, 20),
source: 'bio-rd-local',
latencyMs: Date.now() - t0,
}));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({ error: e.message }));
}
}
// ── bio-rd RIB: list routers ───────────────────────────────────
if (reqPath === '/api/rib/routers') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); }
try {
const routers = await risClient.getRouters();
res.writeHead(200);
return res.end(JSON.stringify({ routers: routers || [], source: 'bio-rd-local' }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({ error: e.message }));
}
}
// ── bio-rd RIB: dump ───────────────────────────────────────────
if (reqPath === '/api/rib/dump') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); }
const params = new URL(req.url, 'http://localhost').searchParams;
const router = params.get('router') || '';
const asnFilter = params.get('asn') ? parseInt(params.get('asn')) : undefined;
const limit = Math.min(parseInt(params.get('limit') || '100', 10), 1000);
if (!router) { res.writeHead(400); return res.end(JSON.stringify({ error: 'router required' })); }
try {
const t0 = Date.now();
const allRoutes = await risClient.dumpRib(router, 'default', asnFilter);
const routes = (allRoutes || []).slice(0, limit);
res.writeHead(200);
return res.end(JSON.stringify({
router,
routes,
total: (allRoutes || []).length,
source: 'bio-rd-local',
latencyMs: Date.now() - t0,
}));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({ error: e.message }));
}
}
// ── Prefix Changes ──────────────────────────────────────────────
if (reqPath === '/api/prefix-changes') {
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, '');
if (!rawAsn) { res.writeHead(400); return res.end(JSON.stringify({ error: 'Missing ASN' })); }
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
const hoursParam = Math.min(parseInt(url.searchParams.get('hours') || '1', 10), 168);
let starttime, endtime;
if (fromParam && toParam) {
starttime = new Date(fromParam).toISOString();
endtime = new Date(toParam).toISOString();
} else {
endtime = new Date().toISOString();
starttime = new Date(Date.now() - hoursParam * 3600000).toISOString();
}
try {
const updUrl = `https://stat.ripe.net/data/bgp-updates/data.json?resource=AS${rawAsn}&starttime=${encodeURIComponent(starttime)}&endtime=${encodeURIComponent(endtime)}&limit=1000`;
const raw = await fetchJSON(updUrl, { timeout: 8000 });
const updates = (raw && raw.data && raw.data.updates && raw.data.updates.updates) || [];
const announcements = [], withdrawals = [], originChanges = [], rpkiIssues = [];
const lastOriginByPrefix = {}, rpkiSeen = new Set();
for (const u of updates) {
const prefix = u.attrs && u.attrs.prefix; if (!prefix) continue;
const originRaw = u.attrs && u.attrs.origin;
const origin = originRaw ? parseInt(String(originRaw).replace('AS', ''), 10) : null;
const ts = u.timestamp || '';
const peer = u.peer || '';
if (u.type === 'A') {
const rpki = (origin && roaStore.ready) ? roaStore.validate(origin, prefix) : null;
const rpkiStatus = rpki ? rpki.status : 'unknown';
announcements.push({ prefix, timestamp: ts, peer, origin, rpki_status: rpkiStatus });
if (lastOriginByPrefix[prefix] !== undefined && lastOriginByPrefix[prefix] !== origin) {
originChanges.push({ prefix, from_origin: lastOriginByPrefix[prefix], to_origin: origin, timestamp: ts, peer });
}
lastOriginByPrefix[prefix] = origin;
if (!rpkiSeen.has(prefix) && rpkiStatus !== 'valid') {
rpkiSeen.add(prefix);
rpkiIssues.push({ prefix, origin, rpki_status: rpkiStatus, timestamp: ts });
}
} else if (u.type === 'W') {
withdrawals.push({ prefix, timestamp: ts, peer });
}
}
res.writeHead(200);
return res.end(JSON.stringify({
asn: parseInt(rawAsn, 10),
time_range: { from: starttime, to: endtime },
total_updates: updates.length,
summary: { announcements: announcements.length, withdrawals: withdrawals.length, origin_changes: originChanges.length, rpki_issues: rpkiIssues.length },
announcements,
withdrawals,
origin_changes: originChanges,
rpki_issues: rpkiIssues,
}));
} catch(e) {
res.writeHead(500);
return res.end(JSON.stringify({ error: e.message }));
}
}
// ============================================================
// 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",
})
);
});
// ============================================================
// Atlas Probe Cache (for Lia's Atlas Paradise)
// ============================================================
let atlasProbeCache = null;
let atlasProbeFetching = false;
function fetchAllAtlasProbes() {
if (atlasProbeFetching) return Promise.resolve();
atlasProbeFetching = true;
console.log("[ATLAS] Fetching all Atlas probes...");
return new Promise(function(resolve) {
var allAsns = new Set();
var byCountry = {};
var pageCount = 0;
var maxPages = 40;
function fetchPage(pageUrl) {
if (pageCount >= maxPages) return finish();
pageCount++;
fetchJSON(pageUrl).then(function(data) {
if (!data || !data.results) return finish();
data.results.forEach(function(probe) {
var asn4 = probe.asn_v4;
var asn6 = probe.asn_v6;
var cc = probe.country_code || "XX";
if (!byCountry[cc]) byCountry[cc] = { total: 0, connected: 0, asnSet: new Set() };
byCountry[cc].total++;
if (probe.status && probe.status.id === 1) byCountry[cc].connected++;
if (asn4) { allAsns.add(asn4); byCountry[cc].asnSet.add(asn4); }
if (asn6) { allAsns.add(asn6); byCountry[cc].asnSet.add(asn6); }
});
if (data.next) {
fetchPage(data.next);
} else {
finish();
}
}).catch(function() { finish(); });
}
function finish() {
var byCountryOut = {};
Object.keys(byCountry).forEach(function(cc) {
var info = byCountry[cc];
byCountryOut[cc] = { total: info.total, connected: info.connected, asn_count: info.asnSet.size };
});
atlasProbeCache = {
total_probes: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].total; }, 0),
total_connected: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].connected; }, 0),
unique_asns_with_probes: allAsns.size,
asns_with_probes: Array.from(allAsns).sort(function(a, b) { return a - b; }),
by_country: byCountryOut,
fetched_at: new Date().toISOString(),
pages_fetched: pageCount,
};
console.log("[ATLAS] Loaded " + allAsns.size + " unique ASNs with probes (" + pageCount + " pages)");
atlasProbeFetching = false;
resolve();
}
fetchPage("https://atlas.ripe.net/api/v2/probes/?page_size=500&status=1&page=1&format=json");
});
}
// ============================================================
// 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);
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);
});