4895 lines
216 KiB
JavaScript
4895 lines
216 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();
|
|
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.6",
|
|
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;
|
|
const rdapForReg = [
|
|
"https://rdap.db.ripe.net/autnum/" + asn,
|
|
"https://rdap.apnic.net/autnum/" + asn,
|
|
"https://rdap.arin.net/registry/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", (async () => {
|
|
for (const url of rdapForReg) {
|
|
try {
|
|
const d = await fetchJSON(url, { timeout: 5000 });
|
|
if (d && !d.errorCode && d.handle) return d;
|
|
} catch(e) {}
|
|
}
|
|
return null;
|
|
})()),
|
|
];
|
|
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 = "";
|
|
if (Array.isArray(rirEntries) && rirEntries.length > 0) {
|
|
rir = rirEntries[0]?.rir || "";
|
|
country = rirEntries[0]?.country || "";
|
|
}
|
|
if (!rir && rirData?.data) {
|
|
const rirField = rirData.data.rirs || [];
|
|
if (rirField.length > 0) rir = rirField[0]?.rir || "";
|
|
}
|
|
|
|
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.6",
|
|
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 || "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(/ |✓|&/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);
|
|
});
|