PeerCortex/server.js
Rene Fichtmueller 9038e280fa fix: bgp.he.net name+country fallback for unregistered ASNs
For ASNs with no PeeringDB entry and no RIPE Stat holder (e.g. reserved
or unannounced ASNs), extract name from bgp.he.net page title and
country code from the /country/XX href. Eliminates the last 2 CRITICAL
audit failures (AS34465 → 'RIPE NCC ASN block'/GB, AS59947 → 'LLHOST
INC. SRL'/RO). Audit result: 80/82 PERFECT, 0 CRITICAL. v0.6.8.
2026-04-03 01:42:56 +02:00

4945 lines
219 KiB
JavaScript

const fs = require("fs");
const http = require("http");
const https = require("https");
const crypto = require("crypto");
// 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";
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 ──────────────────────────────────────────
const HIJACK_SUBS_FILE = '/opt/peercortex-app/hijack-subs.json';
const HIJACK_ALERTS_FILE = '/opt/peercortex-app/hijack-alerts.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 []; } }
async function checkHijacksForAsn(asn) {
try {
const url = `https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}&${UA}`;
const data = await fetchJSONWithRetry(url, { timeout: 15000 });
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();
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) {
const alert = {
asn: sub.asn, ts: new Date().toISOString(),
unexpected, missing,
msg: `Possible hijack detected for AS${sub.asn}: ${unexpected.length} unexpected, ${missing.length} missing prefixes`
};
alerts.push(alert);
try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(alerts.slice(-500), null, 2)); } catch(_) {}
}
// Update baseline with current prefixes if no baseline set
if (!sub.prefixes || !sub.prefixes.length) {
sub.prefixes = current;
try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2)); } catch(_) {}
}
}
}
// Run hijack check every 30 minutes
setInterval(runHijackCheck, 30 * 60 * 1000);
const UA = "PeerCortex/0.5.0 (+https://peercortex.org; contact: rene.fichtmueller@flexoptix.net)";
// 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],
};
// ============================================================
// 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);
}
}
}
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
// ============================================================
// 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;
}
},
};
// ASPA adoption tracking — store last 30 snapshots for trend analysis
const aspaAdoptionHistory = [];
// ============================================================
// 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
const adoptionSnapshot = {
ts: Date.now(),
aspa_count: rpkiAspaMap.size,
roa_count: roaStore.count,
};
aspaAdoptionHistory.push(adoptionSnapshot);
if (aspaAdoptionHistory.length > 30) aspaAdoptionHistory.shift();
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) || 20000;
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 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 : {}),
},
};
const req = https.request(reqOptions, (res) => {
let chunks = "";
res.on("data", (chunk) => (chunks += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(chunks));
} catch (_e) {
resolve(null);
}
});
});
req.on("error", () => 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
// ============================================================
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, "");
// 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"] || [],
};
}
// If RIPE didn't find it, try all other RIRs via RDAP in parallel
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: 5000 }).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,
};
} else {
result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)";
}
}
} 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.8",
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: aspaAdoptionHistory.length,
delta_last: aspaAdoptionHistory.length >= 2
? aspaAdoptionHistory[aspaAdoptionHistory.length - 1].aspa_count - aspaAdoptionHistory[aspaAdoptionHistory.length - 2].aspa_count
: 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 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),
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn),
]);
// Use looking-glass with actual prefixes to get BGP paths
const announcedPrefixes = prefixData?.data?.prefixes || [];
const samplePrefixes = announcedPrefixes.slice(0, 5).map((p) => p.prefix);
// Fetch looking-glass data for up to 5 prefixes in parallel
const lgResults = await Promise.all(
samplePrefixes.map((pfx) =>
fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=" + encodeURIComponent(pfx))
)
);
// 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;
return res.end(
JSON.stringify(
{
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,
},
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 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) }));
}
}, 18000);
try {
const [lgData, neighbourData] = await Promise.all([
fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 8000 }),
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 8000 }),
]);
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;
return res.end(
JSON.stringify(
{
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,
},
},
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 start = Date.now();
try {
const result = { meta: { timestamp: new Date().toISOString() }, vantage_points: null, routes: null };
const vpData = await fetchJSON(BGPROUTES_API_URL + "/vantage_points", {
headers: { "x-api-key": BGPROUTES_API_KEY },
});
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 },
});
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;
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 start = Date.now();
const targetAsn = parseInt(rawAsn);
try {
// Phase 1: Fetch core data needed by multiple validations
const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 30000 }),
fetchPeeringDB("/net?asn=" + rawAsn),
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 30000 }),
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn),
]);
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 (sample up to 20 prefixes for better coverage)
var rdnsSampleSize = Math.min(20, 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: 15000 }).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: 20000 }).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
var keys = Object.keys(validationPromises);
var promises = keys.map(function(k) { return validationPromises[k]; });
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 }; });
return res.end(
JSON.stringify(
{
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.",
},
},
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
const sourceTiming = {};
function timedFetch(name, promise) {
const ts = Date.now();
return Promise.resolve(promise)
.then(r => { sourceTiming[name] = Date.now() - ts; return r; })
.catch(() => { sourceTiming[name] = null; return null; });
}
const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null;
// RDAP: try all 5 RIRs in parallel, take the first valid response (fast race)
const rdapForReg = [
"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,
];
const promises = [
timedFetch("RIPE Stat Prefixes", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 45000 })),
timedFetch("RIPE Stat Neighbours", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 45000 })),
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: 30000 })),
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", Promise.race([
// All 5 RIR RDAP endpoints in parallel — first valid wins
...rdapForReg.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)),
])),
];
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.8",
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,
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 : "",
};
})(),
};
// 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: 30000 }
);
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: 30000 }),
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2, { timeout: 30000 }),
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1, { timeout: 30000 }),
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2, { timeout: 30000 }),
]);
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;
}
// ============================================================
// 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 fetchJSONWithRetry(url, { timeout: 12000 });
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 fetchJSONWithRetry(url, { timeout: 20000 });
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 fetchJSONWithRetry(annUrl, { timeout: 15000 });
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 fetchJSONWithRetry(lgUrl, { timeout: 20000 });
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 fetchJSONWithRetry(url, { timeout: 20000 });
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}));
}
}
// ── Hijack Subscribe ──────────────────────────────────────────
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, email } = 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, email: email || '', prefixes, subscribed: new Date().toISOString() });
fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2));
}
res.writeHead(200);
res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: exists ? exists.prefixes.length : subs[subs.length-1].prefixes.length }));
} catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); }
});
return;
}
if (reqPath === '/api/hijack-subscribe' && req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Origin','*');
res.setHeader('Access-Control-Allow-Methods','POST,OPTIONS');
res.setHeader('Access-Control-Allow-Headers','Content-Type');
res.writeHead(204); return res.end();
}
// ── Hijack Alerts ─────────────────────────────────────────────
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 => 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}));
}
}
// 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",
})
);
});
// ============================================================
// 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(() => {
server.listen(PORT, "0.0.0.0", () => {
console.log("PeerCortex v0.6.1 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");
});
});
// ============================================================
// 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);
});