From b93492edffe694be840a1ea19adad155ba1c4e56 Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Wed, 29 Apr 2026 09:49:41 +0200 Subject: [PATCH] refactor: migrate all remaining API routes to src/features/ modules --- package-lock.json | 4 +- server.js | 687 +- server.js.backup-v0.7.0 | 5660 +++++++++++++++++ src/api/server.ts | 28 +- src/backend/config.js | 28 + src/backend/services/smtp.js | 78 + src/features/aspath/routes.ts | 79 + src/features/asset-expand/routes.ts | 93 + .../bgp-communities/bgp-community-db.ts | 60 + src/features/bgp-communities/routes.ts | 65 + src/features/changelog/routes.ts | 51 + src/features/global-infra/routes.ts | 76 + src/features/hijack-subscribe/routes.ts | 73 + src/features/irr-audit/routes.ts | 91 + src/features/ix-matrix/routes.ts | 74 + src/features/looking-glass/routes.ts | 67 + src/features/prefix-changes/routes.ts | 128 + src/features/rib/routes.ts | 117 + src/features/rpki-history/routes.ts | 55 + src/features/submarine-cables/routes.ts | 52 + 20 files changed, 6900 insertions(+), 666 deletions(-) create mode 100644 server.js.backup-v0.7.0 create mode 100644 src/backend/config.js create mode 100644 src/backend/services/smtp.js create mode 100644 src/features/aspath/routes.ts create mode 100644 src/features/asset-expand/routes.ts create mode 100644 src/features/bgp-communities/bgp-community-db.ts create mode 100644 src/features/bgp-communities/routes.ts create mode 100644 src/features/changelog/routes.ts create mode 100644 src/features/global-infra/routes.ts create mode 100644 src/features/hijack-subscribe/routes.ts create mode 100644 src/features/irr-audit/routes.ts create mode 100644 src/features/ix-matrix/routes.ts create mode 100644 src/features/looking-glass/routes.ts create mode 100644 src/features/prefix-changes/routes.ts create mode 100644 src/features/rib/routes.ts create mode 100644 src/features/rpki-history/routes.ts create mode 100644 src/features/submarine-cables/routes.ts diff --git a/package-lock.json b/package-lock.json index c03ff8a..7bac697 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "peercortex", - "version": "0.6.5", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "peercortex", - "version": "0.6.5", + "version": "0.7.0", "license": "MIT", "dependencies": { "@grpc/grpc-js": "^1.14.3", diff --git a/server.js b/server.js index a8b83a0..e8d7531 100644 --- a/server.js +++ b/server.js @@ -8,22 +8,7 @@ const localDb = require('./local-db-client'); console.log('[PeerCortex] Local DB client initialized'); // 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); -} +require('./src/backend/config'); const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || ""; const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1"; @@ -212,80 +197,7 @@ 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 '; - -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); - }); -} - - +const { sendFeedbackMail } = require('./src/backend/services/smtp'); // ── SMTP / Email ────────────────────────────────────────────── @@ -309,59 +221,7 @@ function trackVisitor(req) { // ═══════════════════════════════════════════════════════════════ // ── 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 }; - }); -} +// Migrated to src/features/bgp-communities/bgp-community-db.ts // ── Hijack Monitoring ────────────────────────────────────────── const HIJACK_SUBS_FILE = '/opt/peercortex-app/hijack-subs.json'; @@ -4817,52 +4677,11 @@ const server = http.createServer(async (req, res) => { } // 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" })); - } + // ── Submarine Cables map data ───────────────────────────────── + // Migrated to src/features/submarine-cables/ - // 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); - } + // ── Global datacenter/IXP map (PeeringDB proxy) ─────────────── + // Migrated to src/features/global-infra/ // ── Changelog page ───────────────────────────────────────── @@ -4909,500 +4728,42 @@ ${html} } // ── BGP Community Decoder ──────────────────────────────────── - if (reqPath === '/api/communities') { - const params = new URL(req.url, 'http://localhost').searchParams; - const asn = params.get('asn') || ''; - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'public, max-age=3600'); - try { - const url = `https://stat.ripe.net/data/bgp-state/data.json?resource=AS${asn.replace('AS','')}`; - const data = await fetchJSON(url, { timeout: 6000 }); - const rawComms = []; - if (data && data.data && data.data.bgp_state) { - for (const entry of data.data.bgp_state.slice(0, 50)) { - if (entry.community) rawComms.push(...entry.community); - } - } - const unique = [...new Set(rawComms.map(c => Array.isArray(c) ? c.join(':') : String(c)))]; - const decoded = unique.map(k => ({ raw: k, known: BGP_COMMUNITY_DB[k] || null })); - res.writeHead(200); - return res.end(JSON.stringify({ asn, communities: decoded, db_size: Object.keys(BGP_COMMUNITY_DB).length })); - } catch(e) { - res.writeHead(200); - return res.end(JSON.stringify({ asn, communities: [], error: e.message })); - } - } + // Migrated to src/features/bgp-communities/ // ── 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})); - } - } + // Migrated to src/features/irr-audit/ + // ── 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})); - } - } - + // Migrated to src/features/asset-expand/ + // ── Routing History (prefix table via RIPE Stat routing-history) ── - if (reqPath.startsWith('/api/rpki-history')) { - const params = new URL(req.url, 'http://localhost').searchParams; - const asn = (params.get('asn') || '').replace(/[^0-9]/g,''); - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'public, max-age=3600'); - if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); } - try { - const url = 'https://stat.ripe.net/data/routing-history/data.json?resource=AS' + asn + '&max_rows=100'; - const data = await fetchJSON(url, { timeout: 6000 }); - const byOrigin = data && data.data && data.data.by_origin || []; - // Flatten: each origin entry has prefixes[] - var prefixes = []; - for (var i = 0; i < byOrigin.length; i++) { - var orig = byOrigin[i]; - if (orig.prefixes) { - for (var j = 0; j < orig.prefixes.length; j++) { - prefixes.push(orig.prefixes[j]); - } - } - } - res.writeHead(200); - return res.end(JSON.stringify({ asn: asn, prefixes: prefixes, source: 'RIPE Stat routing-history' })); - } catch(e) { - res.writeHead(500); return res.end(JSON.stringify({error:e.message})); - } - } + // Migrated to src/features/rpki-history/ // ── AS-PATH Visualizer (RIPE Stat looking-glass) ──────────────── - if (reqPath.startsWith('/api/aspath')) { - const params = new URL(req.url, 'http://localhost').searchParams; - const asn = (params.get('asn') || '').replace(/[^0-9]/g,''); - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'public, max-age=300'); - if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); } - try { - // Use RIPE Stat announced-prefixes to get prefixes, then looking-glass for paths - var annUrl = 'https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS' + asn; - var annData = await fetchJSON(annUrl, { timeout: 5000 }); - var announced = annData && annData.data && annData.data.prefixes || []; - var prefix = announced.length > 0 ? announced[0].prefix : null; - if (!prefix) { - res.writeHead(200); - return res.end(JSON.stringify({ asn: asn, paths: [], source: 'RIPE Stat' })); - } - // Get looking-glass data for the first announced prefix - var lgUrl = 'https://stat.ripe.net/data/looking-glass/data.json?resource=' + encodeURIComponent(prefix); - var lgData = await fetchJSON(lgUrl, { timeout: 6000 }); - var rrcs = lgData && lgData.data && lgData.data.rrcs || []; - var paths = []; - var seen = new Set(); - for (var i = 0; i < rrcs.length && paths.length < 10; i++) { - var rrc = rrcs[i]; - var peers = rrc.peers || []; - for (var j = 0; j < peers.length && paths.length < 10; j++) { - var p = peers[j]; - var pathStr = (p.as_path || '').trim(); - if (pathStr && !seen.has(pathStr)) { - seen.add(pathStr); - paths.push({ - path: pathStr, - prefix: prefix, - rrc: rrc.rrc + ' (' + (rrc.location||'') + ')', - peer_asn: p.asn_origin || '' - }); - } - } - } - res.writeHead(200); - return res.end(JSON.stringify({ asn: asn, paths: paths, prefix: prefix, source: 'RIPE Stat looking-glass · ' + prefix })); - } catch(e) { - res.writeHead(500); return res.end(JSON.stringify({error:e.message})); - } - } + // Migrated to src/features/aspath/ // ── Looking Glass (RIPE Stat) ───────────────────────────────── - if (reqPath.startsWith('/api/looking-glass')) { - const params = new URL(req.url, 'http://localhost').searchParams; - const resource = params.get('prefix') || params.get('asn') || ''; - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'no-store'); - if (!resource) { res.writeHead(400); return res.end(JSON.stringify({error:'prefix or asn required'})); } - try { - const url = `https://stat.ripe.net/data/looking-glass/data.json?resource=${encodeURIComponent(resource)}`; - const data = await fetchJSON(url, { timeout: 6000 }); - const rrcs = data && data.data && data.data.rrcs || []; - const results = rrcs.slice(0, 15).map(rrc => ({ - rrc: rrc.rrc, - location: rrc.location, - peers: (rrc.peers || []).slice(0,5).map(p => ({ - asn: p.asn_origin, - as_path: p.as_path, - community: p.community, - next_hop: p.next_hop, - })) - })); - res.writeHead(200); - return res.end(JSON.stringify({ resource, rrcs: results, total_rrcs: rrcs.length })); - } catch(e) { - res.writeHead(500); return res.end(JSON.stringify({error:e.message})); - } - } + // Migrated to src/features/looking-glass/ // ── 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})); - } - } + // Migrated to src/features/ix-matrix/ + // ── 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 })); - } - + // Migrated to src/features/hijack-subscribe/ + // ── Hijack Alerts (legacy read) ─────────────────────────────── + // Migrated to src/routes/hijack-alerts (Fastify feature) // ── 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})); - } - } + // Migrated to src/features/changelog/ - // ── bio-rd RIB: prefix lookup ────────────────────────────────── - if (reqPath === '/api/rib/prefix') { - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'no-store'); - if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); } - const params = new URL(req.url, 'http://localhost').searchParams; - const prefix = params.get('prefix') || ''; - const routerParam = params.get('router') || 'default'; - if (!prefix) { res.writeHead(400); return res.end(JSON.stringify({ error: 'prefix required' })); } - try { - const t0 = Date.now(); - const routers = await risClient.getRouters(); - const routerName = (routerParam === 'default' && routers.length > 0) ? routers[0] : routerParam; - const [routes, longer] = await Promise.all([ - risClient.lpm(routerName, prefix), - risClient.getLonger(routerName, prefix), - ]); - res.writeHead(200); - return res.end(JSON.stringify({ - prefix, - router: routerName, - routes: routes || [], - moreSpecifics: (longer || []).slice(0, 20), - source: 'bio-rd-local', - latencyMs: Date.now() - t0, - })); - } catch(e) { - res.writeHead(500); return res.end(JSON.stringify({ error: e.message })); - } - } - - // ── bio-rd RIB: list routers ─────────────────────────────────── - if (reqPath === '/api/rib/routers') { - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'no-store'); - if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); } - try { - const routers = await risClient.getRouters(); - res.writeHead(200); - return res.end(JSON.stringify({ routers: routers || [], source: 'bio-rd-local' })); - } catch(e) { - res.writeHead(500); return res.end(JSON.stringify({ error: e.message })); - } - } - - // ── bio-rd RIB: dump ─────────────────────────────────────────── - if (reqPath === '/api/rib/dump') { - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'no-store'); - if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); } - const params = new URL(req.url, 'http://localhost').searchParams; - const router = params.get('router') || ''; - const asnFilter = params.get('asn') ? parseInt(params.get('asn')) : undefined; - const limit = Math.min(parseInt(params.get('limit') || '100', 10), 1000); - if (!router) { res.writeHead(400); return res.end(JSON.stringify({ error: 'router required' })); } - try { - const t0 = Date.now(); - const allRoutes = await risClient.dumpRib(router, 'default', asnFilter); - const routes = (allRoutes || []).slice(0, limit); - res.writeHead(200); - return res.end(JSON.stringify({ - router, - routes, - total: (allRoutes || []).length, - source: 'bio-rd-local', - latencyMs: Date.now() - t0, - })); - } catch(e) { - res.writeHead(500); return res.end(JSON.stringify({ error: e.message })); - } - } + // ── bio-rd RIB routes ───────────────────────────────────────── + // Migrated to src/features/rib/ // ── Prefix Changes ────────────────────────────────────────────── - if (reqPath === '/api/prefix-changes') { - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'no-store'); - const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, ''); - if (!rawAsn) { res.writeHead(400); return res.end(JSON.stringify({ error: 'Missing ASN' })); } - const fromParam = url.searchParams.get('from'); - const toParam = url.searchParams.get('to'); - const hoursParam = Math.min(parseInt(url.searchParams.get('hours') || '1', 10), 168); - let starttime, endtime; - if (fromParam && toParam) { - starttime = new Date(fromParam).toISOString(); - endtime = new Date(toParam).toISOString(); - } else { - endtime = new Date().toISOString(); - starttime = new Date(Date.now() - hoursParam * 3600000).toISOString(); - } - try { - const updUrl = `https://stat.ripe.net/data/bgp-updates/data.json?resource=AS${rawAsn}&starttime=${encodeURIComponent(starttime)}&endtime=${encodeURIComponent(endtime)}&limit=1000`; - const raw = await fetchJSON(updUrl, { timeout: 8000 }); - const updates = (raw && raw.data && raw.data.updates && raw.data.updates.updates) || []; - - const announcements = [], withdrawals = [], originChanges = [], rpkiIssues = []; - const lastOriginByPrefix = {}, rpkiSeen = new Set(); - - for (const u of updates) { - const prefix = u.attrs && u.attrs.prefix; if (!prefix) continue; - const originRaw = u.attrs && u.attrs.origin; - const origin = originRaw ? parseInt(String(originRaw).replace('AS', ''), 10) : null; - const ts = u.timestamp || ''; - const peer = u.peer || ''; - - if (u.type === 'A') { - // Query local PostgreSQL for RPKI status (sub-10ms) - let rpkiStatus = 'unknown'; - try { - if (origin && prefix) { - const rpkiResult = await validateRPKIWithCache(origin, prefix); - rpkiStatus = rpkiResult.status; - } - } catch (e) { - console.error("[Prefix Changes] RPKI lookup error:", e.message); - } - announcements.push({ prefix, timestamp: ts, peer, origin, rpki_status: rpkiStatus }); - - if (lastOriginByPrefix[prefix] !== undefined && lastOriginByPrefix[prefix] !== origin) { - originChanges.push({ prefix, from_origin: lastOriginByPrefix[prefix], to_origin: origin, timestamp: ts, peer }); - } - lastOriginByPrefix[prefix] = origin; - - if (!rpkiSeen.has(prefix) && rpkiStatus !== 'valid') { - rpkiSeen.add(prefix); - rpkiIssues.push({ prefix, origin, rpki_status: rpkiStatus, timestamp: ts }); - } - } else if (u.type === 'W') { - withdrawals.push({ prefix, timestamp: ts, peer }); - } - } - - res.writeHead(200); - return res.end(JSON.stringify({ - asn: parseInt(rawAsn, 10), - time_range: { from: starttime, to: endtime }, - total_updates: updates.length, - summary: { announcements: announcements.length, withdrawals: withdrawals.length, origin_changes: originChanges.length, rpki_issues: rpkiIssues.length }, - announcements, - withdrawals, - origin_changes: originChanges, - rpki_issues: rpkiIssues, - })); - } catch(e) { - res.writeHead(500); - return res.end(JSON.stringify({ error: e.message })); - } - } + // Migrated to src/features/prefix-changes/ // 404 res.writeHead(404); diff --git a/server.js.backup-v0.7.0 b/server.js.backup-v0.7.0 new file mode 100644 index 0000000..a8b83a0 --- /dev/null +++ b/server.js.backup-v0.7.0 @@ -0,0 +1,5660 @@ +const fs = require("fs"); +const http = require("http"); +const https = require("https"); +const crypto = require("crypto"); + +// ── LOCAL DATABASE CLIENT (BGP, RPKI, Threat Intel) ────────────── +const localDb = require('./local-db-client'); +console.log('[PeerCortex] Local DB client initialized'); + +// Load .env file +const envPath = "/opt/peercortex-app/.env"; +try { + const envContent = fs.readFileSync(envPath, "utf8"); + envContent.split("\n").forEach((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) return; + const eqIdx = trimmed.indexOf("="); + if (eqIdx > 0) { + const key = trimmed.substring(0, eqIdx).trim(); + const val = trimmed.substring(eqIdx + 1).trim(); + if (!process.env[key]) process.env[key] = val; + } + }); +} catch (_e) { + console.warn("Warning: Could not read .env file at", envPath); +} + +const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || ""; +const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1"; + +// bio-rd gRPC client (optional — graceful fallback if unavailable) +let risClient = null; +try { + const { createRisClient } = require('./bio-rd-client'); + const BIO_RD_HOST = process.env.BIO_RD_HOST || 'localhost'; + const BIO_RD_PORT = parseInt(process.env.BIO_RD_PORT || '4321'); + risClient = createRisClient(BIO_RD_HOST, BIO_RD_PORT); + console.log(`[bio-rd] RIS client configured → ${BIO_RD_HOST}:${BIO_RD_PORT}`); +} catch (e) { + console.log('[bio-rd] RIS client not available (bio-rd-client.js missing or gRPC not installed)'); +} + +const PEERINGDB_API_KEY = process.env.PEERINGDB_API_KEY || ""; +const PEERINGDB_API_URL = process.env.PEERINGDB_API_URL || "https://www.peeringdb.com/api"; + +// ── Local PeeringDB SQLite (peeringdb-py sync, refreshed daily by cron) ────── +const PEERINGDB_LOCAL_PATH = process.env.PEERINGDB_LOCAL_PATH || "/opt/peeringdb-data/peeringdb.sqlite3"; +let _pdbLocal = null; +function getPdbLocal() { + if (_pdbLocal) return _pdbLocal; + try { + const BetterSqlite3 = require("better-sqlite3"); + if (!fs.existsSync(PEERINGDB_LOCAL_PATH)) return null; + _pdbLocal = new BetterSqlite3(PEERINGDB_LOCAL_PATH, { readonly: true, fileMustExist: true }); + console.log("[PeeringDB-local] SQLite opened:", PEERINGDB_LOCAL_PATH); + return _pdbLocal; + } catch (e) { + console.warn("[PeeringDB-local] Could not open SQLite:", e.message); + return null; + } +} + +// Map API path → SQLite result in { data: [...] } format, emulating the live PDB REST API. +function queryPeeringDBLocal(path) { + const db = getPdbLocal(); + if (!db) return null; + try { + // /net?asn=X + const netAsnMatch = path.match(/^\/net\?asn=(\d+)/); + if (netAsnMatch) { + const rows = db.prepare( + "SELECT n.*, o.name AS org_name FROM peeringdb_network n " + + "LEFT JOIN peeringdb_organization o ON n.org_id = o.id " + + "WHERE n.asn = ? AND n.status = 'ok'" + ).all(parseInt(netAsnMatch[1])); + return { data: rows }; + } + + // /net?status=ok&depth=0 (coverage endpoint — all networks) + if (path === "/net?status=ok&depth=0" || path.startsWith("/net?status=ok")) { + const rows = db.prepare( + "SELECT id, asn, name, aka, website, info_prefixes4, info_prefixes6, " + + "info_type, info_traffic, info_unicast, info_ipv6, policy_general, org_id " + + "FROM peeringdb_network WHERE status = 'ok' ORDER BY asn" + ).all(); + return { data: rows }; + } + + // /netixlan?net_id=X&limit=... or /netixlan?asn=X&limit=... + const netixlanNetId = path.match(/\/netixlan\?net_id=(\d+)/); + if (netixlanNetId) { + const rows = db.prepare( + "SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " + + "ni.operational, ni.bfd_support, il.id AS ixlan_id, " + + "ix.id AS ix_id, ix.name, ix.city, ix.country " + + "FROM peeringdb_network_ixlan ni " + + "LEFT JOIN peeringdb_ixlan il ON ni.ixlan_id = il.id " + + "LEFT JOIN peeringdb_ix ix ON il.ix_id = ix.id " + + "WHERE ni.net_id = ? AND ni.status = 'ok'" + ).all(parseInt(netixlanNetId[1])); + return { data: rows }; + } + const netixlanAsn = path.match(/\/netixlan\?asn=(\d+)/); + if (netixlanAsn) { + const rows = db.prepare( + "SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " + + "ni.operational, ni.bfd_support, il.id AS ixlan_id, " + + "ix.id AS ix_id, ix.name, ix.city, ix.country " + + "FROM peeringdb_network_ixlan ni " + + "LEFT JOIN peeringdb_ixlan il ON ni.ixlan_id = il.id " + + "LEFT JOIN peeringdb_ix ix ON il.ix_id = ix.id " + + "WHERE ni.asn = ? AND ni.status = 'ok'" + ).all(parseInt(netixlanAsn[1])); + return { data: rows }; + } + + // /netixlan?ixlan_id=X + const netixlanIxlanId = path.match(/\/netixlan\?ixlan_id=(\d+)/); + if (netixlanIxlanId) { + const rows = db.prepare( + "SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " + + "n.name AS net_name " + + "FROM peeringdb_network_ixlan ni " + + "LEFT JOIN peeringdb_network n ON ni.net_id = n.id " + + "WHERE ni.ixlan_id = ? AND ni.status = 'ok'" + ).all(parseInt(netixlanIxlanId[1])); + return { data: rows }; + } + + // /netfac?net_id=X + const netfacNetId = path.match(/\/netfac\?net_id=(\d+)/); + if (netfacNetId) { + const rows = db.prepare( + "SELECT nf.id, nf.net_id, f.id AS fac_id, f.name, f.city, f.state, " + + "f.country, f.latitude, f.longitude, f.website " + + "FROM peeringdb_network_facility nf " + + "LEFT JOIN peeringdb_facility f ON nf.fac_id = f.id " + + "WHERE nf.net_id = ? AND nf.status = 'ok'" + ).all(parseInt(netfacNetId[1])); + return { data: rows }; + } + + // /fac?id__in=X,Y,Z&fields=... + const facIdIn = path.match(/\/fac\?id__in=([\d,]+)/); + if (facIdIn) { + const ids = facIdIn[1].split(",").map(Number).filter(Boolean); + if (ids.length === 0) return { data: [] }; + const placeholders = ids.map(() => "?").join(","); + const rows = db.prepare( + "SELECT id, name, city, country, latitude, longitude, website " + + "FROM peeringdb_facility WHERE id IN (" + placeholders + ") AND status = 'ok'" + ).all(...ids); + return { data: rows }; + } + + // /ixfac?ix_id__in=X,Y,Z + const ixfacIxIdIn = path.match(/\/ixfac\?ix_id__in=([\d,]+)/); + if (ixfacIxIdIn) { + const ids = ixfacIxIdIn[1].split(",").map(Number).filter(Boolean); + if (ids.length === 0) return { data: [] }; + const placeholders = ids.map(() => "?").join(","); + const rows = db.prepare( + "SELECT ixf.id, ixf.ix_id, ixf.fac_id, f.latitude, f.longitude, f.city, f.country " + + "FROM peeringdb_ix_facility ixf " + + "LEFT JOIN peeringdb_facility f ON ixf.fac_id = f.id " + + "WHERE ixf.ix_id IN (" + placeholders + ") AND ixf.status = 'ok'" + ).all(...ids); + return { data: rows }; + } + + // /ix?name__contains=X + const ixNameContains = path.match(/\/ix\?name__contains=([^&]+)/); + if (ixNameContains) { + const term = "%" + decodeURIComponent(ixNameContains[1]) + "%"; + const rows = db.prepare( + "SELECT id, name, name_long, city, country, website, region_continent " + + "FROM peeringdb_ix WHERE (name LIKE ? OR name_long LIKE ?) AND status = 'ok' LIMIT 20" + ).all(term, term); + return { data: rows }; + } + + // /ixlan?ix_id=X + const ixlanIxId = path.match(/\/ixlan\?ix_id=(\d+)/); + if (ixlanIxId) { + const rows = db.prepare( + "SELECT id, ix_id, name, rs_asn, arp_sponge, mtu FROM peeringdb_ixlan " + + "WHERE ix_id = ? AND status = 'ok'" + ).all(parseInt(ixlanIxId[1])); + return { data: rows }; + } + + // /net/X (single network by PDB id) + const netById = path.match(/^\/net\/(\d+)$/); + if (netById) { + const row = db.prepare( + "SELECT n.*, o.name AS org_name FROM peeringdb_network n " + + "LEFT JOIN peeringdb_organization o ON n.org_id = o.id " + + "WHERE n.id = ? AND n.status = 'ok'" + ).get(parseInt(netById[1])); + return row ? { data: [row] } : { data: [] }; + } + + return null; // path not handled locally — fall through to live API + } catch (e) { + console.warn("[PeeringDB-local] Query error for", path, ":", e.message); + return null; + } +} + +const FEEDBACK_TOKEN = process.env.FEEDBACK_TOKEN || "changeme-set-in-env"; +const FEEDBACK_FILE = "/opt/peercortex-app/feedback.json"; +const VISITORS_FILE = "/opt/peercortex-app/visitors.json"; + +// ── SMTP / Email ────────────────────────────────────────────── +const SMTP_HOST = 'mail.fichtmueller.org'; +const SMTP_PORT = 587; +const SMTP_USER = process.env.SMTP_USER; +const SMTP_PASS = process.env.SMTP_PASS; +const MAIL_TO = 'peercortex@context-x.org'; +const MAIL_FROM = 'PeerCortex Feedback '; + +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 data = await localDb.getRipeStatAnnouncedPrefixes(asn); + 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)"; + +// 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); + } + } +} + + +// ── Tier-1 ASN Set (used for route leak heuristics) ───────────────────────── +const TIER1_ASNS = new Set([ + 174, // Cogent + 209, // CenturyLink/Lumen + 286, // KPN + 701, // Verizon/UUNET + 702, // Verizon + 1239, // Sprint + 1273, // Vodafone + 1280, // Internet Systems Consortium + 1299, // Arelion (Telia) + 2914, // NTT + 3257, // GTT + 3320, // Deutsche Telekom + 3356, // Lumen (Level3) + 3491, // PCCW + 5511, // Orange + 6453, // TATA + 6461, // Zayo + 6762, // Telecom Italia Sparkle + 7018, // AT&T + 7473, // SingTel + 12956, // Telxius +]); + +const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes +const CACHE_TTL_ASPA = 4 * 60 * 60 * 1000; // 4 hours +const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes +const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 minutes + +// ============================================================ +// RDAP Cache — prevents 429 flooding on LACNIC/AFRINIC/APNIC/ARIN +// ============================================================ +const rdapCache = new Map(); // key: asn string, value: { data, ts } +const RDAP_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours + +function rdapCacheGet(asn) { + const e = rdapCache.get(String(asn)); + if (e && (Date.now() - e.ts) < RDAP_CACHE_TTL) return e.data; + return undefined; // undefined = not cached, null = cached miss +} +function rdapCacheSet(asn, data) { + if (rdapCache.size > 5000) { + rdapCache.delete(rdapCache.keys().next().value); + } + rdapCache.set(String(asn), { data, ts: Date.now() }); +} + +// ============================================================ +// WHOIS Cache — 24h TTL, prevents repeated RDAP hammering +// ============================================================ +const whoisCache = new Map(); // key: asn string, value: { data, ts } +const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000; +function whoisCacheGet(asn) { + const e = whoisCache.get(String(asn)); + if (e && (Date.now() - e.ts) < WHOIS_CACHE_TTL) return e.data; + return undefined; +} +function whoisCacheSet(asn, data) { + if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value); + whoisCache.set(String(asn), { data, ts: Date.now() }); +} + +// ============================================================ +// Quick-IX Cache — 1h TTL, for Peering Recommendations +// ============================================================ +const quickIxCache = new Map(); // key: asn string, value: { data, ts } +const QUICK_IX_CACHE_TTL = 60 * 60 * 1000; +function quickIxCacheGet(asn) { + const e = quickIxCache.get(String(asn)); + if (e && (Date.now() - e.ts) < QUICK_IX_CACHE_TTL) return e.data; + return undefined; +} +function quickIxCacheSet(asn, data) { + if (quickIxCache.size > 2000) quickIxCache.delete(quickIxCache.keys().next().value); + quickIxCache.set(String(asn), { data, ts: Date.now() }); +} + +// ============================================================ +// bgproutes.io Vantage Points Cache — 1h TTL, prevents 429 +// ============================================================ +let bgproutesVpCache = null; +let bgproutesVpCacheTs = 0; +const BGPROUTES_VP_TTL = 60 * 60 * 1000; // 1 hour + +// ============================================================ +// bgproutes + ASPA result caches — 15min TTL, prevent re-hitting slow APIs +// ============================================================ +const bgproutesResultCache = new Map(); +const aspaResultCache = new Map(); +const validateResultCache = new Map(); +const RESULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes +function resultCacheGet(map, key) { + const e = map.get(String(key)); + if (e && (Date.now() - e.ts) < RESULT_CACHE_TTL) return e.data; + return undefined; +} +function resultCacheSet(map, key, data) { + if (map.size > 2000) map.delete(map.keys().next().value); + map.set(String(key), { data, ts: Date.now() }); +} + +// ============================================================ +// MANRS Participants Cache (scraped from public HTML page, 24h TTL) +// ============================================================ +let manrsAsnSet = null; // Set 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 267490 — may contain multiple space-separated ASNs + const set = new Set(); + const re = /]*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 +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) || 8000; + return new Promise((resolve) => { + const reqOptions = { + headers: { "User-Agent": UA, ...(options && options.headers ? options.headers : {}) }, + timeout: timeoutMs, + }; + const timer = setTimeout(() => resolve(null), timeoutMs + 500); + https + .get(url, reqOptions, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + clearTimeout(timer); + if (res.statusCode === 429) { + console.warn("[PDB] Rate limited (429):", url.substring(0, 80)); + return resolve(null); + } + try { + resolve(JSON.parse(data)); + } catch (_e) { + resolve(null); + } + }); + }) + .on("timeout", () => { clearTimeout(timer); resolve(null); }) + .on("error", () => { clearTimeout(timer); resolve(null); }); + }); +} + + +function fetchHTML(url, options) { + return new Promise((resolve) => { + const reqOptions = { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + ...(options && options.headers ? options.headers : {}), + }, + }; + const lib = url.startsWith("https") ? require("https") : require("http"); + lib + .get(url, reqOptions, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + return fetchHTML(res.headers.location, options).then(resolve); + } + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => resolve(data)); + }) + .on("error", () => resolve(null)); + }); +} + +function postJSON(url, body, options) { + return new Promise((resolve) => { + const data = JSON.stringify(body); + const parsed = new URL(url); + const timeout = (options && options.timeout) || 10000; + const reqOptions = { + hostname: parsed.hostname, + port: parsed.port || 443, + path: parsed.pathname + parsed.search, + method: "POST", + headers: { + "User-Agent": UA, + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(data), + ...(options && options.headers ? options.headers : {}), + }, + }; + let done = false; + const timer = setTimeout(() => { if (!done) { done = true; req.destroy(); resolve(null); } }, timeout); + const req = https.request(reqOptions, (res) => { + let chunks = ""; + res.on("data", (chunk) => (chunks += chunk)); + res.on("end", () => { + if (done) return; + done = true; + clearTimeout(timer); + try { + resolve(JSON.parse(chunks)); + } catch (_e) { + resolve(null); + } + }); + }); + req.on("error", () => { if (!done) { done = true; clearTimeout(timer); resolve(null); } }); + req.write(data); + req.end(); + }); +} + +async function resolveASNames(providers) { + // Batch resolve AS names via RIPE Stat AS overview API + const batchSize = 10; + for (let i = 0; i < providers.length; i += batchSize) { + const batch = providers.slice(i, i + batchSize); + const results = await Promise.all( + batch.map(p => + fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + p.asn) + .then(r => ({ asn: p.asn, name: r?.data?.holder || "" })) + .catch(() => ({ asn: p.asn, name: "" })) + ) + ); + results.forEach(r => { + const provider = providers.find(p => p.asn === r.asn); + if (provider && r.name) provider.name = r.name; + }); + } + return providers; +} + +// ── MASTER RIPE Stat API wrapper (Local-first, zero external API calls) ── +// Analyzes RIPE Stat URL and dispatches to appropriate localDb function +async function fetchRipeStatCached(url, options = {}) { + try { + // Detect which RIPE Stat endpoint this is and call local DB + if (url.includes('/announced-prefixes/')) { + const asnMatch = url.match(/resource=AS(\d+)/); + if (asnMatch) return await localDb.getRipeStatAnnouncedPrefixes(parseInt(asnMatch[1])); + } + if (url.includes('/asn-neighbours/')) { + const asnMatch = url.match(/resource=AS(\d+)/); + if (asnMatch) return await localDb.getRipeStatAsnNeighbours(parseInt(asnMatch[1])); + } + if (url.includes('/as-overview/')) { + const asnMatch = url.match(/resource=AS(\d+)/); + if (asnMatch) return await localDb.getRipeStatAsOverview(parseInt(asnMatch[1])); + } + if (url.includes('/visibility/')) { + const asnMatch = url.match(/resource=AS(\d+)/); + if (asnMatch) return await localDb.getRipeStatVisibility(parseInt(asnMatch[1])); + } + if (url.includes('/prefix-size-distribution/')) { + const asnMatch = url.match(/resource=AS(\d+)/); + if (asnMatch) return await localDb.getRipeStatPrefixSizeDistribution(parseInt(asnMatch[1])); + } + + // For other RIPE Stat endpoints (not in localDb): return empty/null gracefully + // Examples: rir-stats-country, bgp-updates, reverse-dns-consistency, routing-status, maxmind-geo-lite, etc. + return Promise.resolve(null); + } catch (e) { + console.error("[fetchRipeStatCached] Error:", e.message); + return Promise.resolve(null); + } +} + +// 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) +// Validate RPKI for a prefix — uses local PostgreSQL database (sub-10ms, zero external API calls) +// Returns: { prefix, status: "valid"|"invalid"|"not_found", validating_roas: N } +async function validateRPKIWithCache(asn, prefix) { + try { + // Query local database (sub-10ms, no external API calls) + const result = await localDb.validateRpki(prefix, asn); + + // Adapt response to match expected format + if (result.status === 'valid') { + return { prefix, status: "valid", validating_roas: 1 }; + } else if (result.status === 'invalid') { + return { prefix, status: "invalid", validating_roas: 1 }; + } else { + // 'not-found' or 'unknown' + return { prefix, status: "not_found", validating_roas: 0 }; + } + } catch (_e) { + console.error("[RPKI] Error validating " + prefix + ":", _e.message); + 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> (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>/i); + if (titleMatch) result.title = titleMatch[1].trim(); + const peerMatch = html.match(/BGP\s+Peers\s+Observed\s*\(all\)\s*:\s*(\d[\d,]*)/i) || html.match(/Observed\s+Peers[^<]*<[^>]*>\s*(\d+)/i); + if (peerMatch) result.peer_count = parseInt(peerMatch[1].replace(/,/g, '')); + const countryMatch = html.match(/Country[^<]*<[^>]*>[^<]*<[^>]*>\s*<[^>]*>([^<]+)/i); + if (countryMatch) result.country = countryMatch[1].trim(); + // Extract 2-letter country code from href="/country/XX" + const ccMatch = html.match(/href="\/country\/([A-Z]{2})"/i); + if (ccMatch) result.country_code = ccMatch[1].toUpperCase(); + // Extract clean AS name from title: "AS12345 Some Name - bgp.he.net" → "Some Name" + if (titleMatch) { + const rawTitle = titleMatch[1].trim(); + const nameFromTitle = rawTitle.replace(/^AS\d+\s+/i, '').replace(/\s+-\s+bgp\.he\.net.*$/i, '').trim(); + if (nameFromTitle && !nameFromTitle.toLowerCase().includes('bgp.he.net')) { + result.name_from_title = nameFromTitle; + } + } + + const lgMatch = html.match(/Looking\s+Glass[^<]*<[^>]*href="([^"]+)"/i); + if (lgMatch) result.looking_glass = lgMatch[1]; + const descMatch = html.match(/AS\s+Name[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i); + if (descMatch) result.description = descMatch[1].trim(); + const irrMatch = html.match(/IRR\s+Record[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i); + if (irrMatch) result.irr_record = irrMatch[1].trim(); + // bgp.he.net format: "Prefixes Originated (v4): 147<br/>" or "Prefixes v4 ... <td>147" + const v4Match = html.match(/Prefixes\s+Originated\s*\(v4\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v4[^<]*<[^>]*>\s*(\d+)/i); + if (v4Match) result.prefixes_v4 = parseInt(v4Match[1].replace(/,/g, '')); + const v6Match = html.match(/Prefixes\s+Originated\s*\(v6\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v6[^<]*<[^>]*>\s*(\d+)/i); + if (v6Match) result.prefixes_v6 = parseInt(v6Match[1].replace(/,/g, '')); + const allMatch = html.match(/Prefixes\s+Originated\s*\(all\)\s*:\s*(\d[\d,]*)/i); + if (allMatch) result.prefixes_all = parseInt(allMatch[1].replace(/,/g, '')); + result.source_url = "https://bgp.he.net/AS" + asn; + return result; + } catch (_e) { + return null; + } +} + +// ============================================================ +// Feature 25: Topology / AS-Relationships +// ============================================================ +async function fetchTopology(targetAsn, depth) { + const maxDepth = Math.min(depth || 2, 3); + const nodes = new Map(); + const edges = []; + async function fetchNeighboursForAsn(asn, currentDepth) { + if (nodes.has(asn) && nodes.get(asn).depth <= currentDepth) return; + const [data, overview] = await Promise.all([ + fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn), + fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn), + ]); + const name = overview?.data?.holder || ""; + const neighbours = data?.data?.neighbours || []; + const upstreams = neighbours.filter((n) => n.type === "left"); + const downstreams = neighbours.filter((n) => n.type === "right"); + const peers = neighbours.filter((n) => n.type === "uncertain" || n.type === "peer"); + const nodeType = asn === targetAsn ? "target" : currentDepth === 1 ? "direct" : "indirect"; + nodes.set(asn, { asn, name, type: nodeType, depth: currentDepth }); + upstreams.forEach((n) => { + if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "upstream", depth: currentDepth + 1 }); + edges.push({ from: n.asn, to: asn, relationship: "provider-to-customer" }); + }); + downstreams.forEach((n) => { + if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "downstream", depth: currentDepth + 1 }); + edges.push({ from: asn, to: n.asn, relationship: "provider-to-customer" }); + }); + peers.slice(0, 10).forEach((n) => { + if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "peer", depth: currentDepth + 1 }); + edges.push({ from: asn, to: n.asn, relationship: "peer" }); + }); + if (currentDepth < maxDepth && upstreams.length > 0) { + const top5 = upstreams.sort((a, b) => (b.power || 0) - (a.power || 0)).slice(0, 5); + await Promise.all(top5.map((u) => fetchNeighboursForAsn(u.asn, currentDepth + 1))); + } + } + await fetchNeighboursForAsn(targetAsn, 0); + const edgeSet = new Set(); + const uniqueEdges = edges.filter((e) => { + const key = e.from + "-" + e.to + "-" + e.relationship; + if (edgeSet.has(key)) return false; + edgeSet.add(key); + return true; + }); + return { nodes: [...nodes.values()], edges: uniqueEdges, target_asn: targetAsn, depth: maxDepth }; +} + +// ============================================================ +// Feature 27: WHOIS via RIPE DB +// ============================================================ + +// ── Resilience Score ───────────────────────────────────────────────────────── +// Weighted: Transit Diversity 30%, Peering Breadth 25%, IXP Presence 20%, Path Redundancy 25% +// Hard cap at 5.0 when single transit provider detected. +// Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB. +function computeResilienceScore(upstreams, peers, ixConnections, prefixes) { + const upstreamCount = upstreams.length; + const peerCount = peers.length; + const ixCount = [...new Set(ixConnections.map(c => c.ix_id).filter(Boolean))].length; + const prefixCount = prefixes.length; + + // Transit Diversity (0-10) + let transitRaw = 0; + if (upstreamCount === 0) transitRaw = 0; + else if (upstreamCount === 1) transitRaw = 2; + else if (upstreamCount === 2) transitRaw = 5; + else if (upstreamCount <= 4) transitRaw = 7; + else transitRaw = 10; + + // Peering Breadth (0-10) + let peeringRaw = 0; + if (peerCount >= 100) peeringRaw = 10; + else if (peerCount >= 50) peeringRaw = 8; + else if (peerCount >= 20) peeringRaw = 6; + else if (peerCount >= 5) peeringRaw = 4; + else if (peerCount >= 1) peeringRaw = 2; + + // IXP Presence (0-10) + let ixpRaw = 0; + if (ixCount >= 10) ixpRaw = 10; + else if (ixCount >= 6) ixpRaw = 8; + else if (ixCount >= 3) ixpRaw = 6; + else if (ixCount >= 1) ixpRaw = 4; + + // Path Redundancy (0-10) — proxy: prefix diversity + upstream + IXP combination + let pathRaw = 0; + if (upstreamCount >= 2 && ixCount >= 1) pathRaw = 10; + else if (upstreamCount >= 2) pathRaw = 7; + else if (ixCount >= 2) pathRaw = 6; + else if (upstreamCount === 1) pathRaw = 3; + else if (prefixCount > 0) pathRaw = 1; + + const weighted = + transitRaw * 0.30 + + peeringRaw * 0.25 + + ixpRaw * 0.20 + + pathRaw * 0.25; + + const singleTransitCap = upstreamCount === 1; + let score = Math.round(weighted * 10) / 10; + if (singleTransitCap) score = Math.min(score, 5.0); + score = Math.max(1.0, Math.min(10.0, score)); + + // Only return null if truly no data at all + if (upstreamCount === 0 && peerCount === 0 && ixCount === 0 && prefixCount === 0) { + return null; + } + + return { + score, + breakdown: { + transit_diversity: { raw: transitRaw, weighted: Math.round(transitRaw * 0.30 * 10) / 10, upstream_count: upstreamCount }, + peering_breadth: { raw: peeringRaw, weighted: Math.round(peeringRaw * 0.25 * 10) / 10, peer_count: peerCount }, + ixp_presence: { raw: ixpRaw, weighted: Math.round(ixpRaw * 0.20 * 10) / 10, unique_ixps: ixCount }, + path_redundancy: { raw: pathRaw, weighted: Math.round(pathRaw * 0.25 * 10) / 10, prefix_count: prefixCount }, + }, + single_transit_cap_applied: singleTransitCap, + _provenance: { + source: "RIPE Stat asn-neighbours + PeeringDB netixlan", + validation: "cross-validated", + confidence: "high", + note: "All inputs independently validated daily against external sources", + }, + }; +} + +// ── Route Leak Detection ───────────────────────────────────────────────────── +// Heuristic: detects suspicious routing relationships using RIPE Stat neighbour data. +// NOT real-time. False positives possible for large networks with many Tier-1 relationships. +// Confidence: MEDIUM — pattern-based, not path-level analysis. +function computeRouteLeakDetection(upstreams, downstreams, peers) { + const upstreamAsns = new Set(upstreams.map(n => n.asn)); + const downstreamAsns = new Set(downstreams.map(n => n.asn)); + + const tier1Upstreams = upstreams.filter(n => TIER1_ASNS.has(n.asn)); + const tier1Downstreams = downstreams.filter(n => TIER1_ASNS.has(n.asn)); + + const patterns = []; + + // Pattern A: Tier-1 appearing as BOTH upstream AND downstream → sandwich candidate + const sandwich = tier1Upstreams.filter(n => downstreamAsns.has(n.asn)); + sandwich.forEach(n => { + patterns.push({ + type: "sandwich_candidate", + asn: n.asn, + name: n.name, + description: `AS${n.asn} (${n.name}) appears as both upstream and downstream — possible route leak vector`, + }); + }); + + // Pattern B: Tier-1 as downstream (re-originating routes to Tier-1s) + tier1Downstreams.forEach(n => { + if (!upstreamAsns.has(n.asn)) { + patterns.push({ + type: "tier1_downstream", + asn: n.asn, + name: n.name, + description: `AS${n.asn} (${n.name}) is a downstream — unusual for a Tier-1, may indicate leaked routes`, + }); + } + }); + + const detected = patterns.length > 0; + + return { + detected, + patterns, + tier1_upstream_count: tier1Upstreams.length, + tier1_downstream_count: tier1Downstreams.length, + _provenance: { + source: "RIPE Stat asn-neighbours", + validation: "heuristic", + confidence: "medium", + note: "Pattern-based detection only. Not real-time (15-min RIPE RIS snapshot). False positives possible for large networks with legitimate Tier-1 relationships.", + }, + }; +} + +async function fetchWhois(resource) { + const result = { resource, type: null, data: null, error: null }; + try { + const trimmed = resource.trim(); + if (/^(AS)?\d+$/i.test(trimmed)) { + result.type = "aut-num"; + const asn = trimmed.replace(/^AS/i, ""); + + // Check cache first + const cached = whoisCacheGet(asn); + if (cached !== undefined) { + result.data = cached; + if (!cached) result.error = "Not found in any RIR database (cached)"; + return result; + } + + // Try RIPE first + const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=AS" + asn + "&type-filter=aut-num&source=ripe", { timeout: 5000 }).catch(() => null); + if (ripeData && ripeData.objects && ripeData.objects.object) { + const obj = ripeData.objects.object[0]; + const attrs = obj.attributes?.attribute || []; + const parsed = {}; + attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); }); + result.data = { + aut_num: (parsed["aut-num"] || [])[0] || "", + as_name: (parsed["as-name"] || [])[0] || "", + descr: parsed["descr"] || [], + org: (parsed["org"] || [])[0] || "", + admin_c: parsed["admin-c"] || [], + tech_c: parsed["tech-c"] || [], + mnt_by: parsed["mnt-by"] || [], + status: (parsed["status"] || [])[0] || "", + created: (parsed["created"] || [])[0] || "", + last_modified: (parsed["last-modified"] || [])[0] || "", + source: (parsed["source"] || [])[0] || "", + import: parsed["import"] || [], + export: parsed["export"] || [], + remarks: parsed["remarks"] || [], + }; + whoisCacheSet(asn, result.data); + } + + // If RIPE didn't find it, try all other RIRs via RDAP in parallel (3s timeout) + if (!result.data) { + const rdapEndpoints = [ + { name: "APNIC", url: "https://rdap.apnic.net/autnum/" + asn }, + { name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + asn }, + { name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + asn }, + { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn }, + ]; + const rdapResults = await Promise.all(rdapEndpoints.map((ep) => + fetchJSON(ep.url, { timeout: 3000 }).then((d) => { + if (!d || d.errorCode || !d.handle) return null; + return { source: ep.name, data: d }; + }).catch(() => null) + )); + const found = rdapResults.find((r) => r !== null); + if (found) { + const d = found.data; + const remarks = (d.remarks || []).map((r) => (r.description || []).join(" ")); + const entities = d.entities || []; + const adminContacts = entities.filter((e) => (e.roles || []).includes("administrative")).map((e) => e.handle || ""); + const techContacts = entities.filter((e) => (e.roles || []).includes("technical")).map((e) => e.handle || ""); + const events = d.events || []; + const created = (events.find((e) => e.eventAction === "registration") || {}).eventDate || ""; + const lastMod = (events.find((e) => e.eventAction === "last changed") || {}).eventDate || ""; + result.data = { + aut_num: "AS" + asn, + as_name: d.name || "", + descr: remarks, + org: (entities.find((e) => (e.roles || []).includes("registrant")) || {}).handle || "", + admin_c: adminContacts, + tech_c: techContacts, + mnt_by: [], + status: (d.status || []).join(", "), + created: created, + last_modified: lastMod, + source: found.source + " (RDAP)", + import: [], + export: [], + remarks: remarks, + }; + whoisCacheSet(asn, result.data); + } else { + result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)"; + whoisCacheSet(asn, null); // cache miss to avoid repeated hammering + } + } + } else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) { + result.type = "inetnum"; + const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=inetnum,inet6num"); + if (ripeData && ripeData.objects && ripeData.objects.object) { + const results = ripeData.objects.object.map((obj) => { + const attrs = obj.attributes?.attribute || []; + const parsed = {}; + attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); }); + return { + inetnum: (parsed["inetnum"] || parsed["inet6num"] || [])[0] || "", + netname: (parsed["netname"] || [])[0] || "", + descr: parsed["descr"] || [], + country: (parsed["country"] || [])[0] || "", + org: (parsed["org"] || [])[0] || "", + admin_c: parsed["admin-c"] || [], + tech_c: parsed["tech-c"] || [], + mnt_by: parsed["mnt-by"] || [], + status: (parsed["status"] || [])[0] || "", + created: (parsed["created"] || [])[0] || "", + last_modified: (parsed["last-modified"] || [])[0] || "", + source: (parsed["source"] || [])[0] || "", + }; + }); + result.data = results.length === 1 ? results[0] : results; + } else { result.error = "Not found in RIPE DB"; } + } else { + result.type = "domain"; + const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=domain"); + if (ripeData && ripeData.objects && ripeData.objects.object) { + const obj = ripeData.objects.object[0]; + const attrs = obj.attributes?.attribute || []; + const parsed = {}; + attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); }); + result.data = { + domain: (parsed["domain"] || [])[0] || "", + descr: parsed["descr"] || [], + admin_c: parsed["admin-c"] || [], + tech_c: parsed["tech-c"] || [], + zone_c: parsed["zone-c"] || [], + nserver: parsed["nserver"] || [], + mnt_by: parsed["mnt-by"] || [], + created: (parsed["created"] || [])[0] || "", + last_modified: (parsed["last-modified"] || [])[0] || "", + source: (parsed["source"] || [])[0] || "", + }; + } else { result.error = "Not found in RIPE DB"; } + } + } catch (err) { result.error = err.message; } + return result; +} + +// ============================================================ +// HTTP Server +// ============================================================ + +const server = http.createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + return res.end(); + } + + let url, reqPath; + try { + url = new URL(req.url, "http://localhost"); + reqPath = url.pathname; + } catch (_urlErr) { + res.writeHead(400); + return res.end('Bad Request'); + } + + // Serve static files — host-based routing + const host = (req.headers.host || '').split(':')[0]; + + if (reqPath === "/" || reqPath === "/index.html") { + // shell.peercortex.org → admin feedback terminal (check first) + if (host === 'shell.peercortex.org') { + try { + const html = fs.readFileSync('/opt/peercortex-app/public/shell.html', 'utf8'); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + return res.end(html); + } catch (_e) { + res.writeHead(500); + return res.end('shell.html not found'); + } + } + // v2.peercortex.org → redirect to main domain + if (host === 'v2.peercortex.org') { + res.writeHead(301, { Location: 'https://peercortex.org' + reqPath }); + return res.end(); + } + const htmlFile = "index.html"; + try { + const html = fs.readFileSync("/opt/peercortex-app/public/" + htmlFile, "utf8"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + return res.end(html); + } catch (_e) { + res.writeHead(500); + return res.end(htmlFile + " not found"); + } + } + + // Direct access to editorial version + if (reqPath === "/index-editorial.html") { + try { + const html = fs.readFileSync("/opt/peercortex-app/public/index-editorial.html", "utf8"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + return res.end(html); + } catch (_e) { + res.writeHead(500); + return res.end("index-editorial.html not found"); + } + } + + // ============================================================ + // Feedback API + // ============================================================ + + + // ── Name Search (RIPE Stat + PeeringDB combined) ───────────── + if (reqPath === '/api/search') { + const params = new URL(req.url, 'http://localhost').searchParams; + const q = (params.get('q') || '').trim(); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=120'); + if (!q || q.length < 2) { res.writeHead(400); return res.end(JSON.stringify({error:'query too short'})); } + try { + const results = []; + const seen = new Set(); + + // Source 1: RIPE Stat searchcomplete (fast, covers ASNs + org names) + try { + const ripeUrl = 'https://stat.ripe.net/data/searchcomplete/data.json?resource=' + encodeURIComponent(q); + const ripeData = await fetchJSONWithRetry(ripeUrl, { timeout: 6000 }); + const cats = ripeData && ripeData.data && ripeData.data.categories || []; + for (var ci = 0; ci < cats.length; ci++) { + var suggs = cats[ci].suggestions || []; + for (var si = 0; si < suggs.length; si++) { + var s = suggs[si]; + var val = (s.value || '').toString(); + // Only ASN results + if (/^AS\d+$/i.test(val) && !seen.has(val)) { + seen.add(val); + // Use description (e.g. "FLEXOPTIX, DE") as the display label + var ripeName = s.description || s.label || val; + results.push({ asn: val.replace(/^AS/i,''), label: ripeName, description: '', source: 'RIPE Stat' }); + } + } + } + } catch(e) { /* RIPE Stat failed, continue */ } + + // Source 2: PeeringDB name search (best for network operator names) + try { + var pdbUrl = 'https://www.peeringdb.com/api/net?name__icontains=' + encodeURIComponent(q) + '&depth=1&limit=10'; + if (PEERINGDB_API_KEY) pdbUrl += '&key=' + PEERINGDB_API_KEY; + const pdbData = await fetchJSONWithRetry(pdbUrl, { timeout: 8000 }); + var nets = pdbData && pdbData.data || []; + for (var ni = 0; ni < nets.length; ni++) { + var net = nets[ni]; + var asnKey = 'AS' + net.asn; + if (net.asn && !seen.has(asnKey)) { + seen.add(asnKey); + var pdbDesc = [net.info_type, net.country].filter(Boolean).join(' · '); + results.push({ + asn: String(net.asn), + label: net.name || asnKey, + description: pdbDesc, + source: 'PeeringDB' + }); + } + } + } catch(e) { /* PeeringDB failed, continue */ } + + // Sort: RIPE results first (usually more relevant for ASN lookup), then PeeringDB + results.sort((a, b) => { + if (a.source === b.source) return 0; + return a.source === 'RIPE Stat' ? -1 : 1; + }); + + res.writeHead(200); + return res.end(JSON.stringify({ q: q, results: results.slice(0, 12) })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({error: e.message})); + } + } + + // GET /api/visitors — unique visitor count + if (reqPath === "/api/visitors" && req.method === "GET") { + res.setHeader("Content-Type", "application/json"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Cache-Control", "no-store"); + const count = trackVisitor(req); + res.writeHead(200); + return res.end(JSON.stringify({ visitors: count })); + } + + // OPTIONS preflight (CORS) + if (reqPath === '/api/feedback' && req.method === 'OPTIONS') { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.writeHead(204); + return res.end(); + } + + // POST /api/feedback — submit feedback entry + if (reqPath === '/api/feedback' && req.method === 'POST') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + let body = ''; + req.on('data', function(chunk) { body += chunk; }); + req.on('end', function() { + try { + const data = JSON.parse(body); + if (!data.message || String(data.message).trim().length < 3) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'Message too short' })); + } + const entry = { + id: Date.now() + '-' + Math.random().toString(36).slice(2, 7), + timestamp: new Date().toISOString(), + category: String(data.category || 'General').slice(0, 50), + message: String(data.message || '').slice(0, 2000), + name: String(data.name || 'Anonymous').slice(0, 100), + asn: data.asn ? String(data.asn).slice(0, 20) : null, + ip: req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.socket.remoteAddress || null, + ua: String(req.headers['user-agent'] || '').slice(0, 200) + }; + let entries = []; + try { entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8')); } catch (_e) { /* no file yet */ } + entries.push(entry); + fs.writeFileSync(FEEDBACK_FILE, JSON.stringify(entries, null, 2)); + // Send email async — don't block response + sendFeedbackMail(entry).catch(e => console.error('[MAIL] Failed:', e.message)); + return res.end(JSON.stringify({ ok: true, id: entry.id })); + } catch (_e) { + res.writeHead(500); + return res.end(JSON.stringify({ ok: false, error: 'Server error' })); + } + }); + return; + } + + // GET /api/feedback?token=... — admin: fetch all entries as JSON + if (reqPath === '/api/feedback' && req.method === 'GET') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + const token = url.searchParams.get('token'); + if (!token || token !== FEEDBACK_TOKEN) { + res.writeHead(401); + return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' })); + } + try { + const entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8')); + return res.end(JSON.stringify({ ok: true, entries: entries, count: entries.length })); + } catch (_e) { + return res.end(JSON.stringify({ ok: true, entries: [], count: 0 })); + } + } + + // Serve favicon + if (reqPath === "/favicon.ico") { + res.writeHead(204); + return res.end(); + } + + // Lia's Atlas Paradise - Easter egg page + if (reqPath === "/lia" || reqPath === "/lia/") { + try { + const liaHtml = fs.readFileSync(__dirname + "/public/lia.html", "utf8"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + return res.end(liaHtml); + } catch (_e) { + res.writeHead(500); + return res.end("lia.html not found"); + } + } + + + // ============================================================ + // Lia's Atlas Paradise: Atlas probe coverage endpoint + // ============================================================ + if (reqPath === "/api/atlas/coverage") { + res.setHeader("Content-Type", "application/json"); + if (!atlasProbeCache) { + res.writeHead(503); + return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." })); + } + return res.end(JSON.stringify(atlasProbeCache, null, 2)); + } + + // ============================================================ + // Lia's Paradise: File parsing endpoint (for binary uploads) + // ============================================================ + if (reqPath === "/api/lia/parse-file" && req.method === "POST") { + res.setHeader("Content-Type", "application/json"); + let body = ""; + req.on("data", function(chunk) { body += chunk; }); + req.on("end", function() { + try { + var parsed = JSON.parse(body); + var filename = parsed.filename || ""; + var ext = filename.split(".").pop().toLowerCase(); + // For text-based formats, decode base64 and extract text + if (ext === "csv" || ext === "txt") { + var text = Buffer.from(parsed.data, "base64").toString("utf8"); + return res.end(JSON.stringify({ text: text })); + } + // For binary formats (PDF, XLS, DOC), we can't parse server-side without + // heavy dependencies. Return helpful error. + return res.end(JSON.stringify({ + error: "Binary file parsing (" + ext.toUpperCase() + ") requires client-side extraction. Please use CSV or TXT format, or copy-paste the content.", + suggestion: "Export your spreadsheet as CSV first, then upload the CSV file." + })); + } catch(e) { + return res.end(JSON.stringify({ error: "Parse error: " + e.message })); + } + }); + return; + } + + // ============================================================ + // Lia's Paradise: Combined PeeringDB + Atlas coverage data + // ============================================================ + if (reqPath === "/api/lia/coverage") { + res.setHeader("Content-Type", "application/json"); + if (!atlasProbeCache) { + res.writeHead(503); + return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." })); + } + + // Cache this expensive response for 30 min + var liaCacheKey = "lia_coverage"; + var liaCached = cacheGet(liaCacheKey); + if (liaCached) return res.end(liaCached); + + // Fetch PeeringDB network list (all networks with status "ok") + // Use pre-cached org→country map (loaded at startup, 16MB response cached in memory) + fetchPeeringDB("/net?status=ok&depth=0").then(function(pdbData) { + if (!pdbData || !pdbData.data) { + return res.end(JSON.stringify({ error: "Could not fetch PeeringDB networks" })); + } + + var probeAsns = new Set(atlasProbeCache.asns_with_probes || []); + + var enriched = pdbData.data.map(function(n) { + var org = pdbOrgCountryMap.get(n.org_id) || {}; + var cc = org.country || ""; + return { + asn: n.asn, + name: n.name || "", + org_name: org.name || "", + country: cc, + country_name: cc, + info_type: n.info_type || "", + has_probe: probeAsns.has(n.asn), + }; + }).filter(function(n) { return n.asn > 0 && n.country; }); + + var result = JSON.stringify({ + networks: enriched, + total: enriched.length, + with_probes: enriched.filter(function(n) { return n.has_probe; }).length, + without_probes: enriched.filter(function(n) { return !n.has_probe; }).length, + atlas_unique_asns: probeAsns.size, + org_countries_loaded: pdbOrgCountryMap.size, + fetched_at: new Date().toISOString(), + }); + + cacheSet(liaCacheKey, result, 30 * 60 * 1000); + res.end(result); + }).catch(function(e) { + res.end(JSON.stringify({ error: "PeeringDB fetch failed: " + e.message })); + }); + return; + } + + res.setHeader("Content-Type", "application/json"); + + // Health endpoint — extended with cache status, ASPA metrics, and local DB stats + if (reqPath === "/api/health") { + const mem = process.memoryUsage(); + const aspaAge = rpkiAspaLastFetch ? Math.floor((Date.now() - rpkiAspaLastFetch) / 60000) : -1; + const pdbTotal = pdbSourceCache.hits + pdbSourceCache.misses; + + // Query local DB stats (async, but return partial if needed) + localDb.getLocalDbStats().then(function(dbStats) { + // Determine health status based on local DB data availability + const hasLocalBgp = dbStats && dbStats.bgp_routes > 100000; // should have >2M rows normally + const hasLocalRpki = dbStats && dbStats.rpki_roas > 100000; // should have >500k rows normally + const status = (hasLocalBgp && hasLocalRpki && aspaAge < 300) ? "ok" : "degraded"; + + const healthResponse = { + status, + service: "PeerCortex", + version: "0.6.9", + timestamp: new Date().toISOString(), + uptime_seconds: Math.floor(process.uptime()), + memory_mb: Math.round(mem.heapUsed / 1024 / 1024), + bgproutes_configured: !!BGPROUTES_API_KEY, + caches: { + aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge }, + pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 }, + pdb_netixlan: { entries: pdbSourceCache.netixlan.size }, + pdb_netfac: { entries: pdbSourceCache.netfac.size }, + ripe_stat: { entries: ripeStatCache.size }, + response_cache: { entries: responseCache.size }, + }, + local_db: dbStats ? { + bgp_routes: dbStats.bgp_routes, + rpki_roas: dbStats.rpki_roas, + threat_intel: dbStats.threat_intel, + rdap_cache_entries: dbStats.rdap_cache_entries, + source: "PostgreSQL (local)", + healthy: hasLocalBgp && hasLocalRpki, + } : null, + aspa_adoption: { + total_objects: rpkiAspaMap.size, + roa_count: dbStats ? dbStats.rpki_roas : 0, + history_samples: aspaAdoptionHistory.length, + delta_last: aspaAdoptionHistory.length >= 2 + ? aspaAdoptionHistory[aspaAdoptionHistory.length - 1].aspa_count - aspaAdoptionHistory[aspaAdoptionHistory.length - 2].aspa_count + : 0, + }, + }; + return res.end(JSON.stringify(healthResponse, null, 2)); + }).catch(function(e) { + console.error('[/api/health] Local DB stats error:', e.message); + // Return health without local DB stats on error + return res.end( + JSON.stringify({ + status, + service: "PeerCortex", + version: "0.6.9", + timestamp: new Date().toISOString(), + uptime_seconds: Math.floor(process.uptime()), + memory_mb: Math.round(mem.heapUsed / 1024 / 1024), + bgproutes_configured: !!BGPROUTES_API_KEY, + caches: { + roa_store: { entries: roaStore.count, age_minutes: roaAge, ready: roaStore.ready }, + aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge }, + pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 }, + pdb_netixlan: { entries: pdbSourceCache.netixlan.size }, + pdb_netfac: { entries: pdbSourceCache.netfac.size }, + ripe_stat: { entries: ripeStatCache.size }, + response_cache: { entries: responseCache.size }, + }, + local_db: { error: "Could not fetch local DB stats", message: e.message }, + aspa_adoption: { + total_objects: rpkiAspaMap.size, + roa_count: roaStore.count, + history_samples: aspaAdoptionHistory.length, + delta_last: aspaAdoptionHistory.length >= 2 + ? aspaAdoptionHistory[aspaAdoptionHistory.length - 1].aspa_count - aspaAdoptionHistory[aspaAdoptionHistory.length - 2].aspa_count + : 0, + }, + }, null, 2) + ); + }); + return; + } + + // ============================================================ + // ASPA Deep Verification endpoint: /api/aspa/verify?asn=X + // ============================================================ + if (reqPath === "/api/aspa/verify") { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); + } + const cachedVerify = resultCacheGet(aspaResultCache, "verify:" + rawAsn); + if (cachedVerify !== undefined) { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(cachedVerify)); + } + const targetAsn = parseInt(rawAsn); + const start = Date.now(); + + try { + // Fetch neighbour and prefix data first + const [neighbourData, prefixData] = await Promise.all([ + fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }), + fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }), + ]); + + // Use looking-glass with actual prefixes to get BGP paths + const announcedPrefixes = prefixData?.data?.prefixes || []; + const samplePrefixes = announcedPrefixes.slice(0, 3).map((p) => p.prefix); // reduced 5→3 + + // Fetch looking-glass data for up to 3 prefixes in parallel (3s timeout each) + const lgResults = await Promise.all( + samplePrefixes.map((pfx) => + fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=" + encodeURIComponent(pfx), { timeout: 3000 }).catch(() => null) + ) + ); + + // Extract AS paths from looking glass results + const allPaths = []; + const pathNeighbourCount = new Map(); // Count how often each AS appears next to target in paths + + lgResults.forEach((lgData) => { + const rrcs = lgData?.data?.rrcs || []; + rrcs.forEach((rrc) => { + const peers = rrc.peers || []; + peers.forEach((peer) => { + const rawPath = peer.as_path || ""; + const pathArr = rawPath.split(" ").map(Number).filter(Boolean); + if (pathArr.length > 1) { + allPaths.push({ + rrc: rrc.rrc, + path: pathArr, + rawPath: rawPath, + prefix: peer.prefix || "", + hasAsSet: hasAsSet(rawPath), + }); + const idx = pathArr.indexOf(targetAsn); + if (idx > 0) { + const neighbour = pathArr[idx - 1]; + pathNeighbourCount.set(neighbour, (pathNeighbourCount.get(neighbour) || 0) + 1); + } + } + }); + }); + }); + + // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams) + // AS-path analysis is used for frequency/confirmation, NOT as standalone provider source + const neighbours = neighbourData?.data?.neighbours || []; + const leftNeighbours = neighbours.filter((n) => n.type === "left"); + const upstreamSet = new Set(); + leftNeighbours.forEach((n) => upstreamSet.add(n.asn)); + + // Classify left neighbours: high-power = likely upstream, low-power = likely peer + const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1); + const detectedProviders = [...upstreamSet].map((asn) => { + const nb = leftNeighbours.find((n) => n.asn === asn); + const power = nb ? (nb.power || 0) : 0; + const powerPct = Math.round((power / maxPower) * 100); + const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer"; + return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification }; + }); + + await resolveASNames(detectedProviders); + + // Count how often each provider appears in paths + const providerFrequency = new Map(); + allPaths.forEach((p) => { + const idx = p.path.indexOf(targetAsn); + if (idx > 0) { + const prov = p.path[idx - 1]; + providerFrequency.set(prov, (providerFrequency.get(prov) || 0) + 1); + } + }); + + // Check Cloudflare RPKI feed for ASPA object + await ensureAspaCache(); + const aspaLookup = lookupAspaFromRpki(targetAsn); + const aspaObjectExists = aspaLookup.exists; + const aspaDeclaredProviders = aspaLookup.providers; + + // Build ASPA store from RPKI feed data (real ASPA objects) + const aspaStore = new Map(); + // Add the target ASN's RPKI-declared providers + if (aspaObjectExists) { + aspaStore.set(targetAsn, new Set(aspaDeclaredProviders)); + } else { + // Fallback: use detected providers for path verification + const providerSet = new Set(detectedProviders.map((p) => p.asn)); + aspaStore.set(targetAsn, providerSet); + } + // Also populate store with all known ASPA objects from the RPKI feed + // for providers that have their own ASPA objects (enables full path verification) + for (const [cas, provSet] of rpkiAspaMap) { + if (!aspaStore.has(cas)) { + aspaStore.set(cas, provSet); + } + } + + // Also add reverse relationships for providers we know about + // (each provider has the target as customer) + detectedProviders.forEach((p) => { + if (!aspaStore.has(p.asn)) { + aspaStore.set(p.asn, new Set()); + } + }); + + // Sample paths for verification (up to 50) + const samplePaths = allPaths.slice(0, 50); + const pathResults = samplePaths.map((p) => { + const upstream = verifyUpstream(p.path, aspaStore, p.rawPath); + const downstream = verifyDownstream(p.path, aspaStore, p.rawPath); + const valleys = detectValleys(p.path, aspaStore); + + return { + rrc: p.rrc, + prefix: p.prefix, + path: p.path.map((a) => "AS" + a).join(" "), + collapsed_path: collapsePrepends(p.path).map((a) => "AS" + a).join(" "), + has_as_set: p.hasAsSet, + upstream_verification: upstream, + downstream_verification: downstream, + valleys: valleys, + overall: p.hasAsSet + ? "Invalid" + : upstream.result === "Valid" && downstream.result === "Valid" + ? "Valid" + : upstream.result === "Invalid" || downstream.result === "Invalid" + ? "Invalid" + : "Unknown", + }; + }); + + // Calculate statistics + const validPaths = pathResults.filter((p) => p.overall === "Valid").length; + const invalidPaths = pathResults.filter((p) => p.overall === "Invalid").length; + const unknownPaths = pathResults.filter((p) => p.overall === "Unknown").length; + const asSetPaths = pathResults.filter((p) => p.has_as_set).length; + const valleyPaths = pathResults.filter((p) => p.valleys.length > 0).length; + + // For readiness scoring: Valid = full credit, Unknown = partial (no ASPA data is normal), + // only Invalid actually indicates problems + const pathNotInvalidPct = pathResults.length > 0 + ? Math.round(((validPaths + unknownPaths) / pathResults.length) * 100) + : 0; + const pathValidPct = pathResults.length > 0 ? Math.round((validPaths / pathResults.length) * 100) : 0; + + // Provider audit: compare detected vs declared + const detectedSet = new Set(detectedProviders.map((p) => p.asn)); + const declaredSet = new Set(aspaDeclaredProviders); + + const missingFromAspa = detectedProviders + .filter((p) => !declaredSet.has(p.asn)) + .map((p) => ({ + asn: p.asn, + name: p.name, + frequency: providerFrequency.get(p.asn) || 0, + frequency_pct: allPaths.length > 0 + ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100) + : 0, + })) + .sort((a, b) => b.frequency - a.frequency); + + const extraInAspa = aspaDeclaredProviders + .filter((asn) => !detectedSet.has(asn)) + .map((asn) => ({ + asn, + name: "", + seen_in_paths: false, + })); + + const providerCompleteness = detectedProviders.length > 0 + ? Math.round( + (detectedProviders.filter((p) => declaredSet.has(p.asn)).length / + detectedProviders.length) * + 100 + ) + : aspaObjectExists ? 100 : 0; + + // Get RPKI coverage for readiness score + // Validate ALL prefixes using local RPKI data (Cloudflare feed - all 5 RIRs) + await ensureAspaCache(); + const rpkiBatch = announcedPrefixes.map((p) => p.prefix); + const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx))); + const rpkiValid = rpkiResults.filter((r) => r.status === "valid").length; + const rpkiCoverage = rpkiResults.length > 0 ? Math.round((rpkiValid / rpkiResults.length) * 100) : 0; + + // Calculate readiness score + const readinessScore = calculateAspaReadinessScore({ + rpkiCoverage, + aspaObjectExists, + providerCompleteness, + pathValidationPct: pathNotInvalidPct, + }); + + const duration = Date.now() - start; + const verifyResult = { + meta: { + query: "AS" + rawAsn, + duration_ms: duration, + timestamp: new Date().toISOString(), + paths_analyzed: pathResults.length, + total_paths_seen: allPaths.length, + }, + asn: targetAsn, + readiness_score: readinessScore, + aspa_object_exists: aspaObjectExists, + detected_providers: detectedProviders.map((p) => ({ + ...p, + frequency: providerFrequency.get(p.asn) || 0, + frequency_pct: allPaths.length > 0 + ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100) + : 0, + })), + provider_audit: { + declared_count: aspaDeclaredProviders.length, + detected_count: detectedProviders.length, + completeness_pct: providerCompleteness, + missing_from_aspa: missingFromAspa, + extra_in_aspa: extraInAspa, + }, + path_verification: { + total: pathResults.length, + valid: validPaths, + invalid: invalidPaths, + unknown: unknownPaths, + as_set_flagged: asSetPaths, + valley_detected: valleyPaths, + valid_pct: pathValidPct, + not_invalid_pct: pathNotInvalidPct, + results: pathResults, + }, + rpki_coverage: rpkiCoverage, + }; + resultCacheSet(aspaResultCache, "verify:" + rawAsn, verifyResult); + return res.end(JSON.stringify(verifyResult, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "ASPA verification failed", message: err.message })); + } + } + + // ============================================================ + // ASPA Check endpoint: /api/aspa?asn=X (existing, kept for compat) + // ============================================================ + if (reqPath === "/api/aspa") { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); + } + const cachedAspa = resultCacheGet(aspaResultCache, rawAsn); + if (cachedAspa !== undefined) { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(cachedAspa)); + } + const start = Date.now(); + let _aspaDone = false; + const _aspaTimer = setTimeout(() => { + if (!_aspaDone) { + _aspaDone = true; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) })); + } + }, 12000); + try { + const [lgData, neighbourData] = await Promise.all([ + fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 3000 }).catch(() => null), + fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 4000 }), + ]); + + const rrcs = lgData?.data?.rrcs || []; + const asPaths = []; + + rrcs.forEach((rrc) => { + const peers = rrc.peers || []; + peers.forEach((peer) => { + const path = peer.as_path || ""; + const pathArr = path.split(" ").map(Number).filter(Boolean); + if (pathArr.length > 1) { + asPaths.push({ rrc: rrc.rrc, path: pathArr, prefix: peer.prefix || "" }); + } + }); + }); + + // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams) + const neighbours = neighbourData?.data?.neighbours || []; + const leftNeighbours = neighbours.filter((n) => n.type === "left"); + const upstreamSet = new Set(); + leftNeighbours.forEach((n) => upstreamSet.add(n.asn)); + + // Classify left neighbours: high-power = likely upstream, low-power = likely peer + const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1); + const detectedProviders = [...upstreamSet].map((asn) => { + const nb = leftNeighbours.find((n) => n.asn === asn); + const power = nb ? (nb.power || 0) : 0; + const powerPct = Math.round((power / maxPower) * 100); + const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer"; + return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification }; + }); + + await resolveASNames(detectedProviders); + + // Check Cloudflare RPKI feed for ASPA object + await ensureAspaCache(); + const aspaLookup = lookupAspaFromRpki(rawAsn); + const aspaObjectExists = aspaLookup.exists; + const aspaDeclaredProviders = aspaLookup.providers; + + const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", "); + let recommendedAspa = + "aut-num: AS" + rawAsn + "\n" + + "# Recommended ASPA object:\n" + + "# customer: AS" + rawAsn + "\n" + + "# provider-set: " + providerList + "\n" + + "# AFI: ipv4, ipv6\n" + + "#\n" + + "# Detected providers from BGP path analysis:\n" + + detectedProviders.map((p) => "# AS" + p.asn + (p.name ? " (" + p.name + ")" : "")).join("\n"); + + // If ASPA object exists, show RPKI-declared providers + if (aspaObjectExists && aspaDeclaredProviders.length > 0) { + recommendedAspa += "\n#\n# RPKI-declared providers (from Cloudflare RPKI feed):\n" + + aspaDeclaredProviders.map((a) => "# AS" + a).join("\n"); + } + + const samplePaths = asPaths.slice(0, 10).map((p) => { + const pathStr = p.path.map((a) => "AS" + a).join(" -> "); + const idx = p.path.indexOf(parseInt(rawAsn)); + const provider = idx > 0 ? p.path[idx - 1] : null; + return { + rrc: p.rrc, + prefix: p.prefix, + path: pathStr, + detected_provider: provider ? "AS" + provider : null, + provider_in_set: provider ? upstreamSet.has(provider) : false, + }; + }); + + if (_aspaDone) return; // hard timeout already responded + _aspaDone = true; + clearTimeout(_aspaTimer); + const duration = Date.now() - start; + const aspaResult = { + meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() }, + asn: parseInt(rawAsn), + detected_providers: detectedProviders, + provider_count: detectedProviders.length, + aspa_object_exists: aspaObjectExists, + aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })), + aspa_declared_count: aspaDeclaredProviders.length, + recommended_aspa: recommendedAspa, + path_analysis: { + total_paths_seen: asPaths.length, + sample_paths: samplePaths, + }, + }; + resultCacheSet(aspaResultCache, rawAsn, aspaResult); + return res.end(JSON.stringify(aspaResult, null, 2)); + } catch (err) { + if (!_aspaDone) { + _aspaDone = true; + clearTimeout(_aspaTimer); + res.writeHead(500); + return res.end(JSON.stringify({ error: "ASPA check failed", message: err.message })); + } + } + } + + // ============================================================ + // BGP endpoint (LOCAL DB): /api/bgp?asn=X (or prefix=X) + // Queries local PostgreSQL bgp_routes table — zero external API calls + // ============================================================ + if (reqPath === "/api/bgp") { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + const prefix = url.searchParams.get("prefix") || ""; + if (!rawAsn && !prefix) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Need asn or prefix parameter" })); + } + const cacheKey = rawAsn || prefix; + const cached = resultCacheGet(bgproutesResultCache, cacheKey); + if (cached !== undefined) { + res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); + return res.end(JSON.stringify(cached)); + } + const start = Date.now(); + try { + const result = { + meta: { timestamp: new Date().toISOString(), source: "local_bgp_db" }, + bgp_status: null, + threat_intel: null, + }; + + // ---- BGP Status (local DB lookup) ---- + if (prefix) { + // Prefix lookup: Get BGP status for this prefix + const bgpStatus = await localDb.getBgpStatus(prefix); + if (bgpStatus) { + result.bgp_status = { + prefix, + announced: bgpStatus.announced, + origin_asns: bgpStatus.origin_asns, + visibility_percent: bgpStatus.visibility_percent, + last_seen: bgpStatus.last_seen, + source: "local_bgp", + }; + // Check for hijack (multiple origin ASNs) + const hijackAsns = await localDb.checkBgpHijack(prefix); + if (hijackAsns.length > 1) { + result.bgp_status.hijack_warning = { + detected: true, + origin_asns: hijackAsns, + message: `Multiple origin ASNs detected for ${prefix}`, + }; + } + } + } else if (rawAsn) { + // ASN lookup: Get all announced prefixes for this ASN + const prefixes = await localDb.getAnnouncedPrefixes(rawAsn); + if (prefixes && prefixes.length > 0) { + result.bgp_status = { + asn: rawAsn, + announced_count: prefixes.length, + prefixes: prefixes.slice(0, 50).map((p) => ({ + prefix: p.prefix, + origin_asn: p.origin_asn, + visibility_percent: p.visibility_percent, + last_seen: p.last_seen, + })), + source: "local_bgp", + }; + } else { + result.bgp_status = { + asn: rawAsn, + announced: false, + announced_count: 0, + message: "No prefixes found for this ASN in local BGP table", + source: "local_bgp", + }; + } + } + + // ---- Threat Intelligence (local cache lookup) ---- + // If we have an IP context, look up threat intel + if (prefix && prefix.includes(".")) { + // Extract IP from prefix (e.g., "1.1.1.0/24" → "1.1.1.0") + const ipAddr = prefix.split("/")[0]; + const threat = await localDb.getThreatIntel(ipAddr); + if (threat) { + result.threat_intel = { + ip_address: threat.ip_address, + threat_level: threat.threat_level, + confidence_score: threat.confidence_score, + source: threat.source, + cached_at: threat.cached_at, + }; + } + } + + result.meta.duration_ms = Date.now() - start; + resultCacheSet(bgproutesResultCache, cacheKey, result); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(result, null, 2)); + } catch (err) { + console.error("[/api/bgp] Error:", err.message); + res.writeHead(500); + return res.end(JSON.stringify({ error: "BGP query failed", message: err.message })); + } + } + + + // ============================================================ + // Unified Validation endpoint: /api/validate?asn=X + // Runs ALL validations in parallel, returns comprehensive report + // ============================================================ + if (reqPath === "/api/validate") { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); + } + const cachedValidate = resultCacheGet(validateResultCache, rawAsn); + if (cachedValidate !== undefined) { + res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); + return res.end(JSON.stringify(cachedValidate)); + } + const start = Date.now(); + const targetAsn = parseInt(rawAsn); + + try { + // Phase 1: Fetch core data — 5s cap prevents large ASNs from blocking Phase 2 + const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([ + fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }), + fetchPeeringDB("/net?asn=" + rawAsn), + fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }), + fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn, { timeout: 5000 }), + ]); + + const allPrefixes = (prefixData && prefixData.data && prefixData.data.prefixes ? prefixData.data.prefixes : []).map(function(p) { return p.prefix; }); + // Use all prefixes for RPKI validation (local lookup is fast, no API calls) + const samplePrefixes = allPrefixes; + const net = pdbNet && pdbNet.data && pdbNet.data[0] ? pdbNet.data[0] : {}; + const netId = net.id; + const neighbours = neighbourData && neighbourData.data && neighbourData.data.neighbours ? neighbourData.data.neighbours : []; + + // ---- 11. Bogon Detection (local check) ---- + function checkBogonPrefix(prefix) { + var bogonV4 = [ + { net: "0.0.0.0", mask: 8 }, { net: "10.0.0.0", mask: 8 }, + { net: "100.64.0.0", mask: 10 }, { net: "127.0.0.0", mask: 8 }, + { net: "169.254.0.0", mask: 16 }, { net: "172.16.0.0", mask: 12 }, + { net: "192.0.2.0", mask: 24 }, { net: "192.168.0.0", mask: 16 }, + { net: "198.51.100.0", mask: 24 }, { net: "203.0.113.0", mask: 24 }, + { net: "240.0.0.0", mask: 4 }, + ]; + if (prefix.includes(":")) return { prefix: prefix, is_bogon: false, reason: "IPv6 bogon check skipped" }; + var split = prefix.split("/"); + var addr = split[0]; + var mask = parseInt(split[1] || "0"); + var parts = addr.split(".").map(Number); + var ip = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; + for (var bi = 0; bi < bogonV4.length; bi++) { + var b = bogonV4[bi]; + var bParts = b.net.split(".").map(Number); + var bIp = ((bParts[0] << 24) | (bParts[1] << 16) | (bParts[2] << 8) | bParts[3]) >>> 0; + var bMask = (~((1 << (32 - b.mask)) - 1)) >>> 0; + if ((ip & bMask) === (bIp & bMask) && mask >= b.mask) { + return { prefix: prefix, is_bogon: true, reason: "Matches bogon " + b.net + "/" + b.mask }; + } + } + return { prefix: prefix, is_bogon: false }; + } + + function checkBogonAsn(asnNum) { + if (asnNum === 0 || asnNum === 23456 || asnNum === 65535) return true; + if (asnNum >= 64496 && asnNum <= 64511) return true; + if (asnNum >= 64512 && asnNum <= 65534) return true; + return false; + } + + var bogonPrefixResults = allPrefixes.map(checkBogonPrefix); + var bogonPrefixes = bogonPrefixResults.filter(function(r) { return r.is_bogon; }); + var asnInPaths = neighbours.map(function(n) { return n.asn; }); + var bogonAsns = asnInPaths.filter(checkBogonAsn); + var bogonResult = { + status: bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail", + bogon_prefixes: bogonPrefixes, + bogon_asns_in_paths: bogonAsns, + total_prefixes_checked: allPrefixes.length, + }; + + // Phase 2: All API-dependent validations in parallel + var validationPromises = {}; + + // 12. IRR Validation + validationPromises.irr = fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) { + var entries = Array.isArray(irrData) ? irrData : []; + var mismatches = entries.filter(function(e) { + if (!e.bgpOrigins && !e.bgp_origins) return false; + if (!e.irrRoutes && !e.irr_origins) return false; + var bgpArr = e.bgpOrigins || e.bgp_origins || []; + var irrArr = e.irrRoutes || e.irr_origins || []; + var bgpSet = {}; + bgpArr.forEach(function(a) { bgpSet[String(typeof a === "object" ? a.asn : a)] = true; }); + var match = false; + irrArr.forEach(function(a) { if (bgpSet[String(typeof a === "object" ? a.asn : a)]) match = true; }); + return Object.keys(bgpSet).length > 0 && irrArr.length > 0 && !match; + }); + return { + status: mismatches.length === 0 ? "pass" : "warning", + total_entries: entries.length, + mismatches: mismatches.slice(0, 10).map(function(e) { return { prefix: e.prefix, bgp_origins: e.bgpOrigins || e.bgp_origins, irr_origins: e.irrRoutes || e.irr_origins }; }), + mismatch_count: mismatches.length, + }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); + + // 13. RPKI ROA Completeness (local validation against Cloudflare RPKI feed - all RIRs) + await ensureAspaCache(); // Ensure ROA data is loaded + validationPromises.rpki_completeness = Promise.all( + allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); }) + ).then(function(rpkiResults) { + var withRoa = rpkiResults.filter(function(r) { return r.status === "valid"; }); + var coverage = rpkiResults.length > 0 ? Math.round((withRoa.length / rpkiResults.length) * 100) : 0; + var overSpecific = rpkiResults.filter(function(r) { + var mask = parseInt((r.prefix || "").split("/")[1] || "0"); + return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid"; + }); + return { + status: coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail", + coverage_pct: coverage, + total_checked: rpkiResults.length, + with_roa: withRoa.length, + over_specific: overSpecific.map(function(r) { return r.prefix; }), + details: rpkiResults, + }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); + + // 14. Abuse Contact Validation + validationPromises.abuse_contact = fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) { + var contacts = data && data.data && data.data.abuse_contacts ? data.data.abuse_contacts : []; + var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); }); + return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); + + // 15. Spamhaus DROP/Blocklist + validationPromises.blocklist = Promise.all( + samplePrefixes.slice(0, 5).map(function(pfx) { + return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) { + var sources = data && data.data && data.data.sources ? data.data.sources : []; + var listed = sources.filter(function(s) { return s.prefix_count > 0 || (s.entries && s.entries.length > 0); }); + return { prefix: pfx, listed: listed.length > 0, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) }; + }).catch(function() { return { prefix: pfx, listed: false, error: true }; }); + }) + ).then(function(results) { + var listedPrefixes = results.filter(function(r) { return r.listed; }); + return { status: listedPrefixes.length === 0 ? "pass" : "fail", checked: results.length, listed_prefixes: listedPrefixes }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); + + // 16. MANRS Compliance — scraped from public participants list (24h cache) + validationPromises.manrs = ensureManrsCache().then(function() { + return checkManrsMembership(rawAsn); + }).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable: " + e.message, note: "https://www.manrs.org/netops/participants/" }; }); + + // 17. Reverse DNS Coverage (3 prefix sample — more causes semaphore starvation on large ASNs) + var rdnsSampleSize = Math.min(3, samplePrefixes.length); + validationPromises.rdns = Promise.all( + samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) { + return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) { + var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {}; + var hasDelegation = false; + var details = []; + // API returns { ipv4: { "prefix": { complete, domains } }, ipv6: { ... } } + ["ipv4", "ipv6"].forEach(function(af) { + var afData = pfxData[af] || {}; + Object.keys(afData).forEach(function(p) { + var entry = afData[p]; + if (entry && entry.complete) hasDelegation = true; + if (entry && entry.domains) { + entry.domains.forEach(function(d) { + if (d.found) hasDelegation = true; + details.push({ domain: d.domain, found: !!d.found }); + }); + } + }); + }); + // Fallback: old array format + if (Array.isArray(pfxData)) { + pfxData.forEach(function(p) { + if (p.ipv4 || p.ipv6 || (p.delegations && p.delegations.length > 0)) hasDelegation = true; + }); + } + return { prefix: pfx, has_rdns: hasDelegation, details: details }; + }).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; }); + }) + ).then(function(results) { + var withRdns = results.filter(function(r) { return r.has_rdns; }); + var coverage = results.length > 0 ? Math.round((withRdns.length / results.length) * 100) : 0; + // Include details of what failed + var failedPrefixes = results.filter(function(r) { return !r.has_rdns && !r.error; }).map(function(r) { return r.prefix; }); + return { status: coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail", coverage_pct: coverage, checked: results.length, results: results, failed_prefixes: failedPrefixes }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); + + // 18. BGP Visibility (uses routing-status API which is more reliable than visibility API) + validationPromises.visibility = fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 8000 }).then(function(rsData) { + var vis = rsData && rsData.data && rsData.data.visibility ? rsData.data.visibility : {}; + var v4 = vis.v4 || {}; + var v6 = vis.v6 || {}; + var totalPeers = (v4.total_ris_peers || 0) + (v6.total_ris_peers || 0); + var seeingPeers = (v4.ris_peers_seeing || 0) + (v6.ris_peers_seeing || 0); + var score = totalPeers > 0 ? Math.round((seeingPeers / totalPeers) * 100) : 0; + var observedNeighbours = rsData && rsData.data ? (rsData.data.observed_neighbours || 0) : 0; + // If routing-status returned no data, try bgproutes.io + if (totalPeers === 0 && samplePrefixes[0]) { + return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) { + if (bgprFb && bgprFb.vps_seeing > 0) { + seeingPeers = bgprFb.vps_seeing; + totalPeers = Math.max(bgprFb.vps_seeing, 300); + score = Math.round((seeingPeers / totalPeers) * 100); + } + return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "bgproutes.io_fallback" }; + }).catch(function() { + return { status: "fail", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" }; + }); + } + return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "ripe_routing_status" }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); + + // 19. BGP Communities Analysis + validationPromises.communities = (samplePrefixes.length > 0 + ? (function() { + var now = new Date(); + var end = now.toISOString().replace(/\.\d+Z/, ""); + var startTime = new Date(now.getTime() - 3600000).toISOString().replace(/\.\d+Z/, ""); + return fetchRipeStatCached("https://stat.ripe.net/data/bgp-updates/data.json?resource=" + encodeURIComponent(samplePrefixes[0]) + "&starttime=" + startTime + "&endtime=" + end); + })() + : Promise.resolve(null) + ).then(function(data) { + var updates = data && data.data && data.data.updates ? data.data.updates : []; + var communityMap = {}; + var wellKnown = { "65535:0": "GRACEFUL_SHUTDOWN", "65535:65281": "NO_EXPORT", "65535:65282": "NO_ADVERTISE", "65535:666": "BLACKHOLE" }; + updates.forEach(function(u) { + var attrs = u.attrs || {}; + var communities = attrs.community || []; + communities.forEach(function(c) { + var key = Array.isArray(c) ? c.join(":") : String(c); + if (!communityMap[key]) communityMap[key] = { community: key, count: 0, well_known: wellKnown[key] || null }; + communityMap[key].count++; + }); + }); + var sorted = Object.values(communityMap).sort(function(a, b) { return b.count - a.count; }); + var hasBlackhole = sorted.some(function(c) { return c.well_known === "BLACKHOLE"; }); + return { status: hasBlackhole ? "warning" : "pass", total_updates: updates.length, unique_communities: sorted.length, top_communities: sorted.slice(0, 20), well_known_detected: sorted.filter(function(c) { return c.well_known; }) }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); + + // 20. Geolocation Verification + validationPromises.geolocation = (samplePrefixes.length > 0 + ? fetchRipeStatCached("https://stat.ripe.net/data/maxmind-geo-lite-pfx/data.json?resource=" + encodeURIComponent(samplePrefixes[0])) + : Promise.resolve(null) + ).then(function(data) { + var locatedPfxs = data && data.data && data.data.located_resources ? data.data.located_resources : []; + var countries = {}; + locatedPfxs.forEach(function(l) { var locs = l.locations || []; locs.forEach(function(loc) { if (loc.country) countries[loc.country] = true; }); }); + return { status: Object.keys(countries).length > 0 ? "pass" : "warning", geo_countries: Object.keys(countries), sample_prefix: samplePrefixes[0] || null, located_resources: locatedPfxs.length }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); + + // 21. RPSL/IRR Object Validation (query all 5 RIRs in parallel) + validationPromises.rpsl = (function() { + // Try RIPE first (has richest policy data), then RDAP for other RIRs + var ripePromise = fetchJSON("https://rest.db.ripe.net/lookup/ripe/aut-num/AS" + rawAsn + ".json", { timeout: 5000 }).then(function(data) { + var objects = data && data.objects && data.objects.object ? data.objects.object : []; + if (objects.length === 0) return null; + var attrs = objects[0] && objects[0].attributes && objects[0].attributes.attribute ? objects[0].attributes.attribute : []; + var hasImport = attrs.some(function(a) { return a.name === "import" || a.name === "mp-import"; }); + var hasExport = attrs.some(function(a) { return a.name === "export" || a.name === "mp-export"; }); + var hasRemarks = attrs.some(function(a) { return a.name === "remarks"; }); + return { status: (hasImport || hasExport) ? "pass" : "warning", exists: true, has_import: hasImport, has_export: hasExport, has_remarks: hasRemarks, has_policy: hasImport || hasExport, source: "RIPE" }; + }).catch(function() { return null; }); + + var rdapEndpoints = [ + { name: "APNIC", url: "https://rdap.apnic.net/autnum/" + rawAsn }, + { name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + rawAsn }, + { name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + rawAsn }, + { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + rawAsn }, + ]; + var rdapPromises = rdapEndpoints.map(function(ep) { + return fetchJSON(ep.url, { timeout: 5000 }).then(function(data) { + if (!data || data.errorCode || !data.handle) return null; + var hasRemarks = !!(data.remarks && data.remarks.length > 0); + var name = data.name || ""; + return { status: hasRemarks ? "pass" : "warning", exists: true, has_import: false, has_export: false, has_remarks: hasRemarks, has_policy: false, source: ep.name, rdap_name: name, rdap_handle: data.handle || "" }; + }).catch(function() { return null; }); + }); + + return Promise.all([ripePromise].concat(rdapPromises)).then(function(results) { + // Take first successful result + for (var ri = 0; ri < results.length; ri++) { + if (results[ri] !== null) return results[ri]; + } + return { status: "warning", exists: false, has_policy: false }; + }); + })(); + + // 22. IXP Route Server Participation (Bug 5 fix: fair scoring for bilateral peering) + // Always use asn= for netixlan (more reliable than net_id when PDB rate-limits) + var ixRsQueryUrl = "/netixlan?asn=" + rawAsn; + { + validationPromises.ix_route_server = fetchPeeringDB(ixRsQueryUrl).then(function(ixData) { + var connections = ixData && ixData.data ? ixData.data : []; + var rsParticipants = connections.filter(function(c) { return c.is_rs_peer === true; }); + var totalIx = connections.length; + var rsCount = rsParticipants.length; + var rsPct = totalIx > 0 ? Math.round((rsCount / totalIx) * 100) : 0; + var status, note; + + if (totalIx > 0 && rsCount > 0) { + // Using route servers - good + status = "pass"; + note = rsCount + " of " + totalIx + " IX connections use route servers (" + rsPct + "%)"; + } else if (totalIx >= 10 && rsCount === 0) { + // Network with 10+ IX connections but no RS = deliberate bilateral peering policy + status = "pass"; + note = "Bilateral peering policy — " + totalIx + " IX connections, all bilateral (no route server usage)"; + } else if (totalIx < 3 && rsCount === 0) { + // Very small IX presence and no RS + status = "warning"; + note = "Only " + totalIx + " IX connection(s) and no route server usage"; + } else { + // Small-medium network (3-9 IX) without RS - informational + status = "info"; + note = totalIx + " IX connections without route server usage — consider enabling RS for broader reachability"; + } + + return { status: status, total_ix_connections: totalIx, rs_peer_count: rsCount, rs_peer_pct: rsPct, note: note }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); + } + + // 23. Resource Certification (local RPKI validation - all prefixes, all RIRs) + validationPromises.resource_cert = Promise.all( + allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); }) + ).then(function(results) { + var hasRoa = results.some(function(r) { return r.status === "valid" || r.validating_roas > 0; }); + return { status: hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: results.length, roa_count: results.filter(function(r) { return r.status === "valid"; }).length }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); + + // Geolocation cross-ref with PeeringDB facilities + var facCountriesPromise = netId + ? fetchPeeringDB("/netfac?net_id=" + netId).then(function(facData) { + return (facData && facData.data ? facData.data : []).map(function(f) { return f.country; }).filter(Boolean); + }).catch(function() { return []; }) + : Promise.resolve([]); + + // Run all validations in parallel — 5s cap per check, total validate bounded to ~10s + var keys = Object.keys(validationPromises); + var promises = keys.map(function(k) { + return Promise.race([ + validationPromises[k], + new Promise(function(resolve) { setTimeout(function() { resolve({ status: "info", message: "timed out" }); }, 5000); }), + ]); + }); + var settled = await Promise.allSettled(promises); + var facCountries = await facCountriesPromise; + + var validations = {}; + keys.forEach(function(key, i) { + if (settled[i].status === "fulfilled") { + validations[key] = settled[i].value; + } else { + validations[key] = { status: "error", error: settled[i].reason ? String(settled[i].reason) : "Unknown error" }; + } + }); + + // Enrich geolocation (Bug 4 fix: handle anycast/CDN/global networks) + if (validations.geolocation && validations.geolocation.status !== "error") { + var uniqueFacCountries = {}; + facCountries.forEach(function(c) { uniqueFacCountries[c] = true; }); + var facCountryCount = Object.keys(uniqueFacCountries).length; + validations.geolocation.pdb_facility_countries = Object.keys(uniqueFacCountries); + var geoSet = {}; + (validations.geolocation.geo_countries || []).forEach(function(c) { geoSet[c] = true; }); + var geoCountryCount = Object.keys(geoSet).length; + var mismatches = Object.keys(geoSet).filter(function(c) { return !uniqueFacCountries[c] && facCountryCount > 0; }); + validations.geolocation.country_mismatches = mismatches; + + // Detect global/anycast networks: 5+ facility countries OR Content/NSP type + var netInfoType = (net.info_type || "").toLowerCase(); + var isGlobalNetwork = facCountryCount >= 5 || netInfoType === "content" || netInfoType === "nsp"; + if (isGlobalNetwork) { + // Global/anycast/CDN network: geo mismatches are expected, not anomalies + validations.geolocation.status = "pass"; + if (geoCountryCount === 0) { + validations.geolocation.note = "Global network (" + facCountryCount + " countries, type: " + (net.info_type || "N/A") + ") - no MaxMind geolocation data available"; + } else { + validations.geolocation.note = "Global/anycast network - multi-country presence expected (" + facCountryCount + " facility countries, type: " + (net.info_type || "N/A") + ")"; + } + validations.geolocation.country_mismatches = []; + } else if (facCountryCount <= 2 && geoCountryCount >= 10) { + // Actual anomaly: small network appearing in many countries + validations.geolocation.status = "warning"; + validations.geolocation.note = "Prefixes geolocated in " + geoCountryCount + " countries but only " + facCountryCount + " facility countries - possible hijack or misconfiguration"; + } + } + + validations.bogon = bogonResult; + + // Calculate overall health score (0-100) + var checks = [ + { key: "bogon", weight: 15 }, + { key: "irr", weight: 10 }, + { key: "rpki_completeness", weight: 15 }, + { key: "abuse_contact", weight: 5 }, + { key: "blocklist", weight: 15 }, + { key: "manrs", weight: 5 }, + { key: "rdns", weight: 5 }, + { key: "visibility", weight: 10 }, + { key: "rpsl", weight: 5 }, + { key: "ix_route_server", weight: 5 }, + { key: "resource_cert", weight: 10 }, + ]; + + var totalWeight = 0; + var earnedScore = 0; + var checkResults = []; + + checks.forEach(function(c) { + var v = validations[c.key]; + var points = 0; + if (v && v.status === "info") { + // "info" = unable to verify (e.g. API auth required) — exclude from scoring + checkResults.push({ check: c.key, weight: c.weight, earned: 0, status: "info" }); + return; + } + if (v && v.status === "pass") points = c.weight; + else if (v && v.status === "warning") points = Math.round(c.weight * 0.5); + totalWeight += c.weight; + earnedScore += points; + checkResults.push({ check: c.key, weight: c.weight, earned: points, status: v ? v.status : "error" }); + }); + + var healthScore = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0; + var duration = Date.now() - start; + + // Build relationships from neighbour data + var relNeighbours = neighbourData && neighbourData.data && neighbourData.data.neighbour_counts + ? neighbourData.data.neighbour_counts : {}; + var relList = neighbourData && neighbourData.data && neighbourData.data.neighbours + ? neighbourData.data.neighbours : []; + var relUpstreams = relList.filter(function(n) { return n.type === "left"; }) + .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) + .slice(0, 20) + .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); + var relDownstreams = relList.filter(function(n) { return n.type === "right"; }) + .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) + .slice(0, 20) + .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); + var relPeers = relList.filter(function(n) { return n.type === "uncertain"; }) + .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) + .slice(0, 30) + .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); + + const validateResult = { + meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString(), total_prefixes: allPrefixes.length, prefixes_sampled: samplePrefixes.length }, + asn: targetAsn, + name: net.name || (overviewData && overviewData.data ? overviewData.data.holder : "") || "Unknown", + health_score: healthScore, + score_breakdown: checkResults, + validations: validations, + relationships: { + counts: { upstreams: relNeighbours.left || relUpstreams.length, downstreams: relNeighbours.right || relDownstreams.length, peers: relNeighbours.unique || relPeers.length, uncertain: relNeighbours.uncertain || 0 }, + upstreams: relUpstreams, + downstreams: relDownstreams, + top_peers: relPeers, + source: "RIPE Stat asn-neighbours", + note: "left=upstream providers, right=downstream customers, uncertain=peers. Sorted by power score.", + }, + }; + resultCacheSet(validateResultCache, rawAsn, validateResult); + return res.end(JSON.stringify(validateResult, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "Validation failed", message: err.message })); + } + } + + // ============================================================ + // Main lookup endpoint: /api/lookup?asn=X + // ============================================================ + if (reqPath === "/api/lookup") { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); + } + const asn = rawAsn; + const cacheKey = "lookup:" + asn; + const cached = cacheGet(cacheKey); + if (cached) { + res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); + return res.end(JSON.stringify(cached)); + } + const start = Date.now(); + + try { + // Phase 0: Get PDB net first — check L2 cache, then API with retry + let pdbNet = pdbSourceCache.get("net", asn); + if (!pdbNet) { + pdbNet = await fetchPeeringDBWithRetry("/net?asn=" + asn); + if (pdbNet) pdbSourceCache.set("net", asn, pdbNet); + } + const net = pdbNet?.data?.[0] || {}; + const netId = net.id; + + // Phase 1: ALL calls in parallel — RIPE Stat (cached+throttled) + PDB IX/Fac (cached) + Atlas + bgp.he.net + const ixQuery = netId + ? "/netixlan?net_id=" + netId + "&limit=1000" + : "/netixlan?asn=" + asn + "&limit=1000"; + const ixCacheKey = netId ? String(netId) : "asn:" + asn; + + // Check PDB source cache for IX/Fac data + let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey); + let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null; + + // Per-source timing tracking — 9s hard cap per source to prevent long-tail blocking + const sourceTiming = {}; + function timedFetch(name, promise) { + const ts = Date.now(); + return Promise.race([ + Promise.resolve(promise), + new Promise(function(r) { setTimeout(function() { r(null); }, 9000); }), + ]) + .then(function(r) { sourceTiming[name] = Date.now() - ts; return r; }) + .catch(function() { sourceTiming[name] = null; return null; }); + } + + const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null; + + // RDAP: check module-level cache first, only hit RIR endpoints on cache miss + const rdapCached = rdapCacheGet(asn); + const rdapPromise = rdapCached !== undefined + ? Promise.resolve(rdapCached) + : Promise.race([ + ...["https://rdap.db.ripe.net/autnum/"+asn, "https://rdap.arin.net/registry/autnum/"+asn, + "https://rdap.apnic.net/autnum/"+asn, "https://rdap.lacnic.net/rdap/autnum/"+asn, + "https://rdap.afrinic.net/rdap/autnum/"+asn].map(url => + fetchJSON(url, { timeout: 4000 }) + .then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {})) + .catch(() => new Promise(() => {})) + ), + new Promise(resolve => setTimeout(() => resolve(null), 5000)), + ]).then(d => { rdapCacheSet(asn, d); return d; }); + + const promises = [ + timedFetch("RIPE Stat Prefixes", localDb.getRipeStatAnnouncedPrefixes(asn)), + timedFetch("RIPE Stat Neighbours", localDb.getRipeStatAsnNeighbours(asn)), + timedFetch("RIPE Stat Overview", localDb.getRipeStatAsOverview(asn)), + timedFetch("RIPE Stat RIR", Promise.resolve(null)), + timedFetch("RIPE Atlas", Promise.resolve(null)), + timedFetch("bgp.he.net", Promise.resolve(null)), + timedFetch("RIPE Stat Visibility", localDb.getRipeStatVisibility(asn)), + timedFetch("RIPE Stat PrefixSize", localDb.getRipeStatPrefixSizeDistribution(asn)), + timedFetch("PeeringDB IXLan", cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery)), + timedFetch("PeeringDB Facilities", cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))), + timedFetch("PeeringDB Contacts", pocQuery ? fetchPeeringDB(pocQuery).catch(() => null) : Promise.resolve(null)), + timedFetch("RDAP Registration", rdapPromise), + ]; + const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData, pocData, rdapData] = await Promise.all(promises); + + // Store PDB results in L2 source cache for future lookups + if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData); + if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData); + + const prefixes = prefixData?.data?.prefixes || []; + const neighbours = neighbourData?.data?.neighbours || []; + const overview = overviewData?.data || {}; + const rirEntries = rirData?.data?.located_resources || rirData?.data?.rir_stats || []; + + // Bug 6 fix: Atlas probe status uses status.name (object), not status_name (flat) + const atlasProbes = atlasProbeData?.results || []; + const atlasConnected = atlasProbes.filter(p => { + const sName = (p.status_name || (p.status && p.status.name) || "").toLowerCase(); + return sName === "connected"; + }); + const atlasAnchors = atlasProbes.filter(p => p.is_anchor === true); + + // RPKI: validate ALL prefixes using local Cloudflare RPKI data (all 5 RIRs, instant) + await ensureAspaCache(); + const allPrefixes = prefixes.map((p) => p.prefix); + const rpkiAllResults = await Promise.all(allPrefixes.map((pfx) => validateRPKIWithCache(asn, pfx))); + + const ixConnections = (ixlanData?.data || []) + .map((ix) => ({ + ix_name: ix.name || "", + ix_id: ix.ix_id, + speed_mbps: ix.speed || 0, + ipv4: ix.ipaddr4 || null, + ipv6: ix.ipaddr6 || null, + city: ix.city || "", + is_rs_peer: ix.is_rs_peer === true, + })) + .sort((a, b) => b.speed_mbps - a.speed_mbps); + + const facilitiesRaw = (facData?.data || []).map((f) => ({ + fac_id: f.fac_id, + name: f.name || "", + city: f.city || "", + country: f.country || "", + })); + + // Batch-fetch facility coordinates for map (max 50 facilities) + const facIds = facilitiesRaw.map(f => f.fac_id).filter(Boolean).slice(0, 50); + let facCoordMap = {}; + if (facIds.length > 0) { + try { + const chunks = []; + for (let i = 0; i < facIds.length; i += 25) chunks.push(facIds.slice(i, i + 25)); + const coordResults = await Promise.race([ + Promise.all(chunks.map(chunk => + fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null) + )), + new Promise(r => setTimeout(() => r([]), 5000)) + ]); + (coordResults || []).forEach(res => { + (res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; }); + }); + } catch(e) { /* graceful degradation */ } + } + const facilities = facilitiesRaw.map(f => ({ + ...f, + latitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lat : null, + longitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lon : null, + })); + + // Get IX locations for map via ixfac -> fac coordinates (max 20 IXs) + const uniqueIxIds = [...new Set(ixConnections.map(c => c.ix_id))].filter(Boolean).slice(0, 20); + let ixLocations = []; + if (uniqueIxIds.length > 0) { + try { + const ixFacData = await Promise.race([ + fetchPeeringDB("/ixfac?ix_id__in=" + uniqueIxIds.join(",")), + new Promise(r => setTimeout(() => r(null), 5000)) + ]); + const ixFacs = ixFacData?.data || []; + // Collect unique fac_ids we don't already have coords for + const extraFacIds = [...new Set(ixFacs.map(f => f.fac_id).filter(id => id && !facCoordMap[id]))].slice(0, 30); + if (extraFacIds.length > 0) { + const extraChunks = []; + for (let i = 0; i < extraFacIds.length; i += 25) extraChunks.push(extraFacIds.slice(i, i + 25)); + const extraRes = await Promise.race([ + Promise.all(extraChunks.map(chunk => + fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null) + )), + new Promise(r => setTimeout(() => r([]), 4000)) + ]); + (extraRes || []).forEach(res => { + (res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; }); + }); + } + // Build IX locations: pick first facility with coords per IX + const ixNameMap = {}; + ixConnections.forEach(c => { if (c.ix_id && c.ix_name) ixNameMap[c.ix_id] = c.ix_name; }); + const seenIx = {}; + ixFacs.forEach(f => { + if (seenIx[f.ix_id]) return; + const coords = facCoordMap[f.fac_id]; + if (coords) { + seenIx[f.ix_id] = true; + ixLocations.push({ ix_id: f.ix_id, name: ixNameMap[f.ix_id] || f.name || "", city: f.city || "", country: f.country || "", latitude: coords.lat, longitude: coords.lon }); + } + }); + } catch(e) { /* graceful degradation */ } + } + + const rpkiStatuses = rpkiAllResults; + const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length; + const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length; + const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid").length; + const rpkiTotal = rpkiStatuses.length; + const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0; + + const upstreams = neighbours + .filter((n) => n.type === "left") + .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 })) + .sort((a, b) => b.power - a.power); + const downstreams = neighbours + .filter((n) => n.type === "right") + .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 })) + .sort((a, b) => b.power - a.power); + const peers = neighbours + .filter((n) => n.type === "uncertain" || n.type === "peer") + .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 })) + .sort((a, b) => b.power - a.power); + + // Resolve empty AS names — all in parallel, with 3s timeout + const emptyNameNeighbours = [...upstreams, ...downstreams, ...peers].filter(n => !n.name); + if (emptyNameNeighbours.length > 0) { + const resolvePromise = Promise.all( + emptyNameNeighbours.map(n => + fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 }) + .then(r => { if (r?.data?.holder) n.name = r.data.holder; }) + .catch(() => {}) + ) + ); + await Promise.race([resolvePromise, new Promise(r => setTimeout(r, 3000))]); + } + + // ---- Threat Intelligence Enrichment for Neighbors ---- + // Enrich neighbor data with threat status from local threat_intel table + const threatEnrichNeighbors = async (neighbors) => { + const allNeighbors = [...neighbors]; + const threatMap = {}; + + // Batch threat intel lookups (cap at 50 to avoid overwhelming DB) + const toCheck = allNeighbors.slice(0, 50); + const threatPromises = toCheck.map(async (n) => { + try { + // Try to get threat intel by AS number or typical AS IP pattern + // For now, we'll mark neighbors without direct IP threat data + const asNum = String(n.asn); + const threat = await localDb.getThreatIntel(asNum); + if (threat) { + threatMap[n.asn] = { + threat_level: threat.threat_level, + confidence_score: threat.confidence_score, + source: threat.source, + cached_at: threat.cached_at, + }; + } + } catch (e) { + // Gracefully skip on error + console.error(`[Threat Lookup] Error checking ASN ${n.asn}:`, e.message); + } + }); + + // Run threat lookups with 4s timeout + await Promise.race([ + Promise.all(threatPromises), + new Promise(r => setTimeout(r, 4000)), + ]); + + return threatMap; + }; + + const threatMap = await threatEnrichNeighbors([...upstreams, ...downstreams, ...peers]); + + // Attach threat status to neighbor objects + const addThreatToNeighbor = (n) => ({ + ...n, + threat_level: threatMap[n.asn]?.threat_level || null, + threat_confidence: threatMap[n.asn]?.confidence_score || null, + threat_source: threatMap[n.asn]?.source || null, + }); + + upstreams = upstreams.map(addThreatToNeighbor); + downstreams = downstreams.map(addThreatToNeighbor); + peers = peers.map(addThreatToNeighbor); + + let rir = ""; + let country = ""; + // RIPE Stat rir-stats-country uses 'location' field (not 'country' or 'rir') + if (Array.isArray(rirEntries) && rirEntries.length > 0) { + country = rirEntries[0]?.location || rirEntries[0]?.country || ""; + rir = rirEntries[0]?.rir || ""; + } + if (!rir && rirData?.data) { + const rirField = rirData.data.rirs || []; + if (rirField.length > 0) rir = rirField[0]?.rir || ""; + } + // Derive RIR from rdapData.port43 (e.g. "whois.ripe.net" → "RIPE") + if (!rir && rdapData && rdapData.port43) { + const p43 = (rdapData.port43 || "").toLowerCase(); + if (p43.includes("ripe")) rir = "RIPE"; + else if (p43.includes("arin")) rir = "ARIN"; + else if (p43.includes("apnic")) rir = "APNIC"; + else if (p43.includes("lacnic")) rir = "LACNIC"; + else if (p43.includes("afrinic")) rir = "AFRINIC"; + } + // Also derive RIR from RDAP links (URL of the RDAP endpoint that responded) + if (!rir && rdapData && rdapData.links) { + const selfLink = (rdapData.links.find(l => l.rel === "self") || {}).href || ""; + if (selfLink.includes("ripe")) rir = "RIPE"; + else if (selfLink.includes("arin")) rir = "ARIN"; + else if (selfLink.includes("apnic")) rir = "APNIC"; + else if (selfLink.includes("lacnic")) rir = "LACNIC"; + else if (selfLink.includes("afrinic")) rir = "AFRINIC"; + } + // bgp.he.net country_code fallback (for unannounced/reserve ASNs) + if (!country && bgpHeData && bgpHeData.country_code) { + country = bgpHeData.country_code; + } + // Last resort: derive RIR from country code (common assignments) + if (!rir && country) { + const ARIN_CC = new Set(["US","CA","AI","AG","BS","BB","BZ","VG","KY","DM","DO","GD","GP","HT","JM","MQ","MS","PR","KN","LC","VC","TT","TC","VI","UM"]); + const APNIC_CC = new Set(["AU","NZ","JP","CN","KR","IN","HK","SG","TW","VN","TH","ID","MY","PK","BD","LK","NP","PH","AF","KH","LA","MM","MN","BT","BN","FJ","PG","WS","TO","VU","SB","KI","NR","TV","FM","MH","PW","CK","NU","TK","WF","PF","NC","GU","MP","AS","CC","CX","HM","NF"]); + const LACNIC_CC = new Set(["BR","AR","MX","CO","CL","PE","VE","EC","UY","BO","PY","CU","GT","HN","SV","NI","CR","PA","GY","SR","GF","AW","CW","SX","BQ","AN"]); + const AFRINIC_CC = new Set(["ZA","NG","KE","EG","GH","TZ","UG","MA","CI","SN","ZM","ZW","AO","MZ","CM","ET","SD","MG","DZ","TN","LY","RW","NA","BW","MW","ML","BF","NE","GN","TD","SO","LS","SZ","ER","DJ","GM","SL","LR","TG","BJ","GW","CF","CG","CD","GQ","ST","KM","MR","SC","MU","RE","CV","BU","SS","EH"]); + if (ARIN_CC.has(country)) rir = "ARIN"; + else if (APNIC_CC.has(country)) rir = "APNIC"; + else if (LACNIC_CC.has(country)) rir = "LACNIC"; + else if (AFRINIC_CC.has(country)) rir = "AFRINIC"; + else rir = "RIPE"; // Europe + rest = RIPE NCC + } + + const duration = Date.now() - start; + + // Compute routing visibility and prefix size distribution + const routingInfo = await (async function() { + const ipv4Prefixes = prefixes.filter(function(p) { return !p.prefix.includes(":"); }); + const ipv6Prefixes = prefixes.filter(function(p) { return p.prefix.includes(":"); }); + var ipv4VisAvg = 0, ipv6VisAvg = 0, totalRisPeersV4 = 0, totalRisPeersV6 = 0; + + // Visibility API returns per-RIS-collector data + // Each collector has ipv4_full_table_peer_count and ipv4_full_table_peers_not_seeing[] + // Bug 3 fix: visibility API may timeout for large ASNs — handle gracefully + var visibilities = (visibilityData && visibilityData.data && visibilityData.data.visibilities) || []; + var v4Seeing = 0, v4Total = 0, v6Seeing = 0, v6Total = 0; + var visTimedOut = !visibilityData || !visibilityData.data; + visibilities.forEach(function(v) { + if (!v || !v.probe) return; + var v4PeerCount = v.ipv4_full_table_peer_count || 0; + var v4NotSeeing = (v.ipv4_full_table_peers_not_seeing || []).length; + var v6PeerCount = v.ipv6_full_table_peer_count || 0; + var v6NotSeeing = (v.ipv6_full_table_peers_not_seeing || []).length; + v4Total += v4PeerCount; + v4Seeing += (v4PeerCount - v4NotSeeing); + v6Total += v6PeerCount; + v6Seeing += (v6PeerCount - v6NotSeeing); + }); + if (v4Total > 0) ipv4VisAvg = Math.round((v4Seeing / v4Total) * 1000) / 10; + if (v6Total > 0) ipv6VisAvg = Math.round((v6Seeing / v6Total) * 1000) / 10; + // If visibility API timed out but we have prefixes, try bgproutes.io fallback + if (visTimedOut && prefixes.length > 0) { + var fallbackPrefix = prefixes.find(function(p) { return !p.prefix.includes(":"); }); + if (!fallbackPrefix) fallbackPrefix = prefixes[0]; + if (fallbackPrefix) { + var bgprFallback = await fetchBgproutesVisibility(fallbackPrefix.prefix); + if (bgprFallback && bgprFallback.vps_seeing > 0) { + // Estimate visibility: % of VPs seeing the prefix (assume ~300 total RIS-equivalent VPs) + var estimatedTotal = Math.max(bgprFallback.vps_seeing, 300); + ipv4VisAvg = Math.round((bgprFallback.vps_seeing / estimatedTotal) * 1000) / 10; + ipv6VisAvg = -1; // bgproutes fallback is per-prefix, not per-AF aggregate + totalRisPeersV4 = bgprFallback.vps_seeing; + console.log("[Visibility] RIPE Stat timed out, used bgproutes.io fallback for " + fallbackPrefix.prefix + ": " + bgprFallback.vps_seeing + " VPs seeing it"); + } else { + ipv4VisAvg = -1; + ipv6VisAvg = -1; + console.log("[Visibility] RIPE Stat timed out and bgproutes.io fallback returned no data"); + } + } else { + ipv4VisAvg = -1; + ipv6VisAvg = -1; + } + } + totalRisPeersV4 = v4Total; + totalRisPeersV6 = v6Total; + + // Prefix size distribution: data.ipv4[] and data.ipv6[] arrays with {size, count} + var psdData = (prefixSizeData && prefixSizeData.data) || {}; + var psV4 = (psdData.ipv4 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; }); + var psV6 = (psdData.ipv6 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; }); + + return { + ipv4_prefixes: ipv4Prefixes.length, + ipv6_prefixes: ipv6Prefixes.length, + ipv4_visibility_avg: ipv4VisAvg, + ipv6_visibility_avg: ipv6VisAvg, + total_ris_peers_v4: totalRisPeersV4, + total_ris_peers_v6: totalRisPeersV6, + prefix_sizes_v4: psV4, + prefix_sizes_v6: psV6, + }; + })(); + + // ============================================================ + // Multi-source cross-checks (run in parallel, non-blocking) + // ============================================================ + let rpkiCrossCheck = { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: 100, disagreements: [], sample_size: 0 }; + let prefixCrossCheck = { ripe_stat: prefixes.length, bgp_he_net: null, agreement: null, note: "" }; + let neighbourCrossCheck = { ripe_stat_total: neighbours.length, bgp_he_net_total: null }; + + try { + // RPKI cross-check: sample up to 5 prefixes against RIPE Validator (with 8s total timeout) + const rpkiCrossPromise = crossCheckRpki(asn, allPrefixes, rpkiStatuses); + const rpkiCrossResult = await Promise.race([ + rpkiCrossPromise, + new Promise((resolve) => setTimeout(() => resolve(null), 8000)), + ]); + if (rpkiCrossResult) rpkiCrossCheck = rpkiCrossResult; + } catch (_e) { /* cross-check failed, keep defaults */ } + + // Prefix count cross-check: compare RIPE Stat vs bgp.he.net + if (bgpHeData) { + const heV4 = bgpHeData.prefixes_v4 || 0; + const heV6 = bgpHeData.prefixes_v6 || 0; + const heTotal = heV4 + heV6; + if (heTotal > 0) { + prefixCrossCheck.bgp_he_net = heTotal; + const ripeStat = prefixes.length; + if (ripeStat > 0 && heTotal > 0) { + const ratio = Math.min(ripeStat, heTotal) / Math.max(ripeStat, heTotal); + prefixCrossCheck.agreement = ratio >= 0.9; + const diff = Math.abs(ripeStat - heTotal); + prefixCrossCheck.note = diff === 0 + ? "Exact match" + : "Difference of " + diff + " prefixes (" + Math.round((1 - ratio) * 100) + "% divergence)"; + } + } else { + prefixCrossCheck.note = "bgp.he.net prefix count unavailable"; + } + + // Neighbour cross-check: compare RIPE Stat vs bgp.he.net peer_count + if (bgpHeData.peer_count != null) { + neighbourCrossCheck.bgp_he_net_total = bgpHeData.peer_count; + } + } else { + prefixCrossCheck.note = "bgp.he.net data unavailable"; + } + + // Compute overall data quality + const crossCheckScores = []; + // RPKI agreement + crossCheckScores.push(rpkiCrossCheck.agreement_pct); + // Prefix agreement: convert to percentage + if (prefixCrossCheck.bgp_he_net != null && prefixes.length > 0) { + const pfxRatio = Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net); + crossCheckScores.push(Math.round(pfxRatio * 100)); + } + // Neighbour agreement + if (neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0) { + const nbrRatio = Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total); + crossCheckScores.push(Math.round(nbrRatio * 100)); + } + const avgAgreement = crossCheckScores.length > 0 + ? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length) + : 100; + const overallConfidence = avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low"; + + const dataQuality = { + sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"], + cross_checks: { + rpki: { sources: 2, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, disagreements: rpkiCrossCheck.disagreements }, + prefixes: { sources: 2, agreement_pct: prefixCrossCheck.bgp_he_net != null ? Math.round((Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net || 1)) * 100) : null, ripe_stat: prefixCrossCheck.ripe_stat, bgp_he_net: prefixCrossCheck.bgp_he_net, note: prefixCrossCheck.note }, + neighbours: { sources: 2, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total }, + }, + overall_confidence: overallConfidence, + overall_agreement_pct: avgAgreement, + }; + + // === IX Location Geocode Fallback === + // Some IXPs have no facility coordinates in PeeringDB. + // Use ix_name city extraction + hard-coded IX→city map as fallback. + var ixIdsWithCoords = new Set(ixLocations.map(function(l) { return l.ix_id; })); + ixConnections.forEach(function(conn) { + if (ixIdsWithCoords.has(conn.ix_id)) return; + var name = conn.ix_name || ""; + if (name) { + var words = name.toLowerCase().replace(/[^a-z\s]/g, " ").split(/\s+/).filter(Boolean); + for (var w = 0; w < words.length; w++) { + if (CITY_COORDS[words[w]]) { + ixLocations.push({ ix_id: conn.ix_id, name: name, city: words[w].charAt(0).toUpperCase() + words[w].slice(1), country: "", latitude: CITY_COORDS[words[w]][0], longitude: CITY_COORDS[words[w]][1], source: "name_geocode" }); + ixIdsWithCoords.add(conn.ix_id); + return; + } + if (w < words.length - 1) { + var tw = words[w] + " " + words[w + 1]; + if (CITY_COORDS[tw]) { + ixLocations.push({ ix_id: conn.ix_id, name: name, city: tw, country: "", latitude: CITY_COORDS[tw][0], longitude: CITY_COORDS[tw][1], source: "name_geocode" }); + ixIdsWithCoords.add(conn.ix_id); + return; + } + } + } + } + }); + // Hard-coded IX ID → city for well-known IXPs whose names don't contain city + var IX_CITY_MAP = { 60: "zurich", 2601: "meppel", 24: "london", 35: "moscow", 15: "chicago", 11: "seattle", 387: "dublin", 171: "warsaw", 168: "bucharest", 71: "milan", 66: "vienna", 62: "prague", 1: "ashburn" }; + ixConnections.forEach(function(conn) { + if (ixIdsWithCoords.has(conn.ix_id)) return; + var city = IX_CITY_MAP[conn.ix_id]; + if (city && CITY_COORDS[city]) { + ixLocations.push({ ix_id: conn.ix_id, name: conn.ix_name || ("IX " + conn.ix_id), city: city.charAt(0).toUpperCase() + city.slice(1), country: "", latitude: CITY_COORDS[city][0], longitude: CITY_COORDS[city][1], source: "ix_city_map" }); + } + }); + + const result = { + meta: { + service: "PeerCortex", + version: "0.6.9", + query: "AS" + asn, + duration_ms: duration, + sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"], + timestamp: new Date().toISOString(), + rpki_prefixes_checked: rpkiTotal, + total_prefixes: prefixes.length, + }, + network: { + asn: parseInt(asn), + name: net.name || overview?.holder || (bgpHeData && bgpHeData.name_from_title) || "Unknown", + aka: net.aka || "", + org_name: (net.org && net.org.name) ? net.org.name : "", + website: net.website || "", + type: net.info_type || "", + policy: net.policy_general || "", + traffic: net.info_traffic || "", + ratio: net.info_ratio || "", + scope: net.info_scope || "", + notes: net.notes ? net.notes.substring(0, 500) : "", + peeringdb_id: netId || null, + rir: rir, + country: country, + city: net.city || "", + latitude: (net.latitude != null) ? net.latitude : null, + longitude: (net.longitude != null) ? net.longitude : null, + looking_glass: net.looking_glass || "", + route_server: net.route_server || "", + info_prefixes4: net.info_prefixes4 || 0, + info_prefixes6: net.info_prefixes6 || 0, + status: net.status || "", + peeringdb_created: net.created ? net.created.slice(0, 10) : "", + peeringdb_updated: net.updated ? net.updated.slice(0, 10) : "", + }, + prefixes: { + total: prefixes.length, + ipv4: prefixes.filter((p) => !p.prefix.includes(":")).length, + ipv6: prefixes.filter((p) => p.prefix.includes(":")).length, + list: prefixes.map((p) => p.prefix), + cross_check: prefixCrossCheck, + }, + rpki: { + coverage_percent: rpkiCoverage, + valid: rpkiValid, + invalid: rpkiInvalid, + not_found: rpkiNotFound, + checked: rpkiTotal, + details: rpkiStatuses, + cross_check: rpkiCrossCheck, + }, + neighbours: { + total: neighbours.length, + upstream_count: upstreams.length, + downstream_count: downstreams.length, + peer_count: peers.length, + upstreams: upstreams.slice(0, 20), + downstreams: downstreams.slice(0, 20), + peers: peers.slice(0, 20), + cross_check: neighbourCrossCheck, + }, + ix_presence: { + total_connections: ixConnections.length, + unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length, + connections: ixConnections, + }, + ix_locations: ixLocations, + facilities: { + total: facilities.length, + list: facilities, + }, + routing: routingInfo, + resilience_score: computeResilienceScore(upstreams, peers, ixConnections, prefixes), + route_leak: computeRouteLeakDetection(upstreams, downstreams, peers), + bgp_he_net: bgpHeData || null, + atlas: { + total_probes: atlasProbes.length, + connected: atlasConnected.length, + disconnected: atlasProbes.length - atlasConnected.length, + anchors: atlasAnchors.length, + probes: atlasProbes.slice(0, 100).map(p => ({ + id: p.id, + status: p.status_name || p.status || "Unknown", + is_anchor: p.is_anchor || false, + country: p.country_code || "", + prefix_v4: p.prefix_v4 || "", + prefix_v6: p.prefix_v6 || "", + description: p.description || "", + })), + }, + data_quality: dataQuality, + source_timing: sourceTiming, + contacts: (() => { + const pocs = (pocData && pocData.data) ? pocData.data : []; + return pocs.slice(0, 20).map(p => ({ + role: p.role || "", + name: p.name || "", + email: p.email || "", + url: p.url || "", + visible: p.visible || "", + })); + })(), + registration: (() => { + const events = (rdapData && rdapData.events) ? rdapData.events : []; + const created = (events.find(e => e.eventAction === "registration") || {}).eventDate || ""; + const lastChg = (events.find(e => e.eventAction === "last changed") || {}).eventDate || ""; + return { + created: created ? created.slice(0, 10) : "", + last_modified: lastChg ? lastChg.slice(0, 10) : "", + rir: rir || "", + handle: (rdapData && rdapData.handle) ? rdapData.handle : ("AS" + asn), + rdap_source: (rdapData && rdapData.port43) ? rdapData.port43 : "", + }; + })(), + _provenance: { + prefixes: { source: "RIPE Stat announced-prefixes", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net prefix count daily" }, + neighbours: { source: "RIPE Stat asn-neighbours", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net peer count daily" }, + rpki: { source: "Cloudflare RPKI + RIPE Validator", validation: "cross-validated", confidence: "high", note: "Two independent RPKI sources compared" }, + ix_presence: { source: "PeeringDB netixlan (local mirror)", validation: "cross-validated", confidence: "high", note: "PeeringDB mirror refreshed daily" }, + facilities: { source: "PeeringDB netfac (local mirror)", validation: "single-source", confidence: "medium" }, + bgp_he_net: { source: "bgp.he.net HTML scrape", validation: "single-source", confidence: "medium", note: "HTML scrape, no official API — may have parsing drift" }, + atlas: { source: "RIPE Atlas API", validation: "single-source", confidence: "medium", note: "Probe availability varies by region" }, + resilience_score: { source: "Computed from RIPE Stat + PeeringDB", validation: "computed", confidence: "high", note: "All inputs cross-validated daily" }, + route_leak: { source: "RIPE Stat asn-neighbours heuristic", validation: "heuristic", confidence: "medium", note: "Pattern-based, not real-time — false positives possible" }, + registration: { source: "RDAP (RIR registry)", validation: "single-source", confidence: "high" }, + contacts: { source: "PeeringDB POC API", validation: "single-source", confidence: "medium", note: "Subject to PeeringDB rate limiting" }, + }, + }; + + // Update duration to include cross-check time + result.meta.duration_ms = Date.now() - start; + + cacheSet(cacheKey, result, CACHE_TTL_LOOKUP); + res.end(JSON.stringify(result, null, 2)); + } catch (err) { + const duration = Date.now() - start; + res.writeHead(500); + res.end(JSON.stringify({ error: "Lookup failed", message: err.message, duration_ms: duration })); + } + return; + } + + // ============================================================ + // AS Relationships endpoint: /api/relationships?asn=X + // Returns upstream providers, downstream customers, and peers + // with resolved names. Based on RIPE Stat asn-neighbours. + // ============================================================ + if (reqPath === "/api/relationships") { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); + } + const cacheKey = "relationships:" + rawAsn; + const cached = cacheGet(cacheKey); + if (cached) { + res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); + return res.end(JSON.stringify(cached)); + } + const start = Date.now(); + try { + const neighbourData = await fetchRipeStatCached( + "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn + "&lod=1", + { timeout: 8000 } + ); + const neighbours = (neighbourData && neighbourData.data && neighbourData.data.neighbours) || []; + const counts = (neighbourData && neighbourData.data && neighbourData.data.neighbour_counts) || {}; + + const upstreams = neighbours.filter(n => n.type === "left").sort((a,b) => (b.power||0)-(a.power||0)); + const downstreams = neighbours.filter(n => n.type === "right").sort((a,b) => (b.power||0)-(a.power||0)); + const peers = neighbours.filter(n => n.type === "uncertain").sort((a,b) => (b.power||0)-(a.power||0)); + + // Resolve AS names for top entries (upstreams + downstreams all, top 20 peers) + const toResolve = [...upstreams, ...downstreams, ...peers.slice(0, 20)]; + const resolvedNames = {}; + await Promise.race([ + Promise.all(toResolve.map(n => + fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 }) + .then(r => { if (r && r.data && r.data.holder) resolvedNames[n.asn] = r.data.holder; }) + .catch(() => {}) + )), + new Promise(r => setTimeout(r, 5000)), + ]); + + // ---- Threat Intelligence Enrichment ---- + // Enrich neighbors with threat status from local threat_intel table + const threatMap = {}; + const allNeighborsForThreat = [...upstreams, ...downstreams, ...peers]; + const threatPromises = allNeighborsForThreat.slice(0, 100).map(async (n) => { + try { + const asNum = String(n.asn); + const threat = await localDb.getThreatIntel(asNum); + if (threat) { + threatMap[n.asn] = { + threat_level: threat.threat_level, + confidence_score: threat.confidence_score, + source: threat.source, + }; + } + } catch (e) { + console.error(`[Relationships Threat Lookup] Error checking ASN ${n.asn}:`, e.message); + } + }); + await Promise.race([ + Promise.all(threatPromises), + new Promise(r => setTimeout(r, 3000)), + ]); + + const fmt = n => ({ + asn: n.asn, + name: resolvedNames[n.asn] || "", + power: n.power || 0, + v4_peers: n.v4_peers || 0, + v6_peers: n.v6_peers || 0, + threat_level: threatMap[n.asn]?.threat_level || null, + threat_confidence: threatMap[n.asn]?.confidence_score || null, + threat_source: threatMap[n.asn]?.source || null, + }); + + const result = { + asn: parseInt(rawAsn), + query_time: new Date().toISOString(), + duration_ms: Date.now() - start, + counts: { + upstreams: counts.left || upstreams.length, + downstreams: counts.right || downstreams.length, + peers_total: counts.unique || peers.length, + uncertain: counts.uncertain || peers.length, + }, + upstreams: upstreams.map(fmt), + downstreams: downstreams.map(fmt), + peers: peers.slice(0, 50).map(fmt), + methodology: "RIPE Stat asn-neighbours API. left=upstream providers (carry your traffic), right=downstream customers (you carry their traffic), uncertain=lateral peers. Sorted by power score (number of prefixes seen via this relationship).", + source_url: "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, + }; + + cacheSet(cacheKey, result, 10 * 60 * 1000); // 10 min cache + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(result, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "Relationships lookup failed", message: err.message })); + } + } + + // ============================================================ + // Compare endpoint: /api/compare?asn1=X&asn2=Y + // ============================================================ + if (reqPath === "/api/compare") { + const asn1 = (url.searchParams.get("asn1") || "").replace(/[^0-9]/g, ""); + const asn2 = (url.searchParams.get("asn2") || "").replace(/[^0-9]/g, ""); + if (!asn1 || !asn2) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Need asn1 and asn2 parameters" })); + } + + const compareCacheKey = "compare:" + asn1 + ":" + asn2; + const compareCached = cacheGet(compareCacheKey); + if (compareCached) { + res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); + return res.end(JSON.stringify(compareCached)); + } + const start = Date.now(); + try { + // ALL calls in parallel — single batch + // Phase 1: Get PDB net objects + RIPE data + const [pdb1, pdb2, nb1Data, nb2Data, pfx1Data, pfx2Data] = await Promise.all([ + fetchPeeringDB("/net?asn=" + asn1), + fetchPeeringDB("/net?asn=" + asn2), + fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn1, { timeout: 8000 }), + fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2, { timeout: 8000 }), + fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1, { timeout: 8000 }), + fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2, { timeout: 8000 }), + ]); + + const net1 = pdb1?.data?.[0] || {}; + const net2 = pdb2?.data?.[0] || {}; + const netId1 = net1.id; + const netId2 = net2.id; + + // Phase 2: IX + Facility using net_id (Bug 1 fix: netfac requires net_id, not asn) + const ixFacPromises = []; + ixFacPromises.push(netId1 ? fetchPeeringDB("/netixlan?net_id=" + netId1) : Promise.resolve(null)); + ixFacPromises.push(netId2 ? fetchPeeringDB("/netixlan?net_id=" + netId2) : Promise.resolve(null)); + ixFacPromises.push(netId1 ? fetchPeeringDB("/netfac?net_id=" + netId1) : Promise.resolve(null)); + ixFacPromises.push(netId2 ? fetchPeeringDB("/netfac?net_id=" + netId2) : Promise.resolve(null)); + const [ix1Data, ix2Data, fac1Data, fac2Data] = await Promise.all(ixFacPromises); + + const ix1Set = new Set((ix1Data?.data || []).map((ix) => ix.ix_id)); + const ix2Set = new Set((ix2Data?.data || []).map((ix) => ix.ix_id)); + const ix1Names = {}; + (ix1Data?.data || []).forEach((ix) => (ix1Names[ix.ix_id] = ix.name)); + const ix2Names = {}; + (ix2Data?.data || []).forEach((ix) => (ix2Names[ix.ix_id] = ix.name)); + + const commonIX = [...ix1Set].filter((id) => ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || ix2Names[id] || "" })); + const only1IX = [...ix1Set].filter((id) => !ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || "" })); + const only2IX = [...ix2Set].filter((id) => !ix1Set.has(id)).map((id) => ({ ix_id: id, name: ix2Names[id] || "" })); + + const fac1Set = new Set((fac1Data?.data || []).map((f) => f.fac_id)); + const fac2Set = new Set((fac2Data?.data || []).map((f) => f.fac_id)); + const fac1Names = {}; + (fac1Data?.data || []).forEach((f) => (fac1Names[f.fac_id] = f.name)); + const fac2Names = {}; + (fac2Data?.data || []).forEach((f) => (fac2Names[f.fac_id] = f.name)); + + const commonFac = [...fac1Set].filter((id) => fac2Set.has(id)).map((id) => ({ fac_id: id, name: fac1Names[id] || fac2Names[id] || "" })); + + const nb1 = (nb1Data?.data?.neighbours || []).filter((n) => n.type === "left"); + const nb2 = (nb2Data?.data?.neighbours || []).filter((n) => n.type === "left"); + const up1Set = new Set(nb1.map((n) => n.asn)); + const up2Set = new Set(nb2.map((n) => n.asn)); + const nb1Map = {}; + nb1.forEach((n) => (nb1Map[n.asn] = n.as_name || "")); + const nb2Map = {}; + nb2.forEach((n) => (nb2Map[n.asn] = n.as_name || "")); + + const commonUpstreams = [...up1Set] + .filter((a) => up2Set.has(a)) + .map((a) => ({ asn: a, name: nb1Map[a] || nb2Map[a] || "" })); + + // ---- Threat Intelligence Enrichment for Common Upstreams ---- + const threatMap = {}; + const threatPromises = commonUpstreams.slice(0, 50).map(async (n) => { + try { + const asNum = String(n.asn); + const threat = await localDb.getThreatIntel(asNum); + if (threat) { + threatMap[n.asn] = { + threat_level: threat.threat_level, + confidence_score: threat.confidence_score, + source: threat.source, + }; + } + } catch (e) { + console.error(`[Compare Threat Lookup] Error checking ASN ${n.asn}:`, e.message); + } + }); + await Promise.race([ + Promise.all(threatPromises), + new Promise(r => setTimeout(r, 2000)), + ]); + + // Attach threat status to upstream objects + commonUpstreams.forEach((n) => { + if (threatMap[n.asn]) { + n.threat_level = threatMap[n.asn].threat_level; + n.threat_confidence = threatMap[n.asn].confidence_score; + n.threat_source = threatMap[n.asn].source; + } + }); + + // Resolve names + RPKI sample (max 3+3 prefixes) all in parallel with 5s timeout + const pfx1 = (pfx1Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix); + const pfx2 = (pfx2Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix); + const [, rpki1Results, rpki2Results] = await Promise.race([ + Promise.all([ + commonUpstreams.length > 0 ? Promise.all(commonUpstreams.map(n => + fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 }) + .then(r => { if (r?.data?.holder) n.name = r.data.holder; }) + .catch(() => {}) + )) : Promise.resolve([]), + Promise.all(pfx1.map((p) => fetchRPKIPerPrefix(asn1, p))), + Promise.all(pfx2.map((p) => fetchRPKIPerPrefix(asn2, p))), + ]), + new Promise(r => setTimeout(() => r([[], [], []]), 5000)), + ]); + + const rpki1Valid = rpki1Results.filter((r) => r.status === "valid").length; + const rpki2Valid = rpki2Results.filter((r) => r.status === "valid").length; + const rpki1Pct = rpki1Results.length > 0 ? Math.round((rpki1Valid / rpki1Results.length) * 100) : 0; + const rpki2Pct = rpki2Results.length > 0 ? Math.round((rpki2Valid / rpki2Results.length) * 100) : 0; + + const duration = Date.now() - start; + const compareResult = { + meta: { duration_ms: duration, timestamp: new Date().toISOString() }, + asn1: { + asn: parseInt(asn1), + name: net1.name || "Unknown", + ix_count: ix1Set.size, + fac_count: fac1Set.size, + upstream_count: up1Set.size, + rpki_coverage: rpki1Pct, + }, + asn2: { + asn: parseInt(asn2), + name: net2.name || "Unknown", + ix_count: ix2Set.size, + fac_count: fac2Set.size, + upstream_count: up2Set.size, + rpki_coverage: rpki2Pct, + }, + common_ixps: commonIX, + only_asn1_ixps: only1IX, + only_asn2_ixps: only2IX, + common_facilities: commonFac, + common_upstreams: commonUpstreams, + rpki_comparison: { + asn1_coverage: rpki1Pct, + asn2_coverage: rpki2Pct, + asn1_checked: rpki1Results.length, + asn2_checked: rpki2Results.length, + better: rpki1Pct > rpki2Pct ? "AS" + asn1 : rpki2Pct > rpki1Pct ? "AS" + asn2 : "equal", + }, + }; + cacheSet(compareCacheKey, compareResult, CACHE_TTL_DEFAULT); + res.end(JSON.stringify(compareResult, null, 2)); + } catch (err) { + res.writeHead(500); + res.end(JSON.stringify({ error: "Compare failed", message: err.message })); + } + return; + } + + + // ============================================================ + // Quick-IX endpoint: /api/quick-ix?asn=X + // Lightweight: only IX connections from PeeringDB, 1h cached + // Used by Peering Recommendations to avoid 20x full lookups + // ============================================================ + if (reqPath === "/api/quick-ix") { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing asn parameter" })); + } + const cached = quickIxCacheGet(rawAsn); + if (cached !== undefined) { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(cached)); + } + try { + const [pdbNetData, pdbIxlanData] = await Promise.all([ + fetchJSON("https://www.peeringdb.com/api/net?asn=" + rawAsn + "&depth=0", { timeout: 5000 }).catch(() => null), + fetchJSON("https://www.peeringdb.com/api/netixlan?asn=" + rawAsn + "&limit=100", { timeout: 6000 }).catch(() => null), + ]); + const netName = pdbNetData?.data?.[0]?.name || ""; + const ixConnections = []; + if (pdbIxlanData && pdbIxlanData.data) { + pdbIxlanData.data.forEach((row) => { + ixConnections.push({ ix_id: row.ixlan_id, name: row.name || "", speed: row.speed || 0 }); + }); + } + // Fall back to RIPE Stat if PeeringDB returns nothing + if (ixConnections.length === 0) { + const rsStat = await fetchRipeStatCached("https://stat.ripe.net/data/ixs/data.json?resource=AS" + rawAsn, { timeout: 5000 }).catch(() => null); + const ixs = rsStat?.data?.ixs || []; + ixs.forEach((ix) => ixConnections.push({ ix_id: ix.ixp_id || 0, name: ix.name || "", speed: 0 })); + } + const result = { asn: parseInt(rawAsn), name: netName, ix_connections: ixConnections }; + quickIxCacheSet(rawAsn, result); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(result)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "quick-ix lookup failed", message: err.message })); + } + } + + // ============================================================ + // Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000 + // ============================================================ + if (reqPath === "/api/peers/find") { + const ixName = url.searchParams.get("ix") || ""; + const policy = url.searchParams.get("policy") || ""; + const minSpeed = parseInt(url.searchParams.get("min_speed") || "0"); + const netType = url.searchParams.get("type") || ""; + + if (!ixName) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing ix parameter (IX name)" })); + } + + const start = Date.now(); + try { + // Search for IX by name + const ixSearch = await fetchPeeringDB("/ix?name__contains=" + encodeURIComponent(ixName)); + const ixResults = ixSearch?.data || []; + if (ixResults.length === 0) { + return res.end(JSON.stringify({ error: "No IX found matching: " + ixName, matches: [] })); + } + + // Use first matching IX + const ix = ixResults[0]; + const ixId = ix.id; + + // Get ixlan for this IX + const ixlanData = await fetchPeeringDB("/ixlan?ix_id=" + ixId); + const ixlans = ixlanData?.data || []; + if (ixlans.length === 0) { + return res.end(JSON.stringify({ ix: { id: ixId, name: ix.name }, matches: [] })); + } + + const ixlanId = ixlans[0].id; + + // Get all networks at this IX + const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId); + const netixlans = netixlanData?.data || []; + + // Get unique net_ids + const netIds = [...new Set(netixlans.map(n => n.net_id))]; + + // Fetch network details in batches + const networks = []; + const batchSize = 20; + for (let i = 0; i < Math.min(netIds.length, 200); i += batchSize) { + const batch = netIds.slice(i, i + batchSize); + const batchResults = await Promise.all( + batch.map(nid => fetchPeeringDB("/net/" + nid)) + ); + batchResults.forEach(r => { + if (r?.data?.[0]) networks.push(r.data[0]); + }); + } + + // Filter and rank + let filtered = networks.map(net => { + const nix = netixlans.filter(n => n.net_id === net.id); + const maxSpeed = Math.max(...nix.map(n => n.speed || 0)); + return { + asn: net.asn, + name: net.name || "", + policy: net.policy_general || "", + type: net.info_type || "", + speed_mbps: maxSpeed, + speed_gbps: maxSpeed >= 1000 ? (maxSpeed / 1000) + " Gbps" : maxSpeed + " Mbps", + traffic: net.info_traffic || "", + website: net.website || "", + peeringdb_id: net.id, + ipv4: nix[0]?.ipaddr4 || null, + ipv6: nix[0]?.ipaddr6 || null, + }; + }); + + // Apply filters + if (policy) { + filtered = filtered.filter(n => n.policy.toLowerCase().includes(policy.toLowerCase())); + } + if (minSpeed > 0) { + filtered = filtered.filter(n => n.speed_mbps >= minSpeed); + } + if (netType) { + filtered = filtered.filter(n => n.type.toLowerCase().includes(netType.toLowerCase())); + } + + // Sort by speed desc + filtered.sort((a, b) => b.speed_mbps - a.speed_mbps); + + // Also find common IXPs for each match (check if they share other IXPs) + const duration = Date.now() - start; + return res.end(JSON.stringify({ + meta: { duration_ms: duration, timestamp: new Date().toISOString() }, + ix: { id: ixId, name: ix.name, ixlan_id: ixlanId }, + total_members: netixlans.length, + filtered_count: filtered.length, + matches: filtered.slice(0, 50), + }, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "Peer matching failed", message: err.message })); + } + } + + // ============================================================ + // Prefix Detail endpoint: /api/prefix/detail?prefix=X + // ============================================================ + if (reqPath === "/api/prefix/detail") { + const prefix = url.searchParams.get("prefix") || ""; + if (!prefix) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing prefix parameter" })); + } + const start = Date.now(); + try { + const [routingStatus, visibility] = await Promise.all([ + fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=" + encodeURIComponent(prefix)), + fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=" + encodeURIComponent(prefix)), + ]); + + const origins = routingStatus?.data?.origins || []; + const firstSeen = routingStatus?.data?.first_seen?.time || null; + + // RPKI validation: use local PostgreSQL database (sub-10ms, zero external API calls) + let rpkiStatus = "unknown"; + let rpkiRoas = []; + const originAsn = origins.length > 0 ? origins[0].asn : null; + if (originAsn) { + try { + const localRpki = await validateRPKIWithCache(originAsn, prefix); + rpkiStatus = localRpki.status; + rpkiRoas = new Array(localRpki.validating_roas); // count only, no detail + } catch (e) { + console.error("[Prefix Detail] RPKI validation error:", e.message); + rpkiStatus = "unknown"; + rpkiRoas = []; + } + } + var visData = visibility?.data?.visibilities || []; + var risPeersSeeingIt = visData.length > 0 ? visData.filter(v => v.ris_peers_seeing > 0).length : 0; + var visibilitySource = "ripe_stat"; + // bgproutes.io fallback if RIPE Stat visibility returned no data + if (visData.length === 0 && BGPROUTES_API_KEY) { + var bgprVis = await fetchBgproutesVisibility(prefix); + if (bgprVis && bgprVis.vps_seeing > 0) { + risPeersSeeingIt = bgprVis.vps_seeing; + visData = []; // keep empty, use risPeersSeeingIt + visibilitySource = "bgproutes.io"; + } + } + + // Try to get IRR data + let irrStatus = "unknown"; + try { + const whoisData = await fetchRipeStatCached("https://stat.ripe.net/data/whois/data.json?resource=" + encodeURIComponent(prefix)); + const records = whoisData?.data?.records || []; + if (records.length > 0) irrStatus = "found"; + } catch(_e) {} + + const duration = Date.now() - start; + return res.end(JSON.stringify({ + meta: { duration_ms: duration, timestamp: new Date().toISOString() }, + prefix: prefix, + origins: origins.map(o => ({ asn: o.asn, prefix: o.prefix })), + rpki: { status: rpkiStatus, validating_roas: rpkiRoas.length }, + irr_status: irrStatus, + visibility: { ris_peers_seeing: risPeersSeeingIt, total_probes: visData.length || risPeersSeeingIt, source: visibilitySource }, + first_seen: firstSeen, + }, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "Prefix detail failed", message: err.message })); + } + } + + // ============================================================ + // IX Detail endpoint: /api/ix/detail?ix_id=X + // ============================================================ + if (reqPath === "/api/ix/detail") { + const ixId = (url.searchParams.get("ix_id") || "").replace(/[^0-9]/g, ""); + if (!ixId) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing ix_id parameter" })); + } + const start = Date.now(); + try { + const [ixData, ixlanData] = await Promise.all([ + fetchPeeringDB("/ix/" + ixId), + fetchPeeringDB("/ixlan?ix_id=" + ixId), + ]); + + const ix = ixData?.data?.[0] || {}; + const ixlans = ixlanData?.data || []; + const ixlanId = ixlans.length > 0 ? ixlans[0].id : null; + + let members = []; + if (ixlanId) { + const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId); + members = (netixlanData?.data || []).map(m => ({ + asn: m.asn, + name: m.name || "", + speed_mbps: m.speed || 0, + speed_display: (m.speed || 0) >= 1000 ? ((m.speed || 0) / 1000) + " Gbps" : (m.speed || 0) + " Mbps", + ipv4: m.ipaddr4 || null, + ipv6: m.ipaddr6 || null, + })); + } + + // Sort by speed desc for top members + const sorted = members.slice().sort((a, b) => b.speed_mbps - a.speed_mbps); + + const duration = Date.now() - start; + return res.end(JSON.stringify({ + meta: { duration_ms: duration, timestamp: new Date().toISOString() }, + ix: { + id: parseInt(ixId), + name: ix.name || "", + city: ix.city || "", + country: ix.country || "", + website: ix.website || "", + peeringdb_url: "https://www.peeringdb.com/ix/" + ixId, + }, + total_members: members.length, + top_members_by_speed: sorted.slice(0, 20), + all_members: sorted, + }, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "IX detail failed", message: err.message })); + } + } + + + // ============================================================ + // Feature 25: Topology endpoint + // ============================================================ + if (reqPath === "/api/topology") { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + const depth = parseInt(url.searchParams.get("depth") || "2") || 2; + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); + } + const start = Date.now(); + try { + const topology = await fetchTopology(parseInt(rawAsn), depth); + + // ---- Threat Intelligence Enrichment for Topology Nodes ---- + const threatMap = {}; + const threatPromises = topology.nodes.slice(0, 100).map(async (node) => { + try { + const asNum = String(node.asn); + const threat = await localDb.getThreatIntel(asNum); + if (threat) { + threatMap[node.asn] = { + threat_level: threat.threat_level, + confidence_score: threat.confidence_score, + source: threat.source, + }; + } + } catch (e) { + console.error(`[Topology Threat Lookup] Error checking ASN ${node.asn}:`, e.message); + } + }); + await Promise.race([ + Promise.all(threatPromises), + new Promise(r => setTimeout(r, 3000)), + ]); + + // Attach threat status to node objects + topology.nodes.forEach((node) => { + if (threatMap[node.asn]) { + node.threat_level = threatMap[node.asn].threat_level; + node.threat_confidence = threatMap[node.asn].confidence_score; + node.threat_source = threatMap[node.asn].source; + } + }); + + topology.meta = { + query: "AS" + rawAsn, depth: depth, duration_ms: Date.now() - start, + timestamp: new Date().toISOString(), node_count: topology.nodes.length, edge_count: topology.edges.length, + }; + return res.end(JSON.stringify(topology, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "Topology query failed", message: err.message })); + } + } + + // ============================================================ + // Feature 27: WHOIS endpoint + // ============================================================ + if (reqPath === "/api/whois") { + const resource = url.searchParams.get("resource") || ""; + if (!resource) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing resource parameter (ASN, prefix, or domain)" })); + } + const start = Date.now(); + try { + const whoisResult = await fetchWhois(resource); + if (!whoisResult || typeof whoisResult !== "object") { + res.writeHead(503); + return res.end(JSON.stringify({ error: "WHOIS data temporarily unavailable" })); + } + whoisResult.meta = { duration_ms: Date.now() - start, timestamp: new Date().toISOString() }; + return res.end(JSON.stringify(whoisResult, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "WHOIS lookup failed", message: err.message })); + } + } + + // Feature 28b: Company enrichment via Wikipedia + website meta scraping + if (reqPath === "/api/enrich") { + res.setHeader("Content-Type", "application/json"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Cache-Control", "no-store"); + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + const companyName = (url.searchParams.get("name") || "").trim(); + const website = (url.searchParams.get("website") || "").trim(); + const UA_SCRAPE = "Mozilla/5.0 (compatible; PeerCortex/1.0; +https://peercortex.org)"; + + let description = null; + let wikiUrl = null; + + try { + // 1. Direct Wikipedia lookup by company name + if (companyName) { + const nameLower = companyName.toLowerCase(); + const isRelevant = (title, extract) => { + const titleLower = (title || "").toLowerCase(); + const extractLower = (extract || "").toLowerCase(); + const nameWords = nameLower.replace(/\s+(gmbh|ag|ltd|inc|llc|bv|sa|sas|oy|ab)\s*$/i, "").split(/\s+/).filter(w => w.length > 3); + const titleMatch = nameWords.some(w => titleLower.includes(w)); + const netTerms = ["internet", "network", "isp", "hosting", "provider", "transit", "data center", "datacenter", "telecommunications", "telecom", "bandwidth", "peering", "routing", "autonomous system", "colocation", "colo", "fiber", "optical", "transceiver"]; + const hasNetContext = netTerms.some(t => extractLower.includes(t)); + return titleMatch && (hasNetContext || titleMatch); + }; + + // Direct title lookup — try full name first, then first word as fallback + const cleanName = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC|BV|SA|SAS|Oy|AB)$/i, "").trim(); + const firstName = cleanName.split(/\s+/)[0]; + const namesToTry = cleanName === firstName ? [cleanName] : [cleanName, firstName]; + for (const tryName of namesToTry) { + const wikiDirect = await fetchJSON( + "https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(tryName), + { timeout: 5000 } + ); + if (wikiDirect && wikiDirect.type === "disambiguation") continue; // skip disambiguation pages + if (wikiDirect && wikiDirect.extract && wikiDirect.extract.length > 30 && isRelevant(wikiDirect.title, wikiDirect.extract)) { + description = wikiDirect.extract.replace(/\s+/g, " ").trim().slice(0, 300); + wikiUrl = wikiDirect.content_urls && wikiDirect.content_urls.desktop && wikiDirect.content_urls.desktop.page; + break; + } + } + + // 2. Wikipedia search if direct lookup didn't match + if (!description) { + const searchQuery = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC)$/i, "") + " internet service provider"; + const searchData = await fetchJSON( + "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=" + encodeURIComponent(searchQuery) + "&limit=3", + { timeout: 5000 } + ); + if (searchData && Array.isArray(searchData) && searchData[1] && searchData[1].length > 0) { + const topTitle = searchData[1][0]; + const wikiSearch = await fetchJSON( + "https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(topTitle), + { timeout: 5000 } + ); + if (wikiSearch && wikiSearch.extract && wikiSearch.extract.length > 30) { + if (isRelevant(wikiSearch.title, wikiSearch.extract)) { + description = wikiSearch.extract.replace(/\s+/g, " ").trim().slice(0, 300); + wikiUrl = wikiSearch.content_urls && wikiSearch.content_urls.desktop && wikiSearch.content_urls.desktop.page; + } + } + } + } + } + + // 3. Fallback: scrape website meta description (follows up to 3 redirects) + function fetchPage(pageUrl, hops) { + if (hops <= 0) return Promise.resolve(null); + return new Promise((resolve) => { + const mod = pageUrl.startsWith("https") ? https : http; + const req = mod.get(pageUrl, { headers: { "User-Agent": UA_SCRAPE }, timeout: 6000 }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); // drain to free socket + const next = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, pageUrl).href; + return resolve(fetchPage(next, hops - 1)); + } + let data = ""; + res.on("data", (c) => { data += c; if (data.length > 40000) { req.destroy(); resolve(data); } }); + res.on("end", () => resolve(data)); + }); + req.on("error", () => resolve(null)); + req.on("timeout", () => { req.destroy(); resolve(null); }); + }); + } + if (!description && website) { + let wsUrl = website; + if (!wsUrl.startsWith("http")) wsUrl = "https://" + wsUrl; + const aboutUrl = wsUrl.replace(/\/$/, "") + "/about"; + const tryUrls = [aboutUrl, wsUrl]; + for (const tryUrl of tryUrls) { + const resp = await fetchPage(tryUrl, 3); + if (!resp) continue; + const metaPatterns = [ + /<meta[^>]+name=["']description["'][^>]+content=["']([^"']{20,500})["']/i, + /<meta[^>]+content=["']([^"']{20,500})["'][^>]+name=["']description["']/i, + /<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']{20,500})["']/i, + /<meta[^>]+content=["']([^"']{20,500})["'][^>]+property=["']og:description["']/i, + ]; + let matched = false; + for (const pat of metaPatterns) { + const m = resp.match(pat); + if (m && m[1]) { + description = m[1].replace(/ |✓|&/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 + + + +← peercortex.org +${html} +

PeerCortex · v0.5.0 · Open Source · MIT

+`; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.writeHead(200); + return res.end(page); + } catch(e) { + res.writeHead(500); return res.end('Changelog not available'); + } + } + + // ── BGP Community Decoder ──────────────────────────────────── + if (reqPath === '/api/communities') { + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = params.get('asn') || ''; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + try { + const url = `https://stat.ripe.net/data/bgp-state/data.json?resource=AS${asn.replace('AS','')}`; + const data = await fetchJSON(url, { timeout: 6000 }); + const rawComms = []; + if (data && data.data && data.data.bgp_state) { + for (const entry of data.data.bgp_state.slice(0, 50)) { + if (entry.community) rawComms.push(...entry.community); + } + } + const unique = [...new Set(rawComms.map(c => Array.isArray(c) ? c.join(':') : String(c)))]; + const decoded = unique.map(k => ({ raw: k, known: BGP_COMMUNITY_DB[k] || null })); + res.writeHead(200); + return res.end(JSON.stringify({ asn, communities: decoded, db_size: Object.keys(BGP_COMMUNITY_DB).length })); + } catch(e) { + res.writeHead(200); + return res.end(JSON.stringify({ asn, communities: [], error: e.message })); + } + } + + // ── IRR Audit ───────────────────────────────────────────────── + if (reqPath.startsWith('/api/irr-audit')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = (params.get('asn') || '').replace(/[^0-9]/g,''); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=1800'); + if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); } + try { + // Use NLNOG IRR Explorer — covers RIPE, ARIN, APNIC, RPKI, and all major IRR databases + const nlnogData = await fetchJSONWithRetry( + 'https://irrexplorer.nlnog.net/api/prefixes/asn/AS' + asn, + { timeout: 20000 } + ); + const prefixes = nlnogData && nlnogData.directOrigin || []; + var irrRoutes = []; + var irrDetails = []; + var goodCount = 0; + var warnCount = 0; + var errorCount = 0; + for (var i = 0; i < prefixes.length; i++) { + var pfx = prefixes[i]; + var hasIrr = pfx.irrRoutes && Object.keys(pfx.irrRoutes).length > 0; + var sources = hasIrr ? Object.keys(pfx.irrRoutes) : []; + var cat = pfx.categoryOverall || 'unknown'; + if (hasIrr) irrRoutes.push(pfx.prefix); + irrDetails.push({ + prefix: pfx.prefix, + irr_sources: sources, + rpki_status: pfx.rpkiRoutes && pfx.rpkiRoutes.length ? pfx.rpkiRoutes[0].rpkiStatus : 'not-found', + category: cat, + messages: (pfx.messages || []).map(function(m){ return m.text; }) + }); + if (cat === 'success') goodCount++; + else if (cat === 'warning') warnCount++; + else errorCount++; + } + var actualPfx = prefixes.map(function(p){ return p.prefix; }); + var inBgpNotIrr = actualPfx.filter(function(p){ return !irrRoutes.includes(p); }); + var score = actualPfx.length ? Math.round(irrRoutes.length / actualPfx.length * 100) : 0; + res.writeHead(200); + return res.end(JSON.stringify({ + asn: asn, + irr_routes: irrRoutes, + actual_prefixes: actualPfx, + in_irr_not_bgp: [], + in_bgp_not_irr: inBgpNotIrr, + score: score, + details: irrDetails, + summary: { good: goodCount, warning: warnCount, error: errorCount, total: prefixes.length }, + source: 'NLNOG IRR Explorer' + })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({error:e.message})); + } + } + + // ── AS-SET Expander ─────────────────────────────────────────── + if (reqPath.startsWith('/api/asset-expand')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const setName = params.get('set') || ''; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + if (!setName) { res.writeHead(400); return res.end(JSON.stringify({error:'set required (e.g. AS-FLEXOPTIX)'})); } + try { + async function expandSet(name, depth, visited) { + if (depth > 4 || visited.has(name)) return { asns: [], sets: [] }; + visited.add(name); + const url = `https://rest.db.ripe.net/search.json?query-string=${encodeURIComponent(name)}&type-filter=as-set&flags=no-referenced`; + const data = await fetchJSONWithRetry(url, { timeout: 10000 }); + const asns = [], sets = []; + if (data && data.objects && data.objects.object) { + for (const obj of data.objects.object) { + const attrs = obj.attributes && obj.attributes.attribute || []; + for (const a of attrs) { + if (a.name === 'members') { + for (const m of (a.value || '').split(/[,\s]+/).filter(Boolean)) { + if (/^AS\d+$/i.test(m)) asns.push(m.toUpperCase()); + else if (m.startsWith('AS-')) { sets.push(m); } + } + } + } + } + } + for (const sub of sets.slice(0,10)) { + const sub_r = await expandSet(sub, depth+1, visited); + asns.push(...sub_r.asns); + } + return { asns: [...new Set(asns)], sets }; + } + const visited = new Set(); + const result = await expandSet(setName.toUpperCase(), 0, visited); + result.asns.sort((a,b) => parseInt(a.slice(2)) - parseInt(b.slice(2))); + res.writeHead(200); + return res.end(JSON.stringify({ set: setName.toUpperCase(), count: result.asns.length, asns: result.asns, sub_sets: result.sets })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({error:e.message})); + } + } + + // ── Routing History (prefix table via RIPE Stat routing-history) ── + if (reqPath.startsWith('/api/rpki-history')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = (params.get('asn') || '').replace(/[^0-9]/g,''); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); } + try { + const url = 'https://stat.ripe.net/data/routing-history/data.json?resource=AS' + asn + '&max_rows=100'; + const data = await fetchJSON(url, { timeout: 6000 }); + const byOrigin = data && data.data && data.data.by_origin || []; + // Flatten: each origin entry has prefixes[] + var prefixes = []; + for (var i = 0; i < byOrigin.length; i++) { + var orig = byOrigin[i]; + if (orig.prefixes) { + for (var j = 0; j < orig.prefixes.length; j++) { + prefixes.push(orig.prefixes[j]); + } + } + } + res.writeHead(200); + return res.end(JSON.stringify({ asn: asn, prefixes: prefixes, source: 'RIPE Stat routing-history' })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({error:e.message})); + } + } + + // ── AS-PATH Visualizer (RIPE Stat looking-glass) ──────────────── + if (reqPath.startsWith('/api/aspath')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = (params.get('asn') || '').replace(/[^0-9]/g,''); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=300'); + if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); } + try { + // Use RIPE Stat announced-prefixes to get prefixes, then looking-glass for paths + var annUrl = 'https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS' + asn; + var annData = await fetchJSON(annUrl, { timeout: 5000 }); + var announced = annData && annData.data && annData.data.prefixes || []; + var prefix = announced.length > 0 ? announced[0].prefix : null; + if (!prefix) { + res.writeHead(200); + return res.end(JSON.stringify({ asn: asn, paths: [], source: 'RIPE Stat' })); + } + // Get looking-glass data for the first announced prefix + var lgUrl = 'https://stat.ripe.net/data/looking-glass/data.json?resource=' + encodeURIComponent(prefix); + var lgData = await fetchJSON(lgUrl, { timeout: 6000 }); + var rrcs = lgData && lgData.data && lgData.data.rrcs || []; + var paths = []; + var seen = new Set(); + for (var i = 0; i < rrcs.length && paths.length < 10; i++) { + var rrc = rrcs[i]; + var peers = rrc.peers || []; + for (var j = 0; j < peers.length && paths.length < 10; j++) { + var p = peers[j]; + var pathStr = (p.as_path || '').trim(); + if (pathStr && !seen.has(pathStr)) { + seen.add(pathStr); + paths.push({ + path: pathStr, + prefix: prefix, + rrc: rrc.rrc + ' (' + (rrc.location||'') + ')', + peer_asn: p.asn_origin || '' + }); + } + } + } + res.writeHead(200); + return res.end(JSON.stringify({ asn: asn, paths: paths, prefix: prefix, source: 'RIPE Stat looking-glass · ' + prefix })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({error:e.message})); + } + } + + // ── Looking Glass (RIPE Stat) ───────────────────────────────── + if (reqPath.startsWith('/api/looking-glass')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const resource = params.get('prefix') || params.get('asn') || ''; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + if (!resource) { res.writeHead(400); return res.end(JSON.stringify({error:'prefix or asn required'})); } + try { + const url = `https://stat.ripe.net/data/looking-glass/data.json?resource=${encodeURIComponent(resource)}`; + const data = await fetchJSON(url, { timeout: 6000 }); + const rrcs = data && data.data && data.data.rrcs || []; + const results = rrcs.slice(0, 15).map(rrc => ({ + rrc: rrc.rrc, + location: rrc.location, + peers: (rrc.peers || []).slice(0,5).map(p => ({ + asn: p.asn_origin, + as_path: p.as_path, + community: p.community, + next_hop: p.next_hop, + })) + })); + res.writeHead(200); + return res.end(JSON.stringify({ resource, rrcs: results, total_rrcs: rrcs.length })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({error:e.message})); + } + } + + // ── IXP Peering Matrix ──────────────────────────────────────── + if (reqPath.startsWith('/api/ix-matrix')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const ixId = (params.get('ix_id') || '').replace(/[^0-9]/g,''); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + if (!ixId) { res.writeHead(400); return res.end(JSON.stringify({error:'ix_id required'})); } + try { + const [netixData, ixData] = await Promise.all([ + fetchJSONWithRetry(`${PEERINGDB_API_URL}/netixlan?ix_id=${ixId}&depth=1&limit=200`, { timeout: 15000 }), + fetchJSONWithRetry(`${PEERINGDB_API_URL}/ix/${ixId}`, { timeout: 10000 }), + ]); + const ix = ixData && ixData.data && ixData.data[0]; + const members = (netixData && netixData.data || []).map(m => ({ + asn: m.asn, name: m.name, speed: m.speed, ipaddr4: m.ipaddr4, ipaddr6: m.ipaddr6, policy: m.policy_general + })); + members.sort((a,b) => (b.speed||0) - (a.speed||0)); + res.writeHead(200); + return res.end(JSON.stringify({ ix_id: ixId, ix_name: ix && ix.name, ix_city: ix && ix.city, members, member_count: members.length })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({error:e.message})); + } + } + + // ── 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})); + } + } + + // ── bio-rd RIB: prefix lookup ────────────────────────────────── + if (reqPath === '/api/rib/prefix') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); } + const params = new URL(req.url, 'http://localhost').searchParams; + const prefix = params.get('prefix') || ''; + const routerParam = params.get('router') || 'default'; + if (!prefix) { res.writeHead(400); return res.end(JSON.stringify({ error: 'prefix required' })); } + try { + const t0 = Date.now(); + const routers = await risClient.getRouters(); + const routerName = (routerParam === 'default' && routers.length > 0) ? routers[0] : routerParam; + const [routes, longer] = await Promise.all([ + risClient.lpm(routerName, prefix), + risClient.getLonger(routerName, prefix), + ]); + res.writeHead(200); + return res.end(JSON.stringify({ + prefix, + router: routerName, + routes: routes || [], + moreSpecifics: (longer || []).slice(0, 20), + source: 'bio-rd-local', + latencyMs: Date.now() - t0, + })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({ error: e.message })); + } + } + + // ── bio-rd RIB: list routers ─────────────────────────────────── + if (reqPath === '/api/rib/routers') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); } + try { + const routers = await risClient.getRouters(); + res.writeHead(200); + return res.end(JSON.stringify({ routers: routers || [], source: 'bio-rd-local' })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({ error: e.message })); + } + } + + // ── bio-rd RIB: dump ─────────────────────────────────────────── + if (reqPath === '/api/rib/dump') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); } + const params = new URL(req.url, 'http://localhost').searchParams; + const router = params.get('router') || ''; + const asnFilter = params.get('asn') ? parseInt(params.get('asn')) : undefined; + const limit = Math.min(parseInt(params.get('limit') || '100', 10), 1000); + if (!router) { res.writeHead(400); return res.end(JSON.stringify({ error: 'router required' })); } + try { + const t0 = Date.now(); + const allRoutes = await risClient.dumpRib(router, 'default', asnFilter); + const routes = (allRoutes || []).slice(0, limit); + res.writeHead(200); + return res.end(JSON.stringify({ + router, + routes, + total: (allRoutes || []).length, + source: 'bio-rd-local', + latencyMs: Date.now() - t0, + })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({ error: e.message })); + } + } + + // ── Prefix Changes ────────────────────────────────────────────── + if (reqPath === '/api/prefix-changes') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, ''); + if (!rawAsn) { res.writeHead(400); return res.end(JSON.stringify({ error: 'Missing ASN' })); } + const fromParam = url.searchParams.get('from'); + const toParam = url.searchParams.get('to'); + const hoursParam = Math.min(parseInt(url.searchParams.get('hours') || '1', 10), 168); + let starttime, endtime; + if (fromParam && toParam) { + starttime = new Date(fromParam).toISOString(); + endtime = new Date(toParam).toISOString(); + } else { + endtime = new Date().toISOString(); + starttime = new Date(Date.now() - hoursParam * 3600000).toISOString(); + } + try { + const updUrl = `https://stat.ripe.net/data/bgp-updates/data.json?resource=AS${rawAsn}&starttime=${encodeURIComponent(starttime)}&endtime=${encodeURIComponent(endtime)}&limit=1000`; + const raw = await fetchJSON(updUrl, { timeout: 8000 }); + const updates = (raw && raw.data && raw.data.updates && raw.data.updates.updates) || []; + + const announcements = [], withdrawals = [], originChanges = [], rpkiIssues = []; + const lastOriginByPrefix = {}, rpkiSeen = new Set(); + + for (const u of updates) { + const prefix = u.attrs && u.attrs.prefix; if (!prefix) continue; + const originRaw = u.attrs && u.attrs.origin; + const origin = originRaw ? parseInt(String(originRaw).replace('AS', ''), 10) : null; + const ts = u.timestamp || ''; + const peer = u.peer || ''; + + if (u.type === 'A') { + // Query local PostgreSQL for RPKI status (sub-10ms) + let rpkiStatus = 'unknown'; + try { + if (origin && prefix) { + const rpkiResult = await validateRPKIWithCache(origin, prefix); + rpkiStatus = rpkiResult.status; + } + } catch (e) { + console.error("[Prefix Changes] RPKI lookup error:", e.message); + } + announcements.push({ prefix, timestamp: ts, peer, origin, rpki_status: rpkiStatus }); + + if (lastOriginByPrefix[prefix] !== undefined && lastOriginByPrefix[prefix] !== origin) { + originChanges.push({ prefix, from_origin: lastOriginByPrefix[prefix], to_origin: origin, timestamp: ts, peer }); + } + lastOriginByPrefix[prefix] = origin; + + if (!rpkiSeen.has(prefix) && rpkiStatus !== 'valid') { + rpkiSeen.add(prefix); + rpkiIssues.push({ prefix, origin, rpki_status: rpkiStatus, timestamp: ts }); + } + } else if (u.type === 'W') { + withdrawals.push({ prefix, timestamp: ts, peer }); + } + } + + res.writeHead(200); + return res.end(JSON.stringify({ + asn: parseInt(rawAsn, 10), + time_range: { from: starttime, to: endtime }, + total_updates: updates.length, + summary: { announcements: announcements.length, withdrawals: withdrawals.length, origin_changes: originChanges.length, rpki_issues: rpkiIssues.length }, + announcements, + withdrawals, + origin_changes: originChanges, + rpki_issues: rpkiIssues, + })); + } catch(e) { + res.writeHead(500); + return res.end(JSON.stringify({ error: e.message })); + } + } + + // 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"); + }); +}); + +// ============================================================ +// bio-rd RIB WebSocket — live route streaming on /ws/rib +// ============================================================ +let WebSocketServer = null; +try { WebSocketServer = require('ws').Server; } catch(_e) {} + +if (WebSocketServer) { + const ribWss = new WebSocketServer({ server, path: '/ws/rib' }); + ribWss.on('connection', function(ws) { + let cancelStream = null; + + ws.on('message', function(raw) { + try { + const msg = JSON.parse(raw); + if (msg.type === 'rib-subscribe') { + if (cancelStream) { cancelStream(); cancelStream = null; } + if (!risClient) { + ws.send(JSON.stringify({ type: 'error', error: 'bio-rd RIS not configured' })); + return; + } + const router = msg.router || 'default'; + cancelStream = risClient.observeRib( + router, + 'default', + function(update) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(update)); }, + function(err) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: err.message })); } + ); + } + } catch(e) { + if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: e.message })); + } + }); + + ws.on('close', function() { + if (cancelStream) { cancelStream(); cancelStream = null; } + }); + }); + console.log('[bio-rd] RIB WebSocket server listening on /ws/rib'); +} else { + console.log('[bio-rd] WebSocket server skipped (ws package not installed)'); +} + +// ============================================================ +// Refresh timers — jittered to avoid thundering herd +// ============================================================ + +// RPKI feed (ASPA + ROA): every 4h ± 5min jitter +setInterval(() => { + fetchRpkiAspaFeed(); +}, 4 * 60 * 60 * 1000 + Math.floor(Math.random() * 10 * 60 * 1000) - 5 * 60 * 1000); + +// Atlas probe cache: every 12h ± 10min jitter +setInterval(function() { + fetchAllAtlasProbes(); +}, 12 * 60 * 60 * 1000 + Math.floor(Math.random() * 20 * 60 * 1000) - 10 * 60 * 1000); + +// Disk cache persistence: every 30 minutes +setInterval(function() { + pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json"); + saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json"); +}, 30 * 60 * 1000); + +// Save caches on graceful shutdown +process.on("SIGTERM", function() { + console.log("[SHUTDOWN] Saving caches to disk..."); + pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json"); + saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json"); + roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json"); + process.exit(0); +}); +process.on("SIGINT", function() { + console.log("[SHUTDOWN] Saving caches to disk..."); + pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json"); + saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json"); + roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json"); + process.exit(0); +}); diff --git a/src/api/server.ts b/src/api/server.ts index c90e179..513807f 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -3,6 +3,19 @@ import { getDatabase } from '../lib/db' import { hijackAlertsRoutes } from '../routes/hijack-alerts' import { pdfExportRoutes } from '../routes/pdf-export' import { aspaAdoptionRoutes } from '../routes/aspa-adoption' +import { bgpCommunitiesRoutes } from '../features/bgp-communities/routes' +import { irrAuditRoutes } from '../features/irr-audit/routes' +import { assetExpandRoutes } from '../features/asset-expand/routes' +import { rpkiHistoryRoutes } from '../features/rpki-history/routes' +import { aspathRoutes } from '../features/aspath/routes' +import { ixMatrixRoutes } from '../features/ix-matrix/routes' +import { lookingGlassRoutes } from '../features/looking-glass/routes' +import { prefixChangesRoutes } from '../features/prefix-changes/routes' +import { submarineCablesRoutes } from '../features/submarine-cables/routes' +import { globalInfraRoutes } from '../features/global-infra/routes' +import { changelogRoutes } from '../features/changelog/routes' +import { ribRoutes } from '../features/rib/routes' +import { hijackSubscribeRoutes } from '../features/hijack-subscribe/routes' export async function createApiServer(port: number = 3100) { const fastify = Fastify({ @@ -15,6 +28,19 @@ export async function createApiServer(port: number = 3100) { await fastify.register(hijackAlertsRoutes, { prefix: '/api' }) await fastify.register(pdfExportRoutes, { prefix: '/api' }) await fastify.register(aspaAdoptionRoutes, { prefix: '/api' }) + await fastify.register(bgpCommunitiesRoutes, { prefix: '/api' }) + await fastify.register(irrAuditRoutes, { prefix: '/api' }) + await fastify.register(assetExpandRoutes, { prefix: '/api' }) + await fastify.register(rpkiHistoryRoutes, { prefix: '/api' }) + await fastify.register(aspathRoutes, { prefix: '/api' }) + await fastify.register(ixMatrixRoutes, { prefix: '/api' }) + await fastify.register(lookingGlassRoutes, { prefix: '/api' }) + await fastify.register(prefixChangesRoutes, { prefix: '/api' }) + await fastify.register(submarineCablesRoutes, { prefix: '/api' }) + await fastify.register(globalInfraRoutes, { prefix: '/api' }) + await fastify.register(changelogRoutes) + await fastify.register(ribRoutes, { prefix: '/api' }) + await fastify.register(hijackSubscribeRoutes, { prefix: '/api' }) // Health check endpoint fastify.get('/health', async () => { @@ -32,7 +58,7 @@ export async function createApiServer(port: number = 3100) { return { name: 'PeerCortex API', version: '1.0.0', - features: ['hijack-alerts', 'pdf-export', 'aspa-adoption-tracker'], + features: ['hijack-alerts', 'pdf-export', 'aspa-adoption-tracker', 'bgp-communities', 'irr-audit', 'asset-expand', 'rpki-history', 'aspath', 'ix-matrix', 'looking-glass', 'prefix-changes', 'submarine-cables', 'global-infra', 'changelog', 'rib', 'hijack-subscribe'], docs: 'https://peercortex.org/docs', } }) diff --git a/src/backend/config.js b/src/backend/config.js new file mode 100644 index 0000000..23b9076 --- /dev/null +++ b/src/backend/config.js @@ -0,0 +1,28 @@ +const fs = require("fs"); + +function loadEnv() { + 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; + } + }); + console.log('[Config] Environment variables loaded'); + } catch (_e) { + console.warn("Warning: Could not read .env file at", envPath); + } +} + +// Automatically load environment variables when module is required +loadEnv(); + +module.exports = { + loadEnv +}; diff --git a/src/backend/services/smtp.js b/src/backend/services/smtp.js new file mode 100644 index 0000000..e734748 --- /dev/null +++ b/src/backend/services/smtp.js @@ -0,0 +1,78 @@ +const tls = require('tls'); +const net = require('net'); + +const SMTP_HOST = 'mail.fichtmueller.org'; +const SMTP_PORT = 587; +const MAIL_TO = 'peercortex@context-x.org'; +const MAIL_FROM = 'PeerCortex Feedback '; + +function sendFeedbackMail(entry) { + const SMTP_USER = process.env.SMTP_USER; + const SMTP_PASS = process.env.SMTP_PASS; + + return new Promise(function(resolve, reject) { + 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); + }); +} + +module.exports = { + sendFeedbackMail +}; diff --git a/src/features/aspath/routes.ts b/src/features/aspath/routes.ts new file mode 100644 index 0000000..7d1be48 --- /dev/null +++ b/src/features/aspath/routes.ts @@ -0,0 +1,79 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +interface AspathQuery { + asn?: string; +} + +async function fetchWithRetry(url: string, retries = 1, timeout = 10000): Promise { + for (let i = 0; i <= retries; i++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!response.ok) { + if (i === retries) throw new Error(`HTTP ${response.status}`); + continue; + } + return await response.json(); + } catch (e) { + clearTimeout(timeoutId); + if (i === retries) throw e; + await new Promise(r => setTimeout(r, 1500)); + } + } +} + +export async function aspathRoutes(fastify: FastifyInstance): Promise { + fastify.get<{ Querystring: AspathQuery }>( + '/aspath', + async (request: FastifyRequest<{ Querystring: AspathQuery }>, reply: FastifyReply) => { + let asnStr = request.query.asn || ''; + const asn = asnStr.replace(/[^0-9]/g, ''); + + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'public, max-age=300'); + + if (!asn) { + return reply.status(400).send({ error: 'asn required' }); + } + + try { + const pfxUrl = `https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}`; + const pfxData = await fetchWithRetry(pfxUrl, 1, 6000); + + let targetPrefix = ''; + if (pfxData && pfxData.data && pfxData.data.prefixes && pfxData.data.prefixes.length > 0) { + targetPrefix = pfxData.data.prefixes[0].prefix; + } + + if (!targetPrefix) { + return reply.status(200).send({ resource: `AS${asn}`, rrcs: [], error: 'No prefixes announced' }); + } + + const lgUrl = `https://stat.ripe.net/data/looking-glass/data.json?resource=${encodeURIComponent(targetPrefix)}`; + const lgData = await fetchWithRetry(lgUrl, 1, 10000); + + const rrcs = (lgData && lgData.data && lgData.data.rrcs) || []; + const results = rrcs.map((r: any) => ({ + rrc: r.rrc, + location: r.location, + peers: (r.peers || []).map((p: any) => ({ + asn: p.asn, + as_path: p.as_path, + community: p.community, + next_hop: p.next_hop, + })) + })); + + return reply.status(200).send({ + resource: targetPrefix, + rrcs: results, + total_rrcs: rrcs.length + }); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); +} diff --git a/src/features/asset-expand/routes.ts b/src/features/asset-expand/routes.ts new file mode 100644 index 0000000..a6f5838 --- /dev/null +++ b/src/features/asset-expand/routes.ts @@ -0,0 +1,93 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +interface AssetExpandQuery { + set?: string; +} + +async function fetchWithRetry(url: string, retries = 1, timeout = 10000): Promise { + for (let i = 0; i <= retries; i++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!response.ok) { + if (i === retries) throw new Error(`HTTP ${response.status}`); + continue; + } + return await response.json(); + } catch (e) { + clearTimeout(timeoutId); + if (i === retries) throw e; + await new Promise(r => setTimeout(r, 1500)); + } + } +} + +export async function assetExpandRoutes(fastify: FastifyInstance): Promise { + fastify.get<{ Querystring: AssetExpandQuery }>( + '/asset-expand', + async (request: FastifyRequest<{ Querystring: AssetExpandQuery }>, reply: FastifyReply) => { + const setName = request.query.set || ''; + + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'public, max-age=3600'); + + if (!setName) { + return reply.status(400).send({ error: 'set required (e.g. AS-FLEXOPTIX)' }); + } + + async function expandSet(name: string, depth: number, visited: Set): Promise<{ asns: string[], sets: string[] }> { + 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`; + let data; + try { + data = await fetchWithRetry(url); + } catch (e) { + // If RIPE DB fails on a specific set, gracefully continue with empty array for that branch + return { asns: [], sets: [] }; + } + + const asns: string[] = []; + const sets: string[] = []; + + 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)) { // Limit sub-sets to prevent overwhelming API + const sub_r = await expandSet(sub, depth + 1, visited); + asns.push(...sub_r.asns); + } + return { asns: [...new Set(asns)], sets }; + } + + try { + 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))); + + return reply.status(200).send({ + set: setName.toUpperCase(), + count: result.asns.length, + asns: result.asns, + sub_sets: result.sets + }); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); +} diff --git a/src/features/bgp-communities/bgp-community-db.ts b/src/features/bgp-communities/bgp-community-db.ts new file mode 100644 index 0000000..ec792d2 --- /dev/null +++ b/src/features/bgp-communities/bgp-community-db.ts @@ -0,0 +1,60 @@ +export interface BGPCommunity { + name: string; + desc: string; + type: 'rfc' | 'carrier' | 'ixp'; + asn?: number; +} + +export const BGP_COMMUNITY_DB: Record = { + '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 }, +}; + +export function decodeCommunities(communityList: any[]): Array<{ raw: string; known: BGPCommunity | null }> { + if (!Array.isArray(communityList)) return []; + return communityList.map(c => { + const key = Array.isArray(c) ? c.join(':') : String(c); + const known = BGP_COMMUNITY_DB[key] || null; + return { raw: key, known }; + }); +} diff --git a/src/features/bgp-communities/routes.ts b/src/features/bgp-communities/routes.ts new file mode 100644 index 0000000..50aa5fa --- /dev/null +++ b/src/features/bgp-communities/routes.ts @@ -0,0 +1,65 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { BGP_COMMUNITY_DB, decodeCommunities } from './bgp-community-db.js'; + +interface CommunityQuery { + asn?: string; +} + +export async function bgpCommunitiesRoutes(fastify: FastifyInstance): Promise { + fastify.get<{ Querystring: CommunityQuery }>( + '/communities', + async (request: FastifyRequest<{ Querystring: CommunityQuery }>, reply: FastifyReply) => { + let asnStr = request.query.asn || ''; + const asn = asnStr.replace(/[^0-9]/g, ''); + + if (!asn) { + return reply.status(400).send({ error: 'asn required' }); + } + + try { + const url = `https://stat.ripe.net/data/bgp-state/data.json?resource=AS${asn}`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 6000); + + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`RIPE Stat API returned ${response.status}`); + } + + const data = await response.json(); + + const rawComms: string[] = []; + 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: any) => Array.isArray(c) ? c.join(':') : String(c)))]; + const decoded = unique.map(k => ({ raw: k, known: BGP_COMMUNITY_DB[k] || null })); + + // Cache control + reply.header('Cache-Control', 'public, max-age=3600'); + reply.header('Access-Control-Allow-Origin', '*'); + + return reply.send({ + asn: `AS${asn}`, + communities: decoded, + db_size: Object.keys(BGP_COMMUNITY_DB).length + }); + } catch (error: any) { + reply.header('Access-Control-Allow-Origin', '*'); + return reply.status(200).send({ + asn: `AS${asn}`, + communities: [], + error: error.message + }); + } + } + ); +} diff --git a/src/features/changelog/routes.ts b/src/features/changelog/routes.ts new file mode 100644 index 0000000..1a2caeb --- /dev/null +++ b/src/features/changelog/routes.ts @@ -0,0 +1,51 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import fs from 'fs'; +import path from 'path'; + +const CHANGELOG_PATH = process.env.CHANGELOG_PATH || '/opt/peercortex-app/CHANGELOG.md'; + +export async function changelogRoutes(fastify: FastifyInstance): Promise { + fastify.get( + '/changelog-data', + { + // Override prefix — this is a top-level route, not under /api + config: { noPrefix: true } + }, + async (_request: FastifyRequest, reply: FastifyReply) => { + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'public, max-age=3600'); + + try { + const md = fs.readFileSync(CHANGELOG_PATH, 'utf8'); + const entries: any[] = []; + let current: any = null; + let currentSection: any = null; + + for (const line of md.split('\n')) { + 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); + + return reply.status(200).send(entries); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); +} diff --git a/src/features/global-infra/routes.ts b/src/features/global-infra/routes.ts new file mode 100644 index 0000000..89fc045 --- /dev/null +++ b/src/features/global-infra/routes.ts @@ -0,0 +1,76 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +// In-memory cache +let globalFacCache: { ts: number; data: any } | null = null; +const FAC_TTL = 24 * 60 * 60 * 1000; + +async function fetchWithRetry(url: string, retries = 1, timeout = 30000): Promise { + for (let i = 0; i <= retries; i++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!response.ok) { + if (i === retries) throw new Error(`HTTP ${response.status}`); + continue; + } + return await response.json(); + } catch (e) { + clearTimeout(timeoutId); + if (i === retries) throw e; + await new Promise(r => setTimeout(r, 1500)); + } + } +} + +export async function globalInfraRoutes(fastify: FastifyInstance): Promise { + fastify.get( + '/global-infra', + async (request: FastifyRequest, reply: FastifyReply) => { + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'public, max-age=86400'); + + if (globalFacCache && Date.now() - globalFacCache.ts < FAC_TTL) { + return reply.status(200).send(globalFacCache.data); + } + + try { + const PEERINGDB_API_URL = 'https://www.peeringdb.com/api'; + + const [facData, ixData] = await Promise.all([ + fetchWithRetry(`${PEERINGDB_API_URL}/fac?depth=1&limit=3000`, 1, 30000), + fetchWithRetry(`${PEERINGDB_API_URL}/ix?depth=1&limit=1000`, 1, 30000), + ]); + + const facs = (facData && facData.data || []) + .filter((f: any) => f.latitude && f.longitude) + .map((f: any) => ({ + 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: any) => ix.city && ix.country) + .map((ix: any) => ({ + id: ix.id, + name: ix.name, + city: ix.city, + country: ix.country, + website: ix.website + })); + + const result = { facs, ixps }; + globalFacCache = { ts: Date.now(), data: result }; + + return reply.status(200).send(result); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); +} diff --git a/src/features/hijack-subscribe/routes.ts b/src/features/hijack-subscribe/routes.ts new file mode 100644 index 0000000..7def83a --- /dev/null +++ b/src/features/hijack-subscribe/routes.ts @@ -0,0 +1,73 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import fs from 'fs'; +import path from 'path'; + +// Path to legacy JSON file-based subscriptions (kept for backward compat) +const HIJACK_SUBS_FILE = process.env.HIJACK_SUBS_FILE || '/opt/peercortex-app/data/hijack-subs.json'; + +interface HijackSubscribeBody { + asn?: string | number; + email?: string; +} + +function loadHijackSubs(): any[] { + try { + if (!fs.existsSync(HIJACK_SUBS_FILE)) return []; + return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE, 'utf8')); + } catch { + return []; + } +} + +export async function hijackSubscribeRoutes(fastify: FastifyInstance): Promise { + // OPTIONS preflight + fastify.options('/hijack-subscribe', async (_request: FastifyRequest, reply: FastifyReply) => { + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Access-Control-Allow-Methods', 'POST,OPTIONS'); + reply.header('Access-Control-Allow-Headers', 'Content-Type'); + return reply.status(204).send(); + }); + + // POST /api/hijack-subscribe + fastify.post<{ Body: HijackSubscribeBody }>( + '/hijack-subscribe', + async (request: FastifyRequest<{ Body: HijackSubscribeBody }>, reply: FastifyReply) => { + reply.header('Access-Control-Allow-Origin', '*'); + + try { + const body = request.body as HijackSubscribeBody; + const asnNum = String(body.asn || '').replace(/[^0-9]/g, ''); + + if (!asnNum) { + return reply.status(400).send({ error: 'asn required' }); + } + + const subs = loadHijackSubs(); + const exists = subs.find((s: any) => s.asn === asnNum); + + if (!exists) { + // Add subscription without live hijack check (avoids circular dependency) + // The scheduler will pick it up on next cycle + subs.push({ + asn: asnNum, + email: body.email || '', + prefixes: [], + subscribed: new Date().toISOString() + }); + fs.mkdirSync(path.dirname(HIJACK_SUBS_FILE), { recursive: true }); + fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2)); + } + + const entry = exists || subs[subs.length - 1]; + return reply.status(200).send({ + ok: true, + asn: asnNum, + monitoring: true, + prefix_count: entry.prefixes?.length ?? 0 + }); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); +} diff --git a/src/features/irr-audit/routes.ts b/src/features/irr-audit/routes.ts new file mode 100644 index 0000000..47bd9e0 --- /dev/null +++ b/src/features/irr-audit/routes.ts @@ -0,0 +1,91 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +interface IrrAuditQuery { + asn?: string; +} + +async function fetchWithRetry(url: string, retries = 1, timeout = 20000): Promise { + for (let i = 0; i <= retries; i++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!response.ok) { + if (i === retries) throw new Error(`HTTP ${response.status}`); + continue; + } + return await response.json(); + } catch (e) { + clearTimeout(timeoutId); + if (i === retries) throw e; + await new Promise(r => setTimeout(r, 1500)); + } + } +} + +export async function irrAuditRoutes(fastify: FastifyInstance): Promise { + fastify.get<{ Querystring: IrrAuditQuery }>( + '/irr-audit', + async (request: FastifyRequest<{ Querystring: IrrAuditQuery }>, reply: FastifyReply) => { + let asnStr = request.query.asn || ''; + const asn = asnStr.replace(/[^0-9]/g, ''); + + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'public, max-age=1800'); + + if (!asn) { + return reply.status(400).send({ error: 'asn required' }); + } + + try { + const nlnogData = await fetchWithRetry(`https://irrexplorer.nlnog.net/api/prefixes/asn/AS${asn}`); + const prefixes = (nlnogData && nlnogData.directOrigin) || []; + + let irrRoutes: string[] = []; + let irrDetails: any[] = []; + let goodCount = 0; + let warnCount = 0; + let errorCount = 0; + + for (const pfx of prefixes) { + const hasIrr = pfx.irrRoutes && Object.keys(pfx.irrRoutes).length > 0; + const sources = hasIrr ? Object.keys(pfx.irrRoutes) : []; + const 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((m: any) => m.text) + }); + + if (cat === 'success') goodCount++; + else if (cat === 'warning') warnCount++; + else errorCount++; + } + + const actualPfx = prefixes.map((p: any) => p.prefix); + const inBgpNotIrr = actualPfx.filter((p: string) => !irrRoutes.includes(p)); + const score = actualPfx.length ? Math.round((irrRoutes.length / actualPfx.length) * 100) : 0; + + return reply.status(200).send({ + 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: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); +} diff --git a/src/features/ix-matrix/routes.ts b/src/features/ix-matrix/routes.ts new file mode 100644 index 0000000..374392e --- /dev/null +++ b/src/features/ix-matrix/routes.ts @@ -0,0 +1,74 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +interface IxMatrixQuery { + ix_id?: string; +} + +async function fetchWithRetry(url: string, retries = 1, timeout = 10000): Promise { + for (let i = 0; i <= retries; i++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!response.ok) { + if (i === retries) throw new Error(`HTTP ${response.status}`); + continue; + } + return await response.json(); + } catch (e) { + clearTimeout(timeoutId); + if (i === retries) throw e; + await new Promise(r => setTimeout(r, 1500)); + } + } +} + +export async function ixMatrixRoutes(fastify: FastifyInstance): Promise { + fastify.get<{ Querystring: IxMatrixQuery }>( + '/ix-matrix', + async (request: FastifyRequest<{ Querystring: IxMatrixQuery }>, reply: FastifyReply) => { + let ixIdStr = request.query.ix_id || ''; + const ixId = ixIdStr.replace(/[^0-9]/g, ''); + + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'public, max-age=3600'); + + if (!ixId) { + return reply.status(400).send({ error: 'ix_id required' }); + } + + try { + const PEERINGDB_API_URL = 'https://www.peeringdb.com/api'; + + const [netixData, ixData] = await Promise.all([ + fetchWithRetry(`${PEERINGDB_API_URL}/netixlan?ix_id=${ixId}&depth=1&limit=200`, 1, 15000), + fetchWithRetry(`${PEERINGDB_API_URL}/ix/${ixId}`, 1, 10000), + ]); + + const ix = ixData && ixData.data && ixData.data[0]; + + const members = ((netixData && netixData.data) || []).map((m: any) => ({ + asn: m.asn, + name: m.name, + speed: m.speed, + ipaddr4: m.ipaddr4, + ipaddr6: m.ipaddr6, + policy: m.policy_general + })); + + members.sort((a: any, b: any) => (b.speed || 0) - (a.speed || 0)); + + return reply.status(200).send({ + ix_id: ixId, + ix_name: ix && ix.name, + ix_city: ix && ix.city, + members, + member_count: members.length + }); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); +} diff --git a/src/features/looking-glass/routes.ts b/src/features/looking-glass/routes.ts new file mode 100644 index 0000000..c5426a9 --- /dev/null +++ b/src/features/looking-glass/routes.ts @@ -0,0 +1,67 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +interface LookingGlassQuery { + prefix?: string; + asn?: string; +} + +async function fetchWithRetry(url: string, retries = 1, timeout = 10000): Promise { + for (let i = 0; i <= retries; i++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!response.ok) { + if (i === retries) throw new Error(`HTTP ${response.status}`); + continue; + } + return await response.json(); + } catch (e) { + clearTimeout(timeoutId); + if (i === retries) throw e; + await new Promise(r => setTimeout(r, 1500)); + } + } +} + +export async function lookingGlassRoutes(fastify: FastifyInstance): Promise { + fastify.get<{ Querystring: LookingGlassQuery }>( + '/looking-glass', + async (request: FastifyRequest<{ Querystring: LookingGlassQuery }>, reply: FastifyReply) => { + const resource = request.query.prefix || request.query.asn || ''; + + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'no-store'); + + if (!resource) { + return reply.status(400).send({ error: 'prefix or asn required' }); + } + + try { + const url = `https://stat.ripe.net/data/looking-glass/data.json?resource=${encodeURIComponent(resource)}`; + const data = await fetchWithRetry(url, 1, 6000); + + const rrcs = (data && data.data && data.data.rrcs) || []; + const results = rrcs.slice(0, 15).map((rrc: any) => ({ + rrc: rrc.rrc, + location: rrc.location, + peers: (rrc.peers || []).slice(0, 5).map((p: any) => ({ + asn: p.asn_origin, + as_path: p.as_path, + community: p.community, + next_hop: p.next_hop, + })) + })); + + return reply.status(200).send({ + resource, + rrcs: results, + total_rrcs: rrcs.length + }); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); +} diff --git a/src/features/prefix-changes/routes.ts b/src/features/prefix-changes/routes.ts new file mode 100644 index 0000000..76cb6eb --- /dev/null +++ b/src/features/prefix-changes/routes.ts @@ -0,0 +1,128 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { validateRpki } from '../../db/rpki-client'; + +interface PrefixChangesQuery { + asn?: string; + from?: string; + to?: string; + hours?: string; +} + +async function fetchWithRetry(url: string, retries = 1, timeout = 8000): Promise { + for (let i = 0; i <= retries; i++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!response.ok) { + if (i === retries) throw new Error(`HTTP ${response.status}`); + continue; + } + return await response.json(); + } catch (e) { + clearTimeout(timeoutId); + if (i === retries) throw e; + await new Promise(r => setTimeout(r, 1500)); + } + } +} + +export async function prefixChangesRoutes(fastify: FastifyInstance): Promise { + fastify.get<{ Querystring: PrefixChangesQuery }>( + '/prefix-changes', + async (request: FastifyRequest<{ Querystring: PrefixChangesQuery }>, reply: FastifyReply) => { + const rawAsn = (request.query.asn || '').replace(/[^0-9]/g, ''); + + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'no-store'); + + if (!rawAsn) { + return reply.status(400).send({ error: 'Missing ASN' }); + } + + const fromParam = request.query.from; + const toParam = request.query.to; + const hoursParam = Math.min(parseInt(request.query.hours || '1', 10), 168); + + let starttime: string; + let endtime: string; + + if (fromParam && toParam) { + starttime = new Date(fromParam).toISOString(); + endtime = new Date(toParam).toISOString(); + } else { + endtime = new Date().toISOString(); + starttime = new Date(Date.now() - hoursParam * 3600000).toISOString(); + } + + try { + const updUrl = `https://stat.ripe.net/data/bgp-updates/data.json?resource=AS${rawAsn}&starttime=${encodeURIComponent(starttime)}&endtime=${encodeURIComponent(endtime)}&limit=1000`; + const raw = await fetchWithRetry(updUrl, 1, 8000); + const updates = (raw && raw.data && raw.data.updates && raw.data.updates.updates) || []; + + const announcements: any[] = []; + const withdrawals: any[] = []; + const originChanges: any[] = []; + const rpkiIssues: any[] = []; + + const lastOriginByPrefix: Record = {}; + const rpkiSeen = new Set(); + + for (const u of updates) { + const prefix = u.attrs && u.attrs.prefix; + if (!prefix) continue; + + const originRaw = u.attrs && u.attrs.origin; + const origin = originRaw ? parseInt(String(originRaw).replace('AS', ''), 10) : null; + const ts = u.timestamp || ''; + const peer = u.peer || ''; + + if (u.type === 'A') { + let rpkiStatus = 'unknown'; + try { + if (origin && prefix) { + const rpkiResult = await validateRpki(prefix, origin); + rpkiStatus = rpkiResult.status; + } + } catch (e: any) { + console.error("[Prefix Changes] RPKI lookup error:", e.message); + } + + announcements.push({ prefix, timestamp: ts, peer, origin, rpki_status: rpkiStatus }); + + if (lastOriginByPrefix[prefix] !== undefined && lastOriginByPrefix[prefix] !== origin) { + originChanges.push({ prefix, from_origin: lastOriginByPrefix[prefix], to_origin: origin, timestamp: ts, peer }); + } + lastOriginByPrefix[prefix] = origin; + + if (!rpkiSeen.has(prefix) && rpkiStatus !== 'valid' && rpkiStatus !== 'unknown' && rpkiStatus !== 'not-found') { + rpkiSeen.add(prefix); + rpkiIssues.push({ prefix, origin, rpki_status: rpkiStatus, timestamp: ts }); + } + } else if (u.type === 'W') { + withdrawals.push({ prefix, timestamp: ts, peer }); + } + } + + return reply.status(200).send({ + asn: parseInt(rawAsn, 10), + time_range: { from: starttime, to: endtime }, + total_updates: updates.length, + summary: { + announcements: announcements.length, + withdrawals: withdrawals.length, + origin_changes: originChanges.length, + rpki_issues: rpkiIssues.length + }, + announcements, + withdrawals, + origin_changes: originChanges, + rpki_issues: rpkiIssues, + }); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); +} diff --git a/src/features/rib/routes.ts b/src/features/rib/routes.ts new file mode 100644 index 0000000..2a4a0bd --- /dev/null +++ b/src/features/rib/routes.ts @@ -0,0 +1,117 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +interface RibPrefixQuery { + prefix?: string; + router?: string; +} + +interface RibDumpQuery { + router?: string; + asn?: string; + limit?: string; +} + +// risClient is injected from outside; lazy import avoids circular deps at startup +let risClient: any = null; + +export function setRisClient(client: any): void { + risClient = client; +} + +export async function ribRoutes(fastify: FastifyInstance): Promise { + // GET /api/rib/prefix + fastify.get<{ Querystring: RibPrefixQuery }>( + '/rib/prefix', + async (request: FastifyRequest<{ Querystring: RibPrefixQuery }>, reply: FastifyReply) => { + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'no-store'); + + if (!risClient) { + return reply.status(503).send({ error: 'bio-rd RIS not configured' }); + } + + const prefix = request.query.prefix || ''; + const routerParam = request.query.router || 'default'; + + if (!prefix) { + return reply.status(400).send({ error: 'prefix required' }); + } + + try { + const t0 = Date.now(); + const routers = await risClient.getRouters(); + const routerName = (routerParam === 'default' && routers.length > 0) ? routers[0] : routerParam; + const [routes, longer] = await Promise.all([ + risClient.lpm(routerName, prefix), + risClient.getLonger(routerName, prefix), + ]); + return reply.status(200).send({ + prefix, + router: routerName, + routes: routes || [], + moreSpecifics: (longer || []).slice(0, 20), + source: 'bio-rd-local', + latencyMs: Date.now() - t0, + }); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); + + // GET /api/rib/routers + fastify.get( + '/rib/routers', + async (_request: FastifyRequest, reply: FastifyReply) => { + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'no-store'); + + if (!risClient) { + return reply.status(503).send({ error: 'bio-rd RIS not configured' }); + } + + try { + const routers = await risClient.getRouters(); + return reply.status(200).send({ routers: routers || [], source: 'bio-rd-local' }); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); + + // GET /api/rib/dump + fastify.get<{ Querystring: RibDumpQuery }>( + '/rib/dump', + async (request: FastifyRequest<{ Querystring: RibDumpQuery }>, reply: FastifyReply) => { + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'no-store'); + + if (!risClient) { + return reply.status(503).send({ error: 'bio-rd RIS not configured' }); + } + + const router = request.query.router || ''; + const asnFilter = request.query.asn ? parseInt(request.query.asn, 10) : undefined; + const limit = Math.min(parseInt(request.query.limit || '100', 10), 1000); + + if (!router) { + return reply.status(400).send({ error: 'router required' }); + } + + try { + const t0 = Date.now(); + const allRoutes = await risClient.dumpRib(router, 'default', asnFilter); + const routes = (allRoutes || []).slice(0, limit); + return reply.status(200).send({ + router, + routes, + total: (allRoutes || []).length, + source: 'bio-rd-local', + latencyMs: Date.now() - t0, + }); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); +} diff --git a/src/features/rpki-history/routes.ts b/src/features/rpki-history/routes.ts new file mode 100644 index 0000000..b3e34d9 --- /dev/null +++ b/src/features/rpki-history/routes.ts @@ -0,0 +1,55 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +interface RpkiHistoryQuery { + asn?: string; +} + +export async function rpkiHistoryRoutes(fastify: FastifyInstance): Promise { + fastify.get<{ Querystring: RpkiHistoryQuery }>( + '/rpki-history', + async (request: FastifyRequest<{ Querystring: RpkiHistoryQuery }>, reply: FastifyReply) => { + let asnStr = request.query.asn || ''; + const asn = asnStr.replace(/[^0-9]/g, ''); + + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'public, max-age=3600'); + + if (!asn) { + return reply.status(400).send({ error: 'asn required' }); + } + + try { + const url = `https://stat.ripe.net/data/routing-history/data.json?resource=AS${asn}&max_rows=100`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 6000); + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`RIPE Stat returned HTTP ${response.status}`); + } + const data = await response.json(); + + const byOrigin = (data && data.data && data.data.by_origin) || []; + const prefixes: any[] = []; + + for (const orig of byOrigin) { + if (orig.prefixes) { + for (const pfx of orig.prefixes) { + prefixes.push(pfx); + } + } + } + + return reply.status(200).send({ + asn: asn, + prefixes: prefixes, + source: 'RIPE Stat routing-history' + }); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); +} diff --git a/src/features/submarine-cables/routes.ts b/src/features/submarine-cables/routes.ts new file mode 100644 index 0000000..7d20fff --- /dev/null +++ b/src/features/submarine-cables/routes.ts @@ -0,0 +1,52 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +// In-memory cache +let subCableCache: { ts: number; data: any } | null = null; +const CABLE_TTL = 24 * 60 * 60 * 1000; + +async function fetchWithRetry(url: string, retries = 1, timeout = 30000): Promise { + for (let i = 0; i <= retries; i++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!response.ok) { + if (i === retries) throw new Error(`HTTP ${response.status}`); + continue; + } + return await response.json(); + } catch (e) { + clearTimeout(timeoutId); + if (i === retries) throw e; + await new Promise(r => setTimeout(r, 1500)); + } + } +} + +export async function submarineCablesRoutes(fastify: FastifyInstance): Promise { + fastify.get( + '/submarine-cables', + async (request: FastifyRequest, reply: FastifyReply) => { + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Cache-Control', 'public, max-age=86400'); + + if (subCableCache && Date.now() - subCableCache.ts < CABLE_TTL) { + return reply.status(200).send(subCableCache.data); + } + + try { + const cableData = await fetchWithRetry("https://www.submarinecablemap.com/api/v3/cable/cable-geo.json", 1, 30000); + + if (cableData) { + subCableCache = { ts: Date.now(), data: cableData }; + return reply.status(200).send(cableData); + } + + return reply.status(503).send({ error: "Submarine cable data unavailable" }); + } catch (e: any) { + return reply.status(500).send({ error: e.message }); + } + } + ); +}