diff --git a/server.js b/server.js index 3599053..5d8f940 100644 --- a/server.js +++ b/server.js @@ -7,50 +7,6 @@ const crypto = require("crypto"); const localDb = require('./local-db-client'); console.log('[PeerCortex] Local DB client initialized'); -// ── PDF Export: lazy puppeteer loader ──────────────────────────────────────── -let _puppeteerBrowser = null; -let _puppeteerLoading = false; -async function getPuppeteerBrowser() { - if (_puppeteerBrowser) return _puppeteerBrowser; - if (_puppeteerLoading) { - // Wait for in-flight launch - await new Promise(r => setTimeout(r, 2000)); - return _puppeteerBrowser; - } - _puppeteerLoading = true; - try { - const puppeteer = require("puppeteer"); - _puppeteerBrowser = await puppeteer.launch({ - headless: true, - args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"] - }); - console.log("[PDF] Puppeteer browser launched"); - _puppeteerBrowser.on("disconnected", () => { - console.log("[PDF] Browser disconnected — will relaunch on next request"); - _puppeteerBrowser = null; - _puppeteerLoading = false; - }); - } catch (e) { - console.error("[PDF] Puppeteer unavailable:", e.message); - _puppeteerBrowser = null; - } - _puppeteerLoading = false; - return _puppeteerBrowser; -} - -// PDF response cache: key = "asn:format" or "compare:asn1:asn2" → {buf, ts} -const pdfCache = new Map(); -const PDF_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - -function pdfCacheGet(key) { - const e = pdfCache.get(key); - if (e && (Date.now() - e.ts) < PDF_CACHE_TTL) return e.buf; - return null; -} -function pdfCacheSet(key, buf) { - if (pdfCache.size > 100) pdfCache.delete(pdfCache.keys().next().value); - pdfCache.set(key, { buf, ts: Date.now() }); -} // Load .env file require('./src/backend/config'); @@ -70,196 +26,21 @@ try { 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"; +const { + PEERINGDB_API_KEY, + PEERINGDB_API_URL, + getPdbLocal, + queryPeeringDBLocal, + pdbSemaphore, + fetchPeeringDB, + fetchPeeringDBWithRetry, +} = require("./server/services/peeringdb"); // ── SMTP / Email ────────────────────────────────────────────── const { sendFeedbackMail } = require('./src/backend/services/smtp'); // ── 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; -} +const { loadVisitors, trackVisitor } = require("./server/visitors"); // ═══════════════════════════════════════════════════════════════ // PEERCORTEX v0.6.1 — New Features @@ -269,2940 +50,164 @@ function trackVisitor(req) { // Migrated to src/features/bgp-communities/bgp-community-db.ts // ── Hijack Monitoring ────────────────────────────────────────── -// ============================================================ -// FEATURE 1: BGP Hijack Alerting + Webhooks -// ============================================================ -const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : __dirname); -const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json'; -const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json'; -const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.json'; -const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json'; +const { + DATA_DIR, + loadHijackSubs, + loadHijackAlerts, + loadWebhookSubs, + saveWebhookSubs, + saveHijackAlerts, + saveHijackSubs, +} = require("./server/hijack-monitoring/store"); +const { webhookSignature, deliverWebhook, notifyWebhooks } = require("./server/hijack-monitoring/webhooks"); +const { classifyHijackSeverity, checkHijacksForAsn, runHijackCheck } = require("./server/hijack-monitoring/monitor"); -function loadHijackSubs() { try { return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE,'utf8')); } catch(_){ return []; } } -function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALERTS_FILE,'utf8')); } catch(_){ return []; } } -function loadWebhookSubs() { try { return JSON.parse(fs.readFileSync(WEBHOOK_SUBS_FILE,'utf8')); } catch(_){ return []; } } -function saveWebhookSubs(s) { try { fs.writeFileSync(WEBHOOK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} } -function saveHijackAlerts(a) { try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(a.slice(-1000), null, 2)); } catch(_){} } -function saveHijackSubs(s) { try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} } +const { + aspaAdoptionDailyHistory, + recordAspaAdoptionSnapshot, + getAdoptionTrend, + buildAspaAdoptionDashboard, +} = require("./server/aspa-adoption/tracker"); -// Generate HMAC-SHA256 signature for webhook payloads -function webhookSignature(secret, payload) { - return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex'); -} +const { fetchRirDelegation, fetchIpv6AdoptionStats } = require("./server/ipv6-adoption"); -// Classify hijack severity -function classifyHijackSeverity(unexpected, missing, rpkiInvalid) { - if (rpkiInvalid > 0 || unexpected.length >= 5) return 'CRITICAL'; - if (unexpected.length >= 2 || missing.length >= 3) return 'HIGH'; - if (unexpected.length >= 1 || missing.length >= 1) return 'MEDIUM'; - return 'LOW'; -} - -// Deliver webhook with exponential backoff (max 5 retries) -async function deliverWebhook(sub, payload, attempt) { - attempt = attempt || 1; - const payloadStr = JSON.stringify(payload); - const sig = webhookSignature(sub.secret, payloadStr); - return new Promise((resolve) => { - try { - const parsed = new URL(sub.endpoint_url); - const lib = parsed.protocol === 'https:' ? https : http; - const options = { - hostname: parsed.hostname, - port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), - path: parsed.pathname + parsed.search, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payloadStr), - 'X-PeerCortex-Signature': sig, - 'X-PeerCortex-Event': 'hijack_detected', - 'User-Agent': 'PeerCortex-Webhook/1.0' - }, - timeout: 10000 - }; - const req = lib.request(options, (res) => { - let body = ''; - res.on('data', c => body += c); - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve({ ok: true, status: res.statusCode }); - } else if (attempt < 5) { - const delay = Math.pow(2, attempt) * 1000; - setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay); - } else { - resolve({ ok: false, status: res.statusCode, error: 'max_retries' }); - } - }); - }); - req.on('error', () => { - if (attempt < 5) { - const delay = Math.pow(2, attempt) * 1000; - setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay); - } else { - resolve({ ok: false, error: 'connection_failed' }); - } - }); - req.on('timeout', () => { req.destroy(); }); - req.write(payloadStr); - req.end(); - } catch(e) { - resolve({ ok: false, error: e.message }); - } - }); -} - -// Fire webhooks for all subscribers of an ASN when hijack detected -async function notifyWebhooks(asn, alert) { - const subs = loadWebhookSubs().filter(s => String(s.asn) === String(asn) && s.active !== false); - for (const sub of subs) { - const payload = { - event: 'hijack_detected', - asn: asn, - alert: alert, - timestamp: new Date().toISOString(), - source: 'PeerCortex BGP Monitor' - }; - deliverWebhook(sub, payload, 1).then(result => { - if (!result.ok) console.warn(`[WEBHOOK] Delivery failed for AS${asn} → ${sub.endpoint_url}: ${result.error || result.status}`); - }); - } -} - -// Returns null (not []) when the prefix lookup itself failed -- distinct from a -// genuine zero-prefix result. runHijackCheck must not treat these the same way: -// setting an empty baseline from a transient failure would permanently disable -// hijack detection for that subscription (baseline.size stays 0 forever after, -// so "unexpected" never fires again -- see the baseline.size > 0 guard below). -async function checkHijacksForAsn(asn) { - try { - const data = await localDb.getRipeStatAnnouncedPrefixes(asn); - if (data && data.status === 'error') return null; - const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix); - return prefixes; - } catch (_) { return null; } -} - -async function runHijackCheck() { - const subs = loadHijackSubs(); - if (!subs.length) return; - const alerts = loadHijackAlerts(); - let changed = false; - for (const sub of subs) { - const current = await checkHijacksForAsn(sub.asn); - if (current === null) { - // Lookup failed this cycle -- skip both anomaly detection and baseline - // initialization; retry on the next 30-minute run rather than locking in - // an empty baseline that would silently disable detection forever. - continue; - } - const baseline = new Set(sub.prefixes || []); - const unexpected = current.filter(p => baseline.size > 0 && !baseline.has(p)); - const missing = [...baseline].filter(p => !current.includes(p)); - if (unexpected.length || missing.length) { - // Dedup: only alert if no alert in last 6 hours for this ASN - const sixHoursAgo = Date.now() - 6 * 60 * 60 * 1000; - const recentAlert = alerts.find(a => a.asn === sub.asn && new Date(a.ts).getTime() > sixHoursAgo); - if (!recentAlert) { - const severity = classifyHijackSeverity(unexpected, missing, 0); - const alert = { - id: crypto.randomBytes(8).toString('hex'), - asn: sub.asn, ts: new Date().toISOString(), - severity, unexpected, missing, resolved: false, - msg: `BGP anomaly for AS${sub.asn}: ${unexpected.length} unexpected prefix(es), ${missing.length} missing` - }; - alerts.push(alert); - changed = true; - notifyWebhooks(sub.asn, alert); - } - } - // Set baseline on first monitoring - if (!sub.prefixes || !sub.prefixes.length) { - sub.prefixes = current; - changed = true; - } - } - if (changed) { saveHijackAlerts(alerts); saveHijackSubs(subs); } -} -// Run hijack check every 30 minutes -setInterval(runHijackCheck, 30 * 60 * 1000); +const { UA } = require("./server/data/constants"); +const { CITY_COORDS } = require("./server/data/city-coords"); // ============================================================ -// FEATURE 3: ASPA Adoption Tracker -// ============================================================ +const { + scoreRingSvg, + pdfBaseCSS, + buildPdfHtml, + buildComparisonPdfHtml, + formatLabel, + escHtml, +} = require("./server/pdf-export/templates"); +const { + getPuppeteerBrowser, + pdfCacheGet, + pdfCacheSet, + renderHtmlToPdf, +} = require("./server/pdf-export/renderer"); -/** - * Load persisted ASPA adoption history from disk. - * Returns array of snapshot objects (up to 365 entries). - */ -function loadAspaAdoptionHistory() { - try { - const raw = JSON.parse(fs.readFileSync(ASPA_ADOPTION_FILE, 'utf8')); - return Array.isArray(raw) ? raw : []; - } catch(_) { return []; } -} +const { + responseCache, + cacheGet, + cacheSet, + RESULT_CACHE_TTL, + CACHE_TTL_LOOKUP, + CACHE_TTL_LOOKUP_DEGRADED, + CACHE_TTL_DEFAULT, + rdapCacheGet, + rdapCacheSet, + whoisCacheGet, + whoisCacheSet, + WHOIS_NOT_FOUND_CACHE_TTL, + quickIxCacheGet, + quickIxCacheSet, + bgproutesResultCache, + aspaResultCache, + validateResultCache, + resultCacheGet, + resultCacheSet, +} = require("./server/caches/response-caches"); +// bgproutesVpCache/bgproutesVpCacheTs/BGPROUTES_VP_TTL removed 2026-07-16: +// confirmed dead code, never read anywhere in the file. -/** - * Save ASPA adoption history to disk (keep last 365 entries). - */ -function saveAspaAdoptionHistory(history) { - try { - const trimmed = history.slice(-365); - fs.writeFileSync(ASPA_ADOPTION_FILE, JSON.stringify(trimmed, null, 2), 'utf8'); - } catch(e) { - console.error('[ASPA-ADOPT] Save failed:', e.message); - } -} - -/** In-memory adoption history (persisted on each new snapshot) */ -let aspaAdoptionDailyHistory = loadAspaAdoptionHistory(); - -/** - * Take a new adoption snapshot and persist it. - * Called whenever the RPKI feed is refreshed. - * @param {number} aspaCount — total ASPA objects in rpkiAspaMap - * @param {number} roaCount — total ROAs in roaStore - */ -function recordAspaAdoptionSnapshot(aspaCount, roaCount) { - const now = new Date(); - const date = now.toISOString().slice(0, 10); // "YYYY-MM-DD" - - // Compute how many Atlas-visible ASNs have ASPA - let atlasTotal = 0; - let atlasWithAspa = 0; - if (typeof atlasProbeCache !== 'undefined' && atlasProbeCache?.asns_with_probes) { - atlasTotal = atlasProbeCache.asns_with_probes.length; - // rpkiAspaMap is keyed by integer ASN - atlasWithAspa = atlasProbeCache.asns_with_probes.filter(a => rpkiAspaMap.has(Number(a))).length; - } - - const coveragePct = atlasTotal > 0 - ? Math.round((atlasWithAspa / atlasTotal) * 10000) / 100 // 2 decimal places - : 0; - - // Compute delta from previous snapshot - const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1]; - const delta = prev ? aspaCount - prev.aspa_objects : 0; - - const snapshot = { - date, - ts: now.getTime(), - aspa_objects: aspaCount, - roa_count: roaCount, - atlas_asns_total: atlasTotal, - atlas_asns_with_aspa: atlasWithAspa, - coverage_pct_atlas: coveragePct, - aspa_delta: delta, - method: 'rpki_cloudflare_feed', - }; - - // Only store one snapshot per date (overwrite if same day) - const existingIdx = aspaAdoptionDailyHistory.findIndex(s => s.date === date); - if (existingIdx >= 0) { - aspaAdoptionDailyHistory[existingIdx] = snapshot; - } else { - aspaAdoptionDailyHistory.push(snapshot); - } - saveAspaAdoptionHistory(aspaAdoptionDailyHistory); - console.log(`[ASPA-ADOPT] Snapshot ${date}: ${aspaCount} objects, ${coveragePct}% Atlas coverage (${atlasWithAspa}/${atlasTotal})`); - return snapshot; -} - -/** - * Get adoption trend for a given period string ('7d', '30d', '90d', '1y'). - */ -function getAdoptionTrend(period) { - const days = period === '7d' ? 7 : period === '30d' ? 30 : period === '90d' ? 90 : 365; - const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; - return aspaAdoptionDailyHistory.filter(s => s.ts >= cutoff); -} - -/** - * Build the ASPA adoption dashboard HTML. - */ -function buildAspaAdoptionDashboard() { - const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1]; - const trend30 = getAdoptionTrend('30d'); - const trend7 = getAdoptionTrend('7d'); - - // Forecast: simple linear regression over last 30 days - function linearForecast(data, daysAhead) { - if (data.length < 2) return null; - const n = data.length; - const xs = data.map((_, i) => i); - const ys = data.map(d => d.coverage_pct_atlas); - const xMean = xs.reduce((a, b) => a + b, 0) / n; - const yMean = ys.reduce((a, b) => a + b, 0) / n; - const num = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i] - yMean), 0); - const den = xs.reduce((s, x) => s + (x - xMean) ** 2, 0); - if (den === 0) return yMean; - const slope = num / den; - return Math.max(0, Math.min(100, yMean + slope * (n - 1 + daysAhead))); - } - - const forecast90d = linearForecast(trend30, 90); - const change7d = trend7.length >= 2 - ? (trend7[trend7.length-1].aspa_objects - trend7[0].aspa_objects) - : 0; - const trendLabels = trend30.map(d => d.date); - const trendValues = trend30.map(d => d.coverage_pct_atlas); - const asnValues = trend30.map(d => d.aspa_objects); - - const coverageNow = latest?.coverage_pct_atlas ?? 0; - const aspaNow = latest?.aspa_objects ?? 0; - const atlasTotal = latest?.atlas_asns_total ?? 0; - - return ` - - - - - ASPA Adoption Tracker · PeerCortex - - - - - -
-

ASPA Adoption Tracker

-

Global ASPA deployment trends · Powered by RPKI Cloudflare feed · Updated every ~4 hours

- -
-
-
Atlas Coverage
-
${coverageNow.toFixed(1)}%
-
of Atlas-visible ASNs
-
-
-
ASPA Objects
-
${aspaNow.toLocaleString()}
-
total in RPKI
-
-
-
Atlas ASNs
-
${atlasTotal.toLocaleString()}
-
sampled denominator
-
-
-
7-Day Delta
-
${change7d > 0 ? '+' : ''}${change7d}
-
new ASPA objects
-
-
-
Data Points
-
${aspaAdoptionDailyHistory.length}
-
history snapshots
-
-
- -
- ↓ JSON (30d) - ↓ CSV (30d) - ↓ CSV (1y) - -
- -
-
-

Atlas Coverage % (last 30 days)

- -
-
-
90-Day Forecast
-
${forecast90d !== null ? forecast90d.toFixed(1) + '%' : '—'}
-
linear projection from 30-day trend
-
- Based on ${trend30.length} data points.
- Current: ${coverageNow.toFixed(1)}% -
-
-
- -
-

Total ASPA Objects in RPKI (last 30 days)

- -
- -
- Last snapshot: ${latest?.date ?? 'none'} · Denominator: ${atlasTotal.toLocaleString()} Atlas ASNs · - JSON API -
-
- - - -`; -} +const { ensureManrsCache, checkManrsMembership } = require("./server/services/manrs"); // ============================================================ -// FEATURE 4: IPv6 Adoption per RIR -// ============================================================ - -// In-memory cache: { data, ts } -let ipv6StatsCache = null; -const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h - -const RIR_DELEGATION_URLS = { - RIPE: 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest', - ARIN: 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest', - APNIC: 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest', - AFRINIC: 'https://ftp.afrinic.net/stats/afrinic/delegated-afrinic-extended-latest', - LACNIC: 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest', -}; - -/** - * Fetch and parse one RIR delegation file. - * Returns { ipv4, ipv6, asns } count objects. - */ -function fetchRirDelegation(rir, url) { - return new Promise((resolve) => { - const result = { rir, ipv4: 0, ipv6: 0, asns: 0, ipv4_countries: new Set(), ipv6_countries: new Set() }; - const mod = url.startsWith('https') ? require('https') : require('http'); - const req = mod.get(url, { headers: { 'User-Agent': 'PeerCortex/1.0 (+https://peercortex.org)' }, timeout: 20000 }, res => { - if (res.statusCode !== 200) { resolve(result); return; } - let buf = ''; - res.on('data', d => { buf += d; }); - res.on('end', () => { - for (const line of buf.split('\n')) { - if (line.startsWith('#') || line.startsWith(rir.toLowerCase() + '|*') || !line.includes('|')) continue; - const parts = line.split('|'); - if (parts.length < 7) continue; - const type = parts[2]; - const status = parts[6].trim(); - const cc = parts[1]; - if (status !== 'allocated' && status !== 'assigned') continue; - if (type === 'ipv4') { result.ipv4++; result.ipv4_countries.add(cc); } - else if (type === 'ipv6') { result.ipv6++; result.ipv6_countries.add(cc); } - else if (type === 'asn') { result.asns++; } - } - resolve(result); - }); - }); - req.on('error', () => resolve(result)); - req.on('timeout', () => { req.destroy(); resolve(result); }); - }); -} - -/** - * Fetch all 5 RIR delegation files and aggregate IPv6 stats. - * Cached for 24 hours. - */ -async function fetchIpv6AdoptionStats(force) { - if (!force && ipv6StatsCache && (Date.now() - ipv6StatsCache.ts) < IPV6_STATS_TTL) { - return ipv6StatsCache.data; - } - console.log('[IPv6] Fetching RIR delegation stats...'); - const results = await Promise.all( - Object.entries(RIR_DELEGATION_URLS).map(([rir, url]) => fetchRirDelegation(rir, url)) - ); - const rirs = results.map(r => ({ - rir: r.rir, - ipv4_records: r.ipv4, - ipv6_records: r.ipv6, - asn_records: r.asns, - ipv6_pct: (r.ipv4 + r.ipv6) > 0 - ? Math.round((r.ipv6 / (r.ipv4 + r.ipv6)) * 10000) / 100 - : 0, - countries_with_ipv6: r.ipv6_countries.size, - countries_with_ipv4: r.ipv4_countries.size, - })); - const total_ipv4 = rirs.reduce((s, r) => s + r.ipv4_records, 0); - const total_ipv6 = rirs.reduce((s, r) => s + r.ipv6_records, 0); - const data = { - fetched_at: new Date().toISOString(), - global_ipv6_pct: total_ipv4 + total_ipv6 > 0 - ? Math.round((total_ipv6 / (total_ipv4 + total_ipv6)) * 10000) / 100 - : 0, - total_ipv4_records: total_ipv4, - total_ipv6_records: total_ipv6, - by_rir: rirs, - }; - ipv6StatsCache = { data, ts: Date.now() }; - console.log(`[IPv6] Done. Global IPv6%: ${data.global_ipv6_pct}% across ${rirs.length} RIRs`); - return data; -} - -// Pre-fetch on startup (non-blocking) -fetchIpv6AdoptionStats().catch(() => {}); - -const UA = "PeerCortex/0.5.0 (+https://peercortex.org)"; - -// Static geocode cache for major networking cities (fallback when PDB facility coords missing) -const CITY_COORDS = { - "amsterdam": [52.3676, 4.9041], "london": [51.5074, -0.1278], "frankfurt": [50.1109, 8.6821], - "paris": [48.8566, 2.3522], "stockholm": [59.3293, 18.0686], "zurich": [47.3769, 8.5417], - "berlin": [52.5200, 13.4050], "hamburg": [53.5511, 9.9937], "munich": [48.1351, 11.5820], - "vienna": [48.2082, 16.3738], "prague": [50.0755, 14.4378], "warsaw": [52.2297, 21.0122], - "copenhagen": [55.6761, 12.5683], "oslo": [59.9139, 10.7522], "helsinki": [60.1699, 24.9384], - "milan": [45.4642, 9.1900], "madrid": [40.4168, -3.7038], "lisbon": [38.7223, -9.1393], - "dublin": [53.3498, -6.2603], "brussels": [50.8503, 4.3517], "bucharest": [44.4268, 26.1025], - "sofia": [42.6977, 23.3219], "athens": [37.9838, 23.7275], "istanbul": [41.0082, 28.9784], - "moscow": [55.7558, 37.6173], "mumbai": [19.0760, 72.8777], "singapore": [1.3521, 103.8198], - "hong kong": [22.3193, 114.1694], "tokyo": [35.6762, 139.6503], "sydney": [-33.8688, 151.2093], - "los angeles": [34.0522, -118.2437], "new york": [40.7128, -74.0060], "chicago": [41.8781, -87.6298], - "dallas": [32.7767, -96.7970], "miami": [25.7617, -80.1918], "ashburn": [39.0438, -77.4874], - "seattle": [47.6062, -122.3321], "san jose": [37.3382, -121.8863], "toronto": [43.6532, -79.3832], - "sao paulo": [-23.5505, -46.6333], "johannesburg": [-26.2041, 28.0473], "meppel": [52.6966, 6.1940], - "manchester": [53.4808, -2.2426], "marseille": [43.2965, 5.3698], "dusseldorf": [51.2277, 6.7735], - "nuremberg": [49.4521, 11.0767], "tallinn": [59.4370, 24.7536], "riga": [56.9496, 24.1052], - "auckland": [-36.8485, 174.7633], "wellington": [-41.2865, 174.7762], "denver": [39.7392, -104.9903], - "atlanta": [33.7490, -84.3880], "portland": [45.5152, -122.6784], "vancouver": [49.2827, -123.1207], - "montreal": [45.5017, -73.5673], "mexico city": [19.4326, -99.1332], "seoul": [37.5665, 126.9780], - "taipei": [25.0330, 121.5654], "bangkok": [13.7563, 100.5018], "jakarta": [-6.2088, 106.8456], - "scotland": [55.9533, -3.1883], "edinburgh": [55.9533, -3.1883], -}; - -// ============================================================ -// FEATURE 2: PDF Export — template generators + puppeteer renderer -// ============================================================ - -/** - * Generate a health score ring SVG (inline, no external deps) - * @param {number} score 0–100 - * @param {string} color CSS color - */ -function scoreRingSvg(score, color) { - const r = 54, cx = 64, cy = 64; - const circ = 2 * Math.PI * r; - const fill = circ * (1 - score / 100); - const clr = color || (score >= 80 ? '#22c55e' : score >= 60 ? '#f59e0b' : '#ef4444'); - return ` - - - ${score} - /100 - `; -} - -/** Shared CSS for all PDF templates */ -function pdfBaseCSS() { - return ` - *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - body { font-family: -apple-system, 'Segoe UI', system-ui, Arial, sans-serif; color: #1e1b4b; background: #fff; font-size: 11pt; line-height: 1.5; } - @page { size: A4; margin: 18mm 16mm; } - .page { page-break-after: always; min-height: 247mm; padding: 0; } - .page:last-child { page-break-after: avoid; } - h1 { font-size: 22pt; font-weight: 700; color: #4f46e5; } - h2 { font-size: 14pt; font-weight: 600; color: #3730a3; margin-top: 1.4em; margin-bottom: 0.5em; border-bottom: 2px solid #e0e7ff; padding-bottom: 4px; } - h3 { font-size: 11pt; font-weight: 600; color: #4338ca; margin-top: 1em; margin-bottom: 0.3em; } - p { margin-bottom: 0.5em; } - table { width: 100%; border-collapse: collapse; margin: 0.6em 0; font-size: 9.5pt; } - th { background: #4f46e5; color: #fff; padding: 6px 10px; text-align: left; font-weight: 600; } - td { padding: 5px 10px; border-bottom: 1px solid #e0e7ff; vertical-align: top; } - tr:nth-child(even) td { background: #f5f3ff; } - .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8.5pt; font-weight: 600; } - .badge-green { background: #dcfce7; color: #166534; } - .badge-red { background: #fee2e2; color: #991b1b; } - .badge-yellow{ background: #fef9c3; color: #854d0e; } - .badge-gray { background: #f3f4f6; color: #374151; } - .badge-indigo{ background: #e0e7ff; color: #3730a3; } - .cover { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; padding: 40px 0 20px; min-height: 247mm; } - .cover-logo { font-size: 11pt; font-weight: 700; color: #4f46e5; letter-spacing: -0.5px; margin-bottom: 60px; display: flex; align-items: center; gap: 8px; } - .cover-logo span { color: #1e1b4b; } - .cover-asn { font-size: 11pt; font-weight: 500; color: #6b7280; margin-bottom: 6px; } - .cover-name { font-size: 28pt; font-weight: 700; color: #1e1b4b; margin-bottom: 8px; line-height: 1.2; } - .cover-sub { font-size: 13pt; color: #4f46e5; font-weight: 500; margin-bottom: 30px; } - .cover-meta { font-size: 9pt; color: #9ca3af; margin-top: auto; padding-top: 20px; border-top: 1px solid #e0e7ff; width: 100%; } - .cover-stripe { position: fixed; right: -8mm; top: 0; width: 28mm; height: 100vh; background: linear-gradient(180deg, #4f46e5 0%, #6366f1 50%, #818cf8 100%); opacity: 0.08; pointer-events: none; } - .metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 1em 0; } - .metric-card { background: #f5f3ff; border: 1px solid #e0e7ff; border-radius: 8px; padding: 12px 14px; } - .metric-card .label { font-size: 8pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; } - .metric-card .value { font-size: 18pt; font-weight: 700; color: #4f46e5; } - .metric-card .unit { font-size: 8pt; color: #9ca3af; } - .score-row { display: flex; align-items: center; gap: 20px; margin: 0.8em 0; } - .check-list { list-style: none; padding: 0; } - .check-list li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid #f3f4f6; font-size: 9.5pt; } - .check-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; } - .check-pass { color: #16a34a; } - .check-fail { color: #dc2626; } - .check-warn { color: #d97706; } - .section-intro { font-size: 9.5pt; color: #6b7280; margin-bottom: 1em; font-style: italic; } - footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 8pt; color: #9ca3af; padding: 6px 16mm; border-top: 1px solid #e0e7ff; display: flex; justify-content: space-between; } - `; -} - -/** - * Build the PDF HTML for a single ASN. - * @param {object} data Full /api/validate response - * @param {object} aspa /api/aspa response (may be null) - * @param {string} format 'report' | 'executive' | 'technical' - */ -function buildPdfHtml(data, aspa, format) { - const asn = data.asn || '?'; - const name = data.name || 'Unknown Network'; - const score = Math.round(data.health_score || 0); - const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); - const checks = (data.score_breakdown || []); - const rel = data.relationships || {}; - const counts = rel.counts || {}; - // validations is an object keyed by check name; rpki details live in rpki_completeness - const validations = (data.validations && typeof data.validations === 'object' && !Array.isArray(data.validations)) ? data.validations : {}; - const rpkiDetails = (validations.rpki_completeness?.details || []); - const totalPfx = data.meta?.total_prefixes || 0; - - // Derived stats — use rpki_completeness for RPKI metrics - const rpki = validations.rpki_completeness || {}; - const rpkiCoverage = rpki.coverage_pct ?? 0; - const rpkiTotal = rpki.total_checked || rpkiDetails.length || 0; - const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length; - const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').length; - const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not_found').length; - - const passedChecks = checks.filter(c => c.status === 'pass').length; - const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length; - const scoreColor = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626'; - const aspaDirect = aspa?.direct?.has_aspa ?? null; - const aspaProviders= aspa?.direct?.provider_asns?.length || 0; - - // Check list HTML - function checkIcon(passed) { - if (passed) return ''; - return ''; - } - - const checksHtml = checks.map(c => { - const isPassed = c.status === 'pass'; - const isInfo = c.status === 'info'; - const icon = checkIcon(isPassed || isInfo); - const statusBadge = isPassed ? 'badge-green' : isInfo ? 'badge-indigo' : 'badge-red'; - const statusLabel = isPassed ? 'PASS' : isInfo ? 'INFO' : 'FAIL'; - return ` -
  • - ${icon} -
    - ${escHtml(c.check || '')} - ${statusLabel} - ${c.earned !== undefined ? `${c.earned}/${c.weight}pt` : ''} -
    -
  • `; - }).join(''); - - // Upstream providers table - const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => ` - AS${u.asn}${escHtml(u.name || '')}${u.power ?? '-'} - `).join(''); - - // Prefix validations table (sample from rpki_completeness.details) - const pfxRows = rpkiDetails.slice(0, 20).map(v => { - const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray'; - return `${escHtml(v.prefix)} - ${v.status} - ${v.validating_roas ?? '-'} - —`; - }).join(''); - - // Recommendations - const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info'); - const recHtml = failedItems.length === 0 - ? '

    ✓ No critical issues found. Network health is excellent.

    ' - : failedItems.map((c, i) => ` -
    -
    ${i+1}. ${escHtml(c.check || '')}
    - ${c.detail ? `
    ${escHtml(c.detail)}
    ` : ''} -
    `).join(''); - - // ── Executive pages (always present) ─────────────────────────────────────── - const coverPage = ` -
    -
    - -
    AS${asn}
    -
    ${escHtml(name)}
    -
    ${formatLabel(format)} Report
    -
    - ${scoreRingSvg(score)} -
    -
    Health Score
    -
    ${score}/100
    -
    ${passedChecks} passed · ${failedChecks} failed
    -
    -
    -
    - Generated by PeerCortex · ${ts} · peercortex.org -   |   ${totalPfx} prefixes analysed -   |   RPKI coverage ${rpkiCoverage}% -
    -
    `; - - const summaryPage = ` -
    -

    Executive Summary

    -

    Key performance indicators for AS${asn} (${escHtml(name)}).

    -
    -
    Health Score
    ${score}
    out of 100
    -
    RPKI Coverage
    ${rpkiCoverage}%
    ${rpkiValid}/${rpkiTotal} prefixes
    -
    Upstreams
    ${counts.upstreams || 0}
    providers
    -
    Downstreams
    ${counts.downstreams || 0}
    customers
    -
    Peers
    ${counts.peers || 0}
    BGP sessions
    -
    ASPA Status
    -
    - - ${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'} - -
    -
    ${aspaProviders} providers
    -
    -
    -

    Status Overview

    - -
    `; - - // ── Recommendations page ──────────────────────────────────────────────────── - const recsPage = ` -
    -

    Recommendations

    -

    Action items to improve network health and security posture.

    - ${recHtml} - ${failedItems.length > 0 ? ` -

    Remediation Priority

    - - - ${failedItems.map((c, i) => ` - - - - - - `).join('')} -
    #CheckImpactEffort
    ${i+1}${escHtml(c.check || '')}HighMedium
    ` : ''} -
    `; - - // ── Detail pages (included for 'report' and 'technical') ─────────────────── - const rpkiPage = ` -
    -

    RPKI Compliance

    -

    Route Origin Authorization (ROA) validation results for announced prefixes.

    -
    -
    Coverage
    ${rpkiCoverage}%
    ${rpkiValid} valid ROAs
    -
    Invalid
    ${rpkiInvalid}
    prefix${rpkiInvalid !== 1 ? 'es' : ''}
    -
    Not Found
    ${rpkiNotFound}
    no ROA
    -
    -

    ASPA (Autonomous System Provider Authorization)

    - - - - - -
    AttributeValue
    ASPA Object Present${aspaDirect ? 'Yes' : 'No'}
    Provider Count${aspaProviders}
    Providers${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || '—'}
    - ${pfxRows ? ` -

    Prefix Validation Sample (first 20)

    - - - ${pfxRows} -
    PrefixStatusMax LengthROA Origin
    ` : ''} -
    `; - - const bgpPage = ` -
    -

    BGP Topology

    -

    AS relationship overview and upstream connectivity analysis.

    -
    -
    Upstreams
    ${counts.upstreams || 0}
    transit providers
    -
    Downstreams
    ${counts.downstreams || 0}
    customers
    -
    Peers
    ${counts.peers || 0}
    BGP peers
    -
    - ${upstreamRows ? ` -

    Top Upstream Providers

    - - - ${upstreamRows} -
    ASNNamePower Score
    ` : '

    No upstream data available.

    '} -
    `; - - const footer = ` - `; - - // ── Extra pages for technical / report formats ───────────────────────────── - - // Technical: full prefix table (no row cap) + all upstream rows + check scores - const allPfxRows = rpkiDetails.map(v => { - const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray'; - return `${escHtml(v.prefix)} - ${v.status} - ${v.validating_roas ?? '-'} - ${v.max_length ?? '-'}`; - }).join(''); - - const allUpstreamRows = (rel.upstreams || []).map(u => - `AS${u.asn}${escHtml(u.name || '')}${u.power ?? '-'}` - ).join(''); - - const checkScoreRows = checks.map(c => - ` - ${escHtml(c.check || '')} - ${c.status?.toUpperCase()} - ${c.earned ?? '-'}${c.weight ?? '-'} - ${escHtml(c.detail || '')} - ` - ).join(''); - - const technicalHeaderPage = ` -
    -
    -
    PeerCortex · Technical Analysis Report
    -
    AS${asn} — ${escHtml(name)}
    -
    Generated ${ts} · peercortex.org · ${totalPfx} prefixes · RPKI ${rpkiCoverage}%
    -
    -
    -
    Health Score
    ${score}/100
    ${passedChecks} pass · ${failedChecks} fail
    -
    RPKI Coverage
    ${rpkiCoverage}%
    ${rpkiValid} valid · ${rpkiInvalid} invalid · ${rpkiNotFound} missing
    -
    Upstreams
    ${counts.upstreams || 0}
    transit providers
    -
    Peers / Downstreams
    ${counts.peers || 0} / ${counts.downstreams || 0}
    BGP relationships
    -
    ASPA
    -
    ${aspaDirect === true ? 'Deployed' : 'Not deployed'}
    -
    ${aspaProviders} providers registered
    -
    -
    Prefixes Analysed
    ${totalPfx}
    ${rpkiDetails.length} with RPKI data
    -
    -
    `; - - const rpkiFullPage = ` -
    -

    RPKI Compliance — Full Prefix Table

    -

    ${rpkiDetails.length} prefixes with RPKI validation data. Coverage: ${rpkiCoverage}% · Valid: ${rpkiValid} · Invalid: ${rpkiInvalid} · Not Found: ${rpkiNotFound}

    -

    ASPA Object

    - - - - - -
    AttributeValue
    ASPA Present${aspaDirect ? 'Yes' : 'No'}
    Provider Count${aspaProviders}
    Registered Providers${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || '—'}
    - ${allPfxRows ? ` -

    All Prefixes

    - - - ${allPfxRows} -
    PrefixRPKI StatusValidating ROAsMax Length
    ` : '

    No RPKI prefix data available.

    '} -
    `; - - const bgpFullPage = ` -
    -

    BGP Topology — Full Relationship Table

    -

    Complete AS relationship data. Upstreams: ${counts.upstreams || 0} · Peers: ${counts.peers || 0} · Downstreams: ${counts.downstreams || 0}

    - ${allUpstreamRows ? ` -

    All Upstream Providers

    - - - ${allUpstreamRows} -
    ASNNamePower Score
    ` : '

    No upstream data available.

    '} - ${(rel.peers || []).length > 0 ? ` -

    Peer Sample (top 20)

    - - - ${(rel.peers || []).slice(0, 20).map(p => ``).join('')} -
    ASNName
    AS${p.asn}${escHtml(p.name || '')}
    ` : ''} -
    `; - - const checkDetailPage = ` -
    -

    Check Score Detail

    -

    All ${checks.length} health checks with individual scores and diagnostic notes.

    - - - ${checkScoreRows} -
    CheckStatusEarnedWeightDetail
    -
    - Scoring: Total = sum of earned points across all checks. Maximum possible: ${checks.reduce((s, c) => s + (c.weight || 0), 0)} points. - Current score: ${checks.reduce((s, c) => s + (c.earned || 0), 0)} / ${checks.reduce((s, c) => s + (c.weight || 0), 0)} = ${score}/100 -
    -
    `; - - // Report: add data provenance and ASPA deep-dive - const aspaPage = ` -
    -

    ASPA — Provider Authorization Analysis

    -

    Autonomous System Provider Authorization (ASPA) is a BGP security mechanism that cryptographically links an ASN to its upstream providers in the RPKI.

    -
    -
    ASPA Deployed
    -
    ${aspaDirect === true ? 'Yes' : 'No'}
    -
    -
    Providers Listed
    ${aspaProviders}
    in ASPA object
    -
    RPKI Coverage
    ${rpkiCoverage}%
    of announced prefixes
    -
    -

    What ASPA Protects Against

    - - - - - - -
    ThreatASPA Mitigates?Notes
    Route leaks (valley-free violations)YesProviders verify upstream path
    BGP hijacks (origin AS spoofing)PartialCombined with ROV
    AS-path prepending attacksYesInvalid paths rejected
    Forged-origin hijacksNoROA + ROV required
    - ${aspaDirect === false ? ` -
    -
    Recommendation: Deploy ASPA
    -
    - 1. Identify all upstream transit providers (${counts.upstreams || 0} detected)
    - 2. Create an ASPA object in your RIR portal listing their ASNs
    - 3. Sign with your RPKI key — takes effect within hours
    - 4. Verify with: rpki-client -v or RIPE RPKI Validator -
    -
    ` : ` -
    -
    ASPA is deployed — listed providers:
    -
    ${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(' · ') || '—'}
    -
    `} -
    `; - - const provenancePage = ` -
    -

    Data Provenance

    -

    All data sources, cache ages, and methodology used to generate this report.

    - - - - - - - - -
    Data SourceWhat it providesUpdate Frequency
    Cloudflare RPKI FeedROA + ASPA objectsEvery 4 hours
    PeeringDBIXP presence, peering policy, facilitiesDaily (03:00 UTC)
    RIPE StatAS relationships, prefix visibility, BGP dataReal-time / cached 15 min
    RIPE AtlasActive measurement probes by ASNCached 12 hours
    bgproutes.ioBGP route visibility fallbackReal-time
    bgp.he.netAS relationship graphCached 24 hours
    -

    Report Metadata

    - - - - - - - - - - -
    FieldValue
    ASNAS${asn}
    Network Name${escHtml(name)}
    Report Format${formatLabel(format)}
    Generated${ts}
    PlatformPeerCortex · peercortex.org
    Prefixes Analysed${totalPfx}
    RPKI Objects Checked${rpkiDetails.length}
    Health Checks Run${checks.length}
    -
    - This report is generated automatically based on publicly available BGP and RPKI data. - Data may be delayed by cache TTLs. Not a substitute for real-time monitoring. - MIT License · github.com/renefichtmueller/PeerCortex -
    -
    `; - - // ── Compose pages by format ──────────────────────────────────────────────── - let bodyContent; - if (format === 'executive') { - // 3 pages: clean stakeholder summary — no deep technical data - bodyContent = coverPage + summaryPage + recsPage; - } else if (format === 'technical') { - // ~8 pages: engineer-focused, dense, no decorative cover, full data tables - bodyContent = technicalHeaderPage + rpkiFullPage + bgpFullPage + checkDetailPage + recsPage; - } else { - // 'report' — full stakeholder + technical, ~10 pages - bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + aspaPage + recsPage + provenancePage; - } - - return ` - - - - PeerCortex — AS${asn} ${formatLabel(format)} - - - - ${bodyContent} - ${footer} - -`; -} - -/** - * Build comparison PDF HTML for two ASNs. - */ -function buildComparisonPdfHtml(d1, aspa1, l1, d2, aspa2, l2) { - const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); - - function col(d, aspa) { - const asn = d.asn || '?'; - const name = d.name || 'Unknown'; - const score = Math.round(d.health_score || 0); - const rel = d.relationships || {}; - const counts= rel.counts || {}; - const vals = (d.validations && !Array.isArray(d.validations)) ? d.validations : {}; - const rpki = vals.rpki_completeness || {}; - const rpkiV = rpki.with_roa || 0; - const rpkiT = rpki.total_checked || 0; - const rpkiC = rpki.coverage_pct ?? (rpkiT > 0 ? Math.round((rpkiV / rpkiT) * 100) : 0); - const sc = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626'; - const aspaDep = aspa?.direct?.has_aspa ?? null; - const checks= (d.score_breakdown || []); - const passC = checks.filter(c => c.status === 'pass').length; - const failC = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length; - - return ` - -
    - ${scoreRingSvg(score, sc)} -
    AS${asn}
    -
    ${escHtml(name)}
    -
    - - - - - - - - - - - -
    Key Metrics
    Health Score${score}/100
    RPKI Coverage${rpkiC}% (${rpkiV}/${rpkiT})
    Upstreams${counts.upstreams || 0}
    Downstreams${counts.downstreams || 0}
    Peers${counts.peers || 0}
    ASPA${aspaDep ? 'Deployed' : 'Not Deployed'}
    Checks Passed${passC} / ${checks.length}
    Checks Failed${failC}
    Total Prefixes${d.meta?.total_prefixes || 0}
    - `; - } - - const colHtml = col(d1, aspa1) + col(d2, aspa2); - - // ── Peering Intelligence: common IXPs + common facilities ───────────────── - const ixList1 = (l1?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps })); - const ixList2 = (l2?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps })); - const ixIds1 = new Set(ixList1.map(x => x.id)); - const ixIds2 = new Set(ixList2.map(x => x.id)); - const commonIxIds = [...ixIds1].filter(id => ixIds2.has(id)); - const commonIxps = commonIxIds.slice(0, 20).map(id => { - const x1 = ixList1.find(x => x.id === id); - const x2 = ixList2.find(x => x.id === id); - return { id, name: x1?.name || 'Unknown', city: x1?.city || '', speed1: x1?.speed, speed2: x2?.speed }; - }); - - const facList1 = (l1?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' })); - const facList2 = (l2?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' })); - const facIds1 = new Set(facList1.map(f => f.id)); - const facIds2 = new Set(facList2.map(f => f.id)); - const commonFacIds = [...facIds1].filter(id => facIds2.has(id)); - const commonFacs = commonFacIds.slice(0, 15).map(id => facList1.find(f => f.id === id)); - - // IXPs of ASN1 where ASN2 is NOT present (opportunity IXPs for ASN2) - const oppsFor2 = ixList1.filter(x => !ixIds2.has(x.id)).slice(0, 8); - const oppsFor1 = ixList2.filter(x => !ixIds1.has(x.id)).slice(0, 8); - - const commonIxHtml = commonIxps.length > 0 - ? ` - - ${commonIxps.map(x => ` - - - - - - `).join('')} -
    IXP NameCityAS${d1.asn} SpeedAS${d2.asn} Speed
    ${escHtml(x.name)}${escHtml(x.city)}${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}
    ` - : '

    No common IXPs found — these networks do not share any Internet Exchange Points.

    '; - - const commonFacHtml = commonFacs.length > 0 - ? ` - - ${commonFacs.map(f => ` - - - - - `).join('')} -
    Datacenter / Colocation FacilityCityCountry
    ${escHtml(f?.name||'')}${escHtml(f?.city||'')}${escHtml(f?.country||'')}
    ` - : '

    No shared colocation facilities found.

    '; - - const oppsHtml = (asns, opps) => opps.length === 0 - ? `

    Already maximally peered on these exchanges — or no data available.

    ` - : opps.map(x => `
    - ${escHtml(x.name)} - ${x.city ? ` · ${escHtml(x.city)}` : ''} - ${x.speed ? `${(x.speed/1000).toLocaleString()}G` : ''} -
    `).join(''); - - const peeringPage = (commonIxps.length + commonFacs.length > 0 || oppsFor1.length + oppsFor2.length > 0) ? ` -
    -

    Peering Intelligence

    -

    Common Internet Exchange Points and colocation facilities — potential peering locations between AS${d1.asn} and AS${d2.asn}.

    - -
    -
    -
    ${commonIxps.length}
    -
    Common IXPs
    -
    -
    -
    ${commonFacs.length}
    -
    Shared Facilities
    -
    -
    - -

    Common Internet Exchange Points (direct peering possible)

    - ${commonIxHtml} - -

    Shared Colocation Facilities

    - ${commonFacHtml} - - ${oppsFor1.length + oppsFor2.length > 0 ? ` -

    Expansion Opportunities

    -
    -
    -
    AS${d2.asn} could join (AS${d1.asn} is there)
    - ${oppsHtml(d2.asn, oppsFor2)} -
    -
    -
    AS${d1.asn} could join (AS${d2.asn} is there)
    - ${oppsHtml(d1.asn, oppsFor1)} -
    -
    ` : ''} -
    ` : ''; - - // Side-by-side check comparison - const allChecks = [...new Set([ - ...(d1.score_breakdown || []).map(c => c.check), - ...(d2.score_breakdown || []).map(c => c.check), - ])]; - const checkRows = allChecks.map(checkName => { - const c1 = (d1.score_breakdown || []).find(c => c.check === checkName); - const c2 = (d2.score_breakdown || []).find(c => c.check === checkName); - const isP1 = c1?.status === 'pass'; - const isP2 = c2?.status === 'pass'; - const icon = p => p ? '✓' : '✗'; - const st = p => p ? 'color:#16a34a;font-weight:700' : 'color:#dc2626;font-weight:700'; - return ` - ${escHtml(checkName)} - ${c1 ? icon(isP1) : '—'} - ${c2 ? icon(isP2) : '—'} - `; - }).join(''); - - return ` - - - - PeerCortex — AS${d1.asn} vs AS${d2.asn} Comparison - - - -
    -
    - -
    -
    ASN Comparison Report
    -
    AS${d1.asn} vs AS${d2.asn}
    -
    ${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}
    -
    - ${colHtml}
    -
    Generated by PeerCortex · ${ts} · peercortex.org
    -
    -
    -

    Head-to-Head Health Checks

    - - - ${checkRows} -
    CheckAS${d1.asn}AS${d2.asn}
    -
    - ${peeringPage} - - -`; -} - -function formatLabel(fmt) { - return fmt === 'executive' ? 'Executive Summary' : fmt === 'technical' ? 'Technical Analysis' : 'Full Report'; -} - -function escHtml(s) { - if (!s) return ''; - return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); -} - -/** - * Render HTML to PDF buffer using puppeteer. - * Returns Buffer or throws. - */ -async function renderHtmlToPdf(html) { - const browser = await getPuppeteerBrowser(); - if (!browser) throw new Error('PDF renderer unavailable (puppeteer not installed)'); - const page = await browser.newPage(); - try { - await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 15000 }); - const buf = await page.pdf({ - format: 'A4', - printBackground: true, - margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' }, - }); - return buf; - } finally { - await page.close(); - } -} - -// ============================================================ -// Task 6: In-memory cache with TTL + Rate Limiting -// ============================================================ -const responseCache = new Map(); - -function cacheGet(key) { - const entry = responseCache.get(key); - if (!entry) return null; - if (Date.now() > entry.expires) { - responseCache.delete(key); - return null; - } - return entry.data; -} - -function cacheSet(key, data, ttlMs) { - responseCache.set(key, { data, expires: Date.now() + ttlMs }); - // Evict old entries periodically (keep cache under 500 entries) - if (responseCache.size > 500) { - const now = Date.now(); - for (const [k, v] of responseCache) { - if (now > v.expires) responseCache.delete(k); - } - } -} - - -// ── Tier-1 ASN Set (used for route leak heuristics) ───────────────────────── -const TIER1_ASNS = new Set([ - 174, // Cogent - 209, // CenturyLink/Lumen - 286, // KPN - 701, // Verizon/UUNET - 702, // Verizon - 1239, // Sprint - 1273, // Vodafone - 1280, // Internet Systems Consortium - 1299, // Arelion (Telia) - 2914, // NTT - 3257, // GTT - 3320, // Deutsche Telekom - 3356, // Lumen (Level3) - 3491, // PCCW - 5511, // Orange - 6453, // TATA - 6461, // Zayo - 6762, // Telecom Italia Sparkle - 7018, // AT&T - 7473, // SingTel - 12956, // Telxius -]); - -const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes -const CACHE_TTL_LOOKUP_DEGRADED = 90 * 1000; // matches the existing 90s guard for suspicious /api/validate zero-counts -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, ttl } -const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000; -// A "not found" result after all 5 RIR sources failed is far more often a shared -// network blip (DNS, outbound connectivity, simultaneous rate-limit) than a real -// ASN existing nowhere across RIPE+APNIC+ARIN+LACNIC+AFRINIC -- fetchJSON can't -// tell timeout/network-error apart from a genuine 404, so cache that specific -// conclusion much more briefly than a confirmed, parsed WHOIS record. -const WHOIS_NOT_FOUND_CACHE_TTL = 10 * 60 * 1000; -function whoisCacheGet(asn) { - const e = whoisCache.get(String(asn)); - if (e && (Date.now() - e.ts) < (e.ttl || WHOIS_CACHE_TTL)) return e.data; - return undefined; -} -function whoisCacheSet(asn, data, ttl) { - if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value); - whoisCache.set(String(asn), { data, ts: Date.now(), ttl: ttl || WHOIS_CACHE_TTL }); -} - -// ============================================================ -// Quick-IX Cache — 1h TTL, for Peering Recommendations -// ============================================================ -const quickIxCache = new Map(); // key: asn string, value: { data, ts } -const QUICK_IX_CACHE_TTL = 60 * 60 * 1000; -function quickIxCacheGet(asn) { - const e = quickIxCache.get(String(asn)); - if (e && (Date.now() - e.ts) < QUICK_IX_CACHE_TTL) return e.data; - return undefined; -} -function quickIxCacheSet(asn, data) { - if (quickIxCache.size > 2000) quickIxCache.delete(quickIxCache.keys().next().value); - quickIxCache.set(String(asn), { data, ts: Date.now() }); -} - -// ============================================================ -// bgproutes.io Vantage Points Cache — 1h TTL, prevents 429 -// ============================================================ -let bgproutesVpCache = null; -let bgproutesVpCacheTs = 0; -const BGPROUTES_VP_TTL = 60 * 60 * 1000; // 1 hour - -// ============================================================ -// bgproutes + ASPA result caches — 15min TTL, prevent re-hitting slow APIs -// ============================================================ -const bgproutesResultCache = new Map(); -const aspaResultCache = new Map(); -const validateResultCache = new Map(); -const RESULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes -function resultCacheGet(map, key) { - const e = map.get(String(key)); - const ttl = e && e.ttl ? e.ttl : RESULT_CACHE_TTL; - if (e && (Date.now() - e.ts) < ttl) return e.data; - return undefined; -} -function resultCacheSet(map, key, data, ttlMs) { - if (map.size > 2000) map.delete(map.keys().next().value); - map.set(String(key), { data, ts: Date.now(), ttl: ttlMs || RESULT_CACHE_TTL }); -} - -// ============================================================ -// MANRS Participants Cache (scraped from public HTML page, 24h TTL) -// ============================================================ -let manrsAsnSet = null; // Set 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; - } - }, -}; - -// ============================================================ -// 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 -// Renamed from fetchRipeStatCached 2026-07-16: a second function with that exact -// name was added ~150 lines down (the local-DB-routing wrapper) during the Postgres -// refactor. In JS, two `function` declarations sharing a name silently collapse to -// the LAST one in source order -- the real RIPE Stat fetch+cache+throttle -// implementation below was completely shadowed, and every call to -// fetchRipeStatCached() for any endpoint NOT in {announced-prefixes, asn-neighbours, -// as-overview, visibility, prefix-size-distribution} was unconditionally returning -// null instead of ever reaching this code. That silently broke blocklist, -// abuse-contact-finder, bgp-updates, routing-status, looking-glass, whois, -// reverse-dns-consistency, maxmind-geo-lite-pfx, and ixs lookups -- see the router's -// fallback call to this function for the fix. -async function fetchRipeStatCachedFromApi(url, options) { - // Extract endpoint name from URL for TTL lookup - const match = url.match(/\/data\/([^/]+)\//); - const endpoint = match ? match[1] : "default"; - const resourceMatch = url.match(/resource=([^&]+)/); - const resource = resourceMatch ? resourceMatch[1] : url; - const cacheKey = endpoint + ":" + resource; - - // Check cache - const cached = ripeStatCache.get(cacheKey); - const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000; - if (cached && (Date.now() - cached.ts) < ttl) { - return cached.data; - } - - // Throttle via semaphore - await ripeStatSemaphore.acquire(); - try { - // Double-check cache after acquiring semaphore (another request may have filled it) - const cached2 = ripeStatCache.get(cacheKey); - if (cached2 && (Date.now() - cached2.ts) < ttl) { - return cached2.data; - } - - const result = await fetchJSON(url, options); - - // Only cache successful results — never cache null (failed/rate-limited responses) - // Caching null causes cascading failures: retry hits cache, returns null again - if (result !== null) { - if (ripeStatCache.size >= RIPE_STAT_CACHE_MAX) { - ripeStatCache.delete(ripeStatCache.keys().next().value); - } - ripeStatCache.set(cacheKey, { data: result, ts: Date.now() }); - } - return result; - } finally { - ripeStatSemaphore.release(); - } -} - -// Cached + throttled RIPE Stat with one retry on failure -async function fetchRipeStatCachedWithRetry(url, options) { - const result = await fetchRipeStatCached(url, options); - if (result !== null) return result; - await new Promise(r => setTimeout(r, 1500)); - return fetchRipeStatCached(url, options); -} - -// RIPE Stat cache disk persistence (skip null entries) -function saveRipeStatCacheToDisk(filePath) { - try { - const obj = {}; - for (const [k, v] of ripeStatCache) { - if (v.data !== null) obj[k] = v; - } - fs.writeFileSync(filePath, JSON.stringify({ ts: Date.now(), entries: obj })); - console.log("[RIPE-CACHE] Saved " + Object.keys(obj).length + " entries to disk"); - } catch (e) { - console.warn("[RIPE-CACHE] Disk save failed:", e.message); - } -} - -function loadRipeStatCacheFromDisk(filePath) { - try { - if (!fs.existsSync(filePath)) return false; - const raw = fs.readFileSync(filePath, "utf8"); - const data = JSON.parse(raw); - const now = Date.now(); - for (const [k, v] of Object.entries(data.entries || {})) { - const match = k.match(/^([^:]+):/); - const endpoint = match ? match[1] : "default"; - const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000; - if (now - v.ts < ttl) { - ripeStatCache.set(k, v); - } - } - console.log("[RIPE-CACHE] Loaded " + ripeStatCache.size + " entries from disk"); - return true; - } catch (e) { - console.warn("[RIPE-CACHE] Disk load failed:", e.message); - return false; - } -} - -function fetchRpkiAspaFeed() { - if (rpkiAspaFetching) return Promise.resolve(); - rpkiAspaFetching = true; - console.log("[RPKI] Fetching Cloudflare RPKI feed (ASPA + ROA)..."); - return new Promise((resolve) => { - const options = { - headers: { "User-Agent": UA }, - timeout: 120000, - }; - https.get("https://rpki.cloudflare.com/rpki.json", options, (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => { - try { - const parsed = JSON.parse(data); - - // Load ASPA objects - const aspas = parsed.aspas || []; - rpkiAspaMap.clear(); - aspas.forEach((a) => { - const customerAsid = Number(a.customer_asid); - const providers = (a.providers || []).map(Number); - rpkiAspaMap.set(customerAsid, new Set(providers)); - }); - - // Load ROA objects into local store (eliminates RIPE Stat per-prefix calls) - const roas = parsed.roas || []; - roaStore.build(roas); - roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json"); - - // Track ASPA adoption — persist to disk + update in-memory trend - recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count); - - rpkiAspaLastFetch = Date.now(); - console.log("[RPKI] Loaded " + rpkiAspaMap.size + " ASPA objects + " + roaStore.count + " ROAs from Cloudflare feed"); - } catch (e) { - console.error("[RPKI] Failed to parse RPKI feed:", e.message); - } - rpkiAspaFetching = false; - resolve(); - }); - }).on("error", (e) => { - console.error("[RPKI] Fetch failed:", e.message); - rpkiAspaFetching = false; - resolve(); - }); - }); -} - -// Ensure ASPA + ROA cache is fresh -async function ensureAspaCache() { - if (Date.now() - rpkiAspaLastFetch > 4 * 60 * 60 * 1000) { - await fetchRpkiAspaFeed(); - } -} - -// Lookup ASPA object for a given ASN from the RPKI feed cache. feedLoaded -// distinguishes "the feed loaded fine and this ASN genuinely has no ASPA object" -// from "the Cloudflare feed fetch has never succeeded, so we don't actually know" -- -// rpkiAspaLastFetch is only set on a successful parse (see fetchRpkiAspaFeed), so -// 0 means every attempt so far has failed and exists:false here is not trustworthy. -function lookupAspaFromRpki(asn) { - const asnNum = Number(asn); - const feedLoaded = rpkiAspaLastFetch > 0; - if (rpkiAspaMap.has(asnNum)) { - const providers = rpkiAspaMap.get(asnNum); - return { exists: true, providers: [...providers].sort((a, b) => a - b), feedLoaded }; - } - return { exists: false, providers: [], feedLoaded }; -} - - - -// 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 every other RIPE Stat endpoint (not mirrored into the local Postgres DB) -- - // e.g. blocklist, abuse-contact-finder, bgp-updates, routing-status, looking-glass, - // whois, reverse-dns-consistency, maxmind-geo-lite-pfx, ixs -- fall through to the - // real RIPE Stat API with its own cache/throttle/never-cache-null protections, - // instead of returning null unconditionally (see fetchRipeStatCachedFromApi's - // header comment for how this silently broke ~9 checks until 2026-07-16). - return fetchRipeStatCachedFromApi(url, options); - } 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"|"unavailable", validating_roas: N } -// "not_found" means the DB was queried and genuinely has no covering ROA -- a real result. -// "unavailable" means the DB query itself failed (connection/timeout/error) -- we don't know. -// Conflating these used to make DB hiccups masquerade as "no ROA published" (a real, worse- -// sounding finding). Callers computing coverage percentages should exclude "unavailable". -async function validateRPKIWithCache(asn, prefix) { - try { - const result = await localDb.validateRpki(prefix, asn); - if (result.status === 'valid') { - return { prefix, status: "valid", validating_roas: 1 }; - } else if (result.status === 'invalid') { - return { prefix, status: "invalid", validating_roas: 1 }; - } else if (result.status === 'unknown') { - return { prefix, status: "unavailable", validating_roas: 0 }; - } else { - // 'not-found': genuinely no covering ROA - return { prefix, status: "not_found", validating_roas: 0 }; - } - } catch (_e) { - console.error("[RPKI] Error validating " + prefix + ":", _e.message); - return { prefix, status: "unavailable", 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) -// hopCheck(asI, asJ): is asJ an attested provider of CUSTOMER asI? Caller must pass -// the customer first, provider second -- see draft-ietf-sidrops-aspa-verification -// section 5.3's authorized(A(I), A(I+1)), where A(I) is always the customer side. -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++) { - // collapsed[i] (closer to origin) is the customer; collapsed[i - 1] (closer to - // the validator) is the provider it's declaring -- customer goes first in hopCheck. - const check = hopCheck(collapsed[i], collapsed[i - 1], 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 -- draft-ietf-sidrops-aspa-verification Section 5.5. -// Unlike upstream verification, a downstream path may legitimately contain both -// an up-ramp (customer->provider hops, near the origin) AND a down-ramp -// (provider->customer hops, near the validator), transitioning at most once -- -// e.g. origin -> its providers -> a peering exchange -> validator's providers -> -// validator. Section 5.3 defines four ramp lengths computed from two independent -// scans, verified 2026-07-16 against a worked example fetched from the draft -// (N=4 path with a single unattested/no-attestation "kink" correctly resolves -// to Valid, and a path with definite failures on both ends correctly resolves -// to Invalid, matching this implementation). -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], collapsed[i - 1], aspaStore), - }); - } - - // Up-ramp scan: walk from the origin (collapsed[N-1]) toward the validator - // (collapsed[0]), i.e. authorized(A(I), A(I+1)) for I = 1..N-1 ascending. - // maxUpRamp stops at the first DEFINITE violation ("NotProviderPlus"); - // minUpRamp stops at the first hop that isn't confirmed ProviderPlus - // (including unattested "NoAttestation" hops, which don't disprove a leak - // but also don't prove the path is clean). - let maxUpRamp = N; - let minUpRamp = N; - for (let k = N - 1; k >= 1; k--) { - const check = hopCheck(collapsed[k], collapsed[k - 1], aspaStore); - const I = N - k; - if (check === "NotProviderPlus" && maxUpRamp === N) maxUpRamp = I; - if (check !== "ProviderPlus" && minUpRamp === N) minUpRamp = I; - if (maxUpRamp !== N && minUpRamp !== N) break; - } - - // Down-ramp scan: the mirror image, walking from the validator (collapsed[0]) - // toward the origin, i.e. authorized(A(J), A(J-1)) for J = N..2 descending. - let maxDownRamp = N; - let minDownRamp = N; - for (let k = 0; k <= N - 2; k++) { - const check = hopCheck(collapsed[k], collapsed[k + 1], aspaStore); - const rampLen = k + 1; // = N - J + 1 for the corresponding J - if (check === "NotProviderPlus" && maxDownRamp === N) maxDownRamp = rampLen; - if (check !== "ProviderPlus" && minDownRamp === N) minDownRamp = rampLen; - if (maxDownRamp !== N && minDownRamp !== N) break; - } - - if (maxUpRamp + maxDownRamp < N) { - return { - result: "Invalid", - reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") < " + N + ": no valid up/down split covers the path", - hops, - }; - } - if (minUpRamp + minDownRamp < N) { - return { - result: "Unknown", - reason: "min_up_ramp(" + minUpRamp + ") + min_down_ramp(" + minDownRamp + ") < " + N + ": unattested hops prevent full confirmation", - hops, - }; - } - return { - result: "Valid", - reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") >= " + N + " and min_up_ramp + min_down_ramp >= " + N, - 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). -// agreement_pct only counts pairs where BOTH sources actually returned a real -// verdict -- a zero-sample or all-RIPE-lookups-failed result reports agreement_pct: -// null (not a fabricated 100), since nothing was actually cross-checked. -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: null, disagreements: [], sample_size: 0, comparable_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; - let comparable = 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++; - - // A failed/unknown RIPE lookup isn't a comparison at all -- exclude it from - // the agreement percentage instead of counting it as a silent "agreement". - if (ripeState === "error" || ripeState === "unknown") { - continue; - } - - comparable++; - if (cfIsValid === ripeIsValid) { - agreements++; - } else { - disagreements.push({ - prefix: pfx, - cloudflare: cfStatus, - ripe: ripeState, - }); - } - } - - const agreementPct = comparable > 0 ? Math.round((agreements / comparable) * 100) : null; - return { cloudflare_valid: cloudflareValid, ripe_valid: ripeValid, agreement_pct: agreementPct, disagreements: disagreements, sample_size: sample.length, comparable_size: comparable }; -} - -// ============================================================ -// 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)"; - // Short TTL: this "not found" likely reflects a transient failure across - // all 5 sources, not a confirmed non-existent ASN -- see WHOIS_NOT_FOUND_CACHE_TTL. - whoisCacheSet(asn, null, WHOIS_NOT_FOUND_CACHE_TTL); - } - } - } 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; -} - -// ============================================================ -// Internal API Server Proxy -// ============================================================ -// Several routes were extracted into src/api/ + src/features/*/routes.ts -// (Fastify, dist/api/index.js, run as a separate PM2 process on -// API_SERVER_PORT) but were never re-wired here after that migration — -// they just silently 404'd. This forwards those specific paths through -// rather than reimplementing them inline a second time. -const API_SERVER_PORT = parseInt(process.env.API_SERVER_PORT || "3102", 10); - -function proxyToApiServer(req, res, targetUrl) { - const proxyReq = http.request( - { - hostname: "127.0.0.1", - port: API_SERVER_PORT, - path: targetUrl, - method: req.method, - headers: Object.assign({}, req.headers, { host: "127.0.0.1:" + API_SERVER_PORT }), - }, - (proxyRes) => { - res.writeHead(proxyRes.statusCode, proxyRes.headers); - proxyRes.pipe(res); - } - ); - proxyReq.on("error", (err) => { - console.error("[API Proxy] Error forwarding " + targetUrl + ":", err.message); - if (!res.headersSent) { - res.writeHead(502, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "API server unavailable", detail: err.message })); - } - }); - req.pipe(proxyReq); -} +// subCableCache/globalFacCache removed 2026-07-16: confirmed dead code, +// never read anywhere in the file. + +const { roaStore } = require("./server/caches/roa-store"); + +const { + rpkiAspaMap, + getRpkiAspaLastFetch, + fetchRpkiAspaFeed, + ensureAspaCache, + lookupAspaFromRpki, + validateRPKIWithCache, + fetchRipeRpkiValidator, + crossCheckRpki, +} = require("./server/services/rpki"); + +const { pdbSourceCache } = require("./server/caches/pdb-source-cache"); +const { + ripeStatCache, + fetchRipeStatCached, + fetchRipeStatCachedFromApi, + fetchRipeStatCachedWithRetry, + saveRipeStatCacheToDisk, + loadRipeStatCacheFromDisk, + Semaphore, +} = require("./server/services/ripe-stat"); + + +const { + fetchJSON, + fetchJSONWithRetry, + fetchHTML, + postJSON, + fetchBgproutesVisibility, +} = require("./server/services/http-helpers"); +// checkRateLimit/rateLimitMap removed 2026-07-16: dead code, never called by +// the request handler (confirmed via the server.js structural exploration). + +const { resolveASNames } = require("./server/resolve-as-names"); + + + +const { + hasAsSet, + hopCheck, + collapsePrepends, + verifyUpstream, + verifyDownstream, + detectValleys, + buildAspaStore, + calculateAspaReadinessScore, +} = require("./server/aspa-verification/engine"); + + +const { fetchBgpHeNet } = require("./server/bgp-he-net"); + +const { fetchTopology } = require("./server/topology"); + +const { computeResilienceScore } = require("./server/resilience-score"); +const { computeRouteLeakDetection } = require("./server/route-leak"); +const { fetchWhois } = require("./server/whois"); // ============================================================ // HTTP Server // ============================================================ +const staticRoutes = require("./server/routes/static"); +const searchRoutes = require("./server/routes/search"); +const feedbackRoutes = require("./server/routes/feedback"); +const liaRoutes = require("./server/routes/lia"); +const healthRoute = require("./server/routes/health"); +const aspaVerifyRoute = require("./server/routes/aspa-verify"); +const aspaLegacyRoute = require("./server/routes/aspa-legacy"); +const bgpRoute = require("./server/routes/bgp"); +const validateRoute = require("./server/routes/validate"); +const lookupRoute = require("./server/routes/lookup"); +const relationshipsRoute = require("./server/routes/relationships"); +const compareRoute = require("./server/routes/compare"); +const quickIxRoute = require("./server/routes/quick-ix"); +const peersFindRoute = require("./server/routes/peers-find"); +const prefixDetailRoute = require("./server/routes/prefix-detail"); +const ixDetailRoute = require("./server/routes/ix-detail"); +const topologyRoute = require("./server/routes/topology"); +const whoisRoute = require("./server/routes/whois"); +const enrichRoute = require("./server/routes/enrich"); +const proxyStubsRoute = require("./server/routes/proxy-stubs"); +const changelogRoute = require("./server/routes/changelog"); +const hijackAlertsRoute = require("./server/routes/hijack-alerts"); +const pdfExportRoute = require("./server/routes/pdf-export"); +const aspaAdoptionRoute = require("./server/routes/aspa-adoption"); + const server = http.createServer(async (req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); @@ -3226,3358 +231,231 @@ const server = http.createServer(async (req, res) => { 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"); - } + return staticRoutes.handleRoot(req, res, host, reqPath); } // 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"); - } + return staticRoutes.handleIndexEditorial(req, res); } - // ============================================================ - // 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})); - } + // Name Search (RIPE Stat + PeeringDB combined) + if (reqPath === "/api/search") { + return searchRoutes.handleSearch(req, res); } // 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 })); + return searchRoutes.handleVisitors(req, res); } // 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(); + if (reqPath === "/api/feedback" && req.method === "OPTIONS") { + return feedbackRoutes.handleOptions(req, res); } // 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; + if (reqPath === "/api/feedback" && req.method === "POST") { + return feedbackRoutes.handlePost(req, res); } // 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 })); - } + if (reqPath === "/api/feedback" && req.method === "GET") { + return feedbackRoutes.handleGet(req, res, url); } // Serve favicon if (reqPath === "/favicon.ico") { - res.writeHead(204); - return res.end(); + return staticRoutes.handleFavicon(req, res); } // 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"); - } + return staticRoutes.handleLia(req, res); } - - // ============================================================ // 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)); + return liaRoutes.handleAtlasCoverage(req, res); } - // ============================================================ // 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; + return liaRoutes.handleParseFile(req, res); } - // ============================================================ // 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; + return liaRoutes.handleLiaCoverage(req, res); } 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: roaStore.count, - history_samples: aspaAdoptionDailyHistory.length, - delta_last: aspaAdoptionDailyHistory.length >= 2 - ? aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1].aspa_objects - aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2].aspa_objects - : 0, - }, - }; - return res.end(JSON.stringify(healthResponse, null, 2)); - }).catch(function(e) { - console.error('[/api/health] Local DB stats error:', e.message); - // Return health without local DB stats on error - return res.end( - JSON.stringify({ - status, - service: "PeerCortex", - version: "0.6.9", - timestamp: new Date().toISOString(), - uptime_seconds: Math.floor(process.uptime()), - memory_mb: Math.round(mem.heapUsed / 1024 / 1024), - bgproutes_configured: !!BGPROUTES_API_KEY, - caches: { - roa_store: { entries: roaStore.count, age_minutes: roaAge, ready: roaStore.ready }, - aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge }, - pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 }, - pdb_netixlan: { entries: pdbSourceCache.netixlan.size }, - pdb_netfac: { entries: pdbSourceCache.netfac.size }, - ripe_stat: { entries: ripeStatCache.size }, - response_cache: { entries: responseCache.size }, - }, - local_db: { error: "Could not fetch local DB stats", message: e.message }, - aspa_adoption: { - total_objects: rpkiAspaMap.size, - roa_count: roaStore.count, - history_samples: aspaAdoptionDailyHistory.length, - delta_last: aspaAdoptionDailyHistory.length >= 2 - ? aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1].aspa_objects - aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2].aspa_objects - : 0, - }, - }, null, 2) - ); - }); - return; + return healthRoute.handleHealth(req, res); } + // ============================================================ // ASPA Deep Verification endpoint: /api/aspa/verify?asn=X - // ============================================================ if (reqPath === "/api/aspa/verify") { - const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); - if (!rawAsn) { - res.writeHead(400); - return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); - } - const cachedVerify = resultCacheGet(aspaResultCache, "verify:" + rawAsn); - if (cachedVerify !== undefined) { - res.writeHead(200, { "Content-Type": "application/json" }); - return res.end(JSON.stringify(cachedVerify)); - } - const targetAsn = parseInt(rawAsn); - const start = Date.now(); - - try { - // Fetch neighbour and prefix data first - const [neighbourData, prefixData] = await Promise.all([ - fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }), - fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }), - ]); - - // Use looking-glass with actual prefixes to get BGP paths - const announcedPrefixes = prefixData?.data?.prefixes || []; - const samplePrefixes = announcedPrefixes.slice(0, 3).map((p) => p.prefix); // reduced 5→3 - - // Fetch looking-glass data for up to 3 prefixes in parallel (3s timeout each) - const lgResults = await Promise.all( - samplePrefixes.map((pfx) => - fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=" + encodeURIComponent(pfx), { timeout: 3000 }).catch(() => null) - ) - ); - - // Extract AS paths from looking glass results - const allPaths = []; - const pathNeighbourCount = new Map(); // Count how often each AS appears next to target in paths - - lgResults.forEach((lgData) => { - const rrcs = lgData?.data?.rrcs || []; - rrcs.forEach((rrc) => { - const peers = rrc.peers || []; - peers.forEach((peer) => { - const rawPath = peer.as_path || ""; - const pathArr = rawPath.split(" ").map(Number).filter(Boolean); - if (pathArr.length > 1) { - allPaths.push({ - rrc: rrc.rrc, - path: pathArr, - rawPath: rawPath, - prefix: peer.prefix || "", - hasAsSet: hasAsSet(rawPath), - }); - const idx = pathArr.indexOf(targetAsn); - if (idx > 0) { - const neighbour = pathArr[idx - 1]; - pathNeighbourCount.set(neighbour, (pathNeighbourCount.get(neighbour) || 0) + 1); - } - } - }); - }); - }); - - // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams) - // AS-path analysis is used for frequency/confirmation, NOT as standalone provider source - const neighbours = neighbourData?.data?.neighbours || []; - const leftNeighbours = neighbours.filter((n) => n.type === "left"); - const upstreamSet = new Set(); - leftNeighbours.forEach((n) => upstreamSet.add(n.asn)); - - // Classify left neighbours: high-power = likely upstream, low-power = likely peer - const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1); - const detectedProviders = [...upstreamSet].map((asn) => { - const nb = leftNeighbours.find((n) => n.asn === asn); - const power = nb ? (nb.power || 0) : 0; - const powerPct = Math.round((power / maxPower) * 100); - const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer"; - return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification }; - }); - - await resolveASNames(detectedProviders); - - // Count how often each provider appears in paths - const providerFrequency = new Map(); - allPaths.forEach((p) => { - const idx = p.path.indexOf(targetAsn); - if (idx > 0) { - const prov = p.path[idx - 1]; - providerFrequency.set(prov, (providerFrequency.get(prov) || 0) + 1); - } - }); - - // Check Cloudflare RPKI feed for ASPA object - await ensureAspaCache(); - const aspaLookup = lookupAspaFromRpki(targetAsn); - const aspaObjectExists = aspaLookup.exists; - const aspaDeclaredProviders = aspaLookup.providers; - - // Build ASPA store from RPKI feed data (real ASPA objects only). If the - // target has no real ASPA object, its hops must show "NoAttestation" -- - // NOT a fabricated declaration built from heuristically-detected BGP - // neighbours, which would contradict the aspa_object_exists:false this - // same response reports and falsely present unattested hops as - // "ProviderPlus"/Valid. hopCheck() already returns "NoAttestation" for - // any ASN with no entry in aspaStore, so simply not setting one here is - // correct and sufficient -- no fallback needed. - const aspaStore = new Map(); - if (aspaObjectExists) { - aspaStore.set(targetAsn, new Set(aspaDeclaredProviders)); - } - // Also populate store with all known ASPA objects from the RPKI feed - // for providers that have their own ASPA objects (enables full path verification) - for (const [cas, provSet] of rpkiAspaMap) { - if (!aspaStore.has(cas)) { - aspaStore.set(cas, provSet); - } - } - // Deliberately NOT adding empty Set() entries for detected-but-unattested - // providers here: aspaStore.get() returning undefined for them makes - // hopCheck() correctly report "NoAttestation". An empty (but present) Set - // would make providers.has(x) return false, misreporting "NotProviderPlus" - // (implying a real ASPA object that denies this hop) instead of "we simply - // don't know" -- see the 2026-07-16 audit for how this previously caused - // clean, unattested paths to be misclassified as route leaks. - - // Sample paths for verification (up to 50) - const samplePaths = allPaths.slice(0, 50); - const pathResults = samplePaths.map((p) => { - const upstream = verifyUpstream(p.path, aspaStore, p.rawPath); - const downstream = verifyDownstream(p.path, aspaStore, p.rawPath); - const valleys = detectValleys(p.path, aspaStore); - - return { - rrc: p.rrc, - prefix: p.prefix, - path: p.path.map((a) => "AS" + a).join(" "), - collapsed_path: collapsePrepends(p.path).map((a) => "AS" + a).join(" "), - has_as_set: p.hasAsSet, - upstream_verification: upstream, - downstream_verification: downstream, - valleys: valleys, - overall: p.hasAsSet - ? "Invalid" - : upstream.result === "Valid" && downstream.result === "Valid" - ? "Valid" - : upstream.result === "Invalid" || downstream.result === "Invalid" - ? "Invalid" - : "Unknown", - }; - }); - - // Calculate statistics - const validPaths = pathResults.filter((p) => p.overall === "Valid").length; - const invalidPaths = pathResults.filter((p) => p.overall === "Invalid").length; - const unknownPaths = pathResults.filter((p) => p.overall === "Unknown").length; - const asSetPaths = pathResults.filter((p) => p.has_as_set).length; - const valleyPaths = pathResults.filter((p) => p.valleys.length > 0).length; - - // For readiness scoring: Valid = full credit, Unknown = partial (no ASPA data is normal), - // only Invalid actually indicates problems - const pathNotInvalidPct = pathResults.length > 0 - ? Math.round(((validPaths + unknownPaths) / pathResults.length) * 100) - : 0; - const pathValidPct = pathResults.length > 0 ? Math.round((validPaths / pathResults.length) * 100) : 0; - - // Provider audit: compare detected vs declared - const detectedSet = new Set(detectedProviders.map((p) => p.asn)); - const declaredSet = new Set(aspaDeclaredProviders); - - const missingFromAspa = detectedProviders - .filter((p) => !declaredSet.has(p.asn)) - .map((p) => ({ - asn: p.asn, - name: p.name, - frequency: providerFrequency.get(p.asn) || 0, - frequency_pct: allPaths.length > 0 - ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100) - : 0, - })) - .sort((a, b) => b.frequency - a.frequency); - - const extraInAspa = aspaDeclaredProviders - .filter((asn) => !detectedSet.has(asn)) - .map((asn) => ({ - asn, - name: "", - seen_in_paths: false, - })); - - const providerCompleteness = detectedProviders.length > 0 - ? Math.round( - (detectedProviders.filter((p) => declaredSet.has(p.asn)).length / - detectedProviders.length) * - 100 - ) - : aspaObjectExists ? 100 : 0; - - // Get RPKI coverage for readiness score - // Validate ALL prefixes using local RPKI data (Cloudflare feed - all 5 RIRs) - await ensureAspaCache(); - const rpkiBatch = announcedPrefixes.map((p) => p.prefix); - const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx))); - // Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup - // must not lower a network's displayed ASPA readiness score. - const rpkiChecked = rpkiResults.filter((r) => r.status !== "unavailable"); - const rpkiValid = rpkiChecked.filter((r) => r.status === "valid").length; - const rpkiCoverage = rpkiChecked.length > 0 ? Math.round((rpkiValid / rpkiChecked.length) * 100) : 0; - - // Calculate readiness score - const readinessScore = calculateAspaReadinessScore({ - rpkiCoverage, - aspaObjectExists, - providerCompleteness, - pathValidationPct: pathNotInvalidPct, - }); - - const duration = Date.now() - start; - const verifyResult = { - meta: { - query: "AS" + rawAsn, - duration_ms: duration, - timestamp: new Date().toISOString(), - paths_analyzed: pathResults.length, - total_paths_seen: allPaths.length, - }, - asn: targetAsn, - readiness_score: readinessScore, - aspa_object_exists: aspaObjectExists, - aspa_feed_healthy: aspaLookup.feedLoaded, - detected_providers: detectedProviders.map((p) => ({ - ...p, - frequency: providerFrequency.get(p.asn) || 0, - frequency_pct: allPaths.length > 0 - ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100) - : 0, - })), - provider_audit: { - declared_count: aspaDeclaredProviders.length, - detected_count: detectedProviders.length, - completeness_pct: providerCompleteness, - missing_from_aspa: missingFromAspa, - extra_in_aspa: extraInAspa, - }, - path_verification: { - total: pathResults.length, - valid: validPaths, - invalid: invalidPaths, - unknown: unknownPaths, - as_set_flagged: asSetPaths, - valley_detected: valleyPaths, - valid_pct: pathValidPct, - not_invalid_pct: pathNotInvalidPct, - results: pathResults, - }, - rpki_coverage: rpkiCoverage, - }; - resultCacheSet(aspaResultCache, "verify:" + rawAsn, verifyResult); - return res.end(JSON.stringify(verifyResult, null, 2)); - } catch (err) { - res.writeHead(500); - return res.end(JSON.stringify({ error: "ASPA verification failed", message: err.message })); - } + return aspaVerifyRoute.handleAspaVerify(req, res, url); } - // ============================================================ // ASPA Check endpoint: /api/aspa?asn=X (existing, kept for compat) - // ============================================================ if (reqPath === "/api/aspa") { - const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); - if (!rawAsn) { - res.writeHead(400); - return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); - } - const cachedAspa = resultCacheGet(aspaResultCache, rawAsn); - if (cachedAspa !== undefined) { - res.writeHead(200, { "Content-Type": "application/json" }); - return res.end(JSON.stringify(cachedAspa)); - } - const start = Date.now(); - let _aspaDone = false; - const _aspaTimer = setTimeout(() => { - if (!_aspaDone) { - _aspaDone = true; - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) })); - } - }, 12000); - try { - const [lgData, neighbourData] = await Promise.all([ - fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 3000 }).catch(() => null), - fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 4000 }), - ]); - - const rrcs = lgData?.data?.rrcs || []; - const asPaths = []; - - rrcs.forEach((rrc) => { - const peers = rrc.peers || []; - peers.forEach((peer) => { - const path = peer.as_path || ""; - const pathArr = path.split(" ").map(Number).filter(Boolean); - if (pathArr.length > 1) { - asPaths.push({ rrc: rrc.rrc, path: pathArr, prefix: peer.prefix || "" }); - } - }); - }); - - // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams) - const neighbours = neighbourData?.data?.neighbours || []; - const leftNeighbours = neighbours.filter((n) => n.type === "left"); - const upstreamSet = new Set(); - leftNeighbours.forEach((n) => upstreamSet.add(n.asn)); - - // Classify left neighbours: high-power = likely upstream, low-power = likely peer - const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1); - const detectedProviders = [...upstreamSet].map((asn) => { - const nb = leftNeighbours.find((n) => n.asn === asn); - const power = nb ? (nb.power || 0) : 0; - const powerPct = Math.round((power / maxPower) * 100); - const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer"; - return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification }; - }); - - await resolveASNames(detectedProviders); - - // Check Cloudflare RPKI feed for ASPA object - await ensureAspaCache(); - const aspaLookup = lookupAspaFromRpki(rawAsn); - const aspaObjectExists = aspaLookup.exists; - const aspaDeclaredProviders = aspaLookup.providers; - - const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", "); - let recommendedAspa = - "aut-num: AS" + rawAsn + "\n" + - "# Recommended ASPA object:\n" + - "# customer: AS" + rawAsn + "\n" + - "# provider-set: " + providerList + "\n" + - "# AFI: ipv4, ipv6\n" + - "#\n" + - "# Detected providers from BGP path analysis:\n" + - detectedProviders.map((p) => "# AS" + p.asn + (p.name ? " (" + p.name + ")" : "")).join("\n"); - - // If ASPA object exists, show RPKI-declared providers - if (aspaObjectExists && aspaDeclaredProviders.length > 0) { - recommendedAspa += "\n#\n# RPKI-declared providers (from Cloudflare RPKI feed):\n" + - aspaDeclaredProviders.map((a) => "# AS" + a).join("\n"); - } - - const samplePaths = asPaths.slice(0, 10).map((p) => { - const pathStr = p.path.map((a) => "AS" + a).join(" -> "); - const idx = p.path.indexOf(parseInt(rawAsn)); - const provider = idx > 0 ? p.path[idx - 1] : null; - return { - rrc: p.rrc, - prefix: p.prefix, - path: pathStr, - detected_provider: provider ? "AS" + provider : null, - provider_in_set: provider ? upstreamSet.has(provider) : false, - }; - }); - - if (_aspaDone) return; // hard timeout already responded - _aspaDone = true; - clearTimeout(_aspaTimer); - const duration = Date.now() - start; - const aspaResult = { - meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() }, - asn: parseInt(rawAsn), - detected_providers: detectedProviders, - provider_count: detectedProviders.length, - aspa_object_exists: aspaObjectExists, - aspa_feed_healthy: aspaLookup.feedLoaded, - aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })), - aspa_declared_count: aspaDeclaredProviders.length, - recommended_aspa: recommendedAspa, - path_analysis: { - total_paths_seen: asPaths.length, - sample_paths: samplePaths, - }, - }; - resultCacheSet(aspaResultCache, rawAsn, aspaResult); - return res.end(JSON.stringify(aspaResult, null, 2)); - } catch (err) { - if (!_aspaDone) { - _aspaDone = true; - clearTimeout(_aspaTimer); - res.writeHead(500); - return res.end(JSON.stringify({ error: "ASPA check failed", message: err.message })); - } - } + return aspaLegacyRoute.handleAspaLegacy(req, res, url); } - // ============================================================ // BGP endpoint (LOCAL DB): /api/bgp?asn=X (or prefix=X) - // Queries local PostgreSQL bgp_routes table — zero external API calls - // ============================================================ if (reqPath === "/api/bgp") { - const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); - const prefix = url.searchParams.get("prefix") || ""; - if (!rawAsn && !prefix) { - res.writeHead(400); - return res.end(JSON.stringify({ error: "Need asn or prefix parameter" })); - } - const cacheKey = rawAsn || prefix; - const cached = resultCacheGet(bgproutesResultCache, cacheKey); - if (cached !== undefined) { - res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); - return res.end(JSON.stringify(cached)); - } - const start = Date.now(); - try { - const result = { - meta: { timestamp: new Date().toISOString(), source: "local_bgp_db" }, - bgp_status: null, - threat_intel: null, - }; - - // ---- BGP Status (local DB lookup) ---- - if (prefix) { - // Prefix lookup: Get BGP status for this prefix - const bgpStatus = await localDb.getBgpStatus(prefix); - if (bgpStatus) { - result.bgp_status = { - prefix, - announced: bgpStatus.announced, - origin_asns: bgpStatus.origin_asns, - visibility_percent: bgpStatus.visibility_percent, - last_seen: bgpStatus.last_seen, - source: "local_bgp", - }; - // Check for hijack (multiple origin ASNs). null means the check itself - // failed (DB error) -- distinct from a genuine single/no-origin result. - const hijackAsns = await localDb.checkBgpHijack(prefix); - if (hijackAsns === null) { - result.bgp_status.hijack_check_unavailable = true; - } else if (hijackAsns.length > 1) { - result.bgp_status.hijack_warning = { - detected: true, - origin_asns: hijackAsns, - message: `Multiple origin ASNs detected for ${prefix}`, - }; - } - } - } else if (rawAsn) { - // ASN lookup: Get all announced prefixes for this ASN - const prefixes = await localDb.getAnnouncedPrefixes(rawAsn); - if (prefixes && prefixes.length > 0) { - result.bgp_status = { - asn: rawAsn, - announced_count: prefixes.length, - prefixes: prefixes.slice(0, 50).map((p) => ({ - prefix: p.prefix, - origin_asn: p.origin_asn, - visibility_percent: p.visibility_percent, - last_seen: p.last_seen, - })), - source: "local_bgp", - }; - } else { - result.bgp_status = { - asn: rawAsn, - announced: false, - announced_count: 0, - message: "No prefixes found for this ASN in local BGP table", - source: "local_bgp", - }; - } - } - - // ---- Threat Intelligence (local cache lookup) ---- - // If we have an IP context, look up threat intel - if (prefix && prefix.includes(".")) { - // Extract IP from prefix (e.g., "1.1.1.0/24" → "1.1.1.0") - const ipAddr = prefix.split("/")[0]; - const threat = await localDb.getThreatIntel(ipAddr); - if (threat) { - result.threat_intel = { - ip_address: threat.ip_address, - threat_level: threat.threat_level, - confidence_score: threat.confidence_score, - source: threat.source, - cached_at: threat.cached_at, - }; - } - } - - result.meta.duration_ms = Date.now() - start; - resultCacheSet(bgproutesResultCache, cacheKey, result); - res.writeHead(200, { "Content-Type": "application/json" }); - return res.end(JSON.stringify(result, null, 2)); - } catch (err) { - console.error("[/api/bgp] Error:", err.message); - res.writeHead(500); - return res.end(JSON.stringify({ error: "BGP query failed", message: err.message })); - } + return bgpRoute.handleBgp(req, res, url); } - // ============================================================ + // Unified Validation endpoint: /api/validate?asn=X - // Runs ALL validations in parallel, returns comprehensive report - // ============================================================ if (reqPath === "/api/validate") { - const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); - if (!rawAsn) { - res.writeHead(400); - return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); - } - const cachedValidate = resultCacheGet(validateResultCache, rawAsn); - if (cachedValidate !== undefined) { - res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); - return res.end(JSON.stringify(cachedValidate)); - } - const start = Date.now(); - const targetAsn = parseInt(rawAsn); - - try { - // Phase 1: Fetch core data — 5s cap prevents large ASNs from blocking Phase 2 - const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([ - fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }), - fetchPeeringDB("/net?asn=" + rawAsn), - fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }), - fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn, { timeout: 5000 }), - ]); - - const allPrefixes = (prefixData && prefixData.data && prefixData.data.prefixes ? prefixData.data.prefixes : []).map(function(p) { return p.prefix; }); - // Use all prefixes for RPKI validation (local lookup is fast, no API calls) - const samplePrefixes = allPrefixes; - const net = pdbNet && pdbNet.data && pdbNet.data[0] ? pdbNet.data[0] : {}; - const netId = net.id; - const neighbours = neighbourData && neighbourData.data && neighbourData.data.neighbours ? neighbourData.data.neighbours : []; - - // ---- 11. Bogon Detection (local check) ---- - function checkBogonPrefix(prefix) { - var bogonV4 = [ - { net: "0.0.0.0", mask: 8 }, { net: "10.0.0.0", mask: 8 }, - { net: "100.64.0.0", mask: 10 }, { net: "127.0.0.0", mask: 8 }, - { net: "169.254.0.0", mask: 16 }, { net: "172.16.0.0", mask: 12 }, - { net: "192.0.2.0", mask: 24 }, { net: "192.168.0.0", mask: 16 }, - { net: "198.51.100.0", mask: 24 }, { net: "203.0.113.0", mask: 24 }, - { net: "240.0.0.0", mask: 4 }, - ]; - if (prefix.includes(":")) return { prefix: prefix, is_bogon: false, reason: "IPv6 bogon check skipped" }; - var split = prefix.split("/"); - var addr = split[0]; - var mask = parseInt(split[1] || "0"); - var parts = addr.split(".").map(Number); - var ip = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; - for (var bi = 0; bi < bogonV4.length; bi++) { - var b = bogonV4[bi]; - var bParts = b.net.split(".").map(Number); - var bIp = ((bParts[0] << 24) | (bParts[1] << 16) | (bParts[2] << 8) | bParts[3]) >>> 0; - var bMask = (~((1 << (32 - b.mask)) - 1)) >>> 0; - if ((ip & bMask) === (bIp & bMask) && mask >= b.mask) { - return { prefix: prefix, is_bogon: true, reason: "Matches bogon " + b.net + "/" + b.mask }; - } - } - return { prefix: prefix, is_bogon: false }; - } - - function checkBogonAsn(asnNum) { - if (asnNum === 0 || asnNum === 23456 || asnNum === 65535) return true; - if (asnNum >= 64496 && asnNum <= 64511) return true; - if (asnNum >= 64512 && asnNum <= 65534) return true; - return false; - } - - var bogonPrefixResults = allPrefixes.map(checkBogonPrefix); - var bogonPrefixes = bogonPrefixResults.filter(function(r) { return r.is_bogon; }); - var asnInPaths = neighbours.map(function(n) { return n.asn; }); - var bogonAsns = asnInPaths.filter(checkBogonAsn); - // "pass" must mean prefixes/neighbours were actually fetched and checked clean -- - // not that the upstream source failed and left nothing to check. A source is - // unavailable if it's falsy (fetchRipeStatCached network failure) OR if it's a - // truthy object with status:"error" (local-db-client.js's DB-error signal -- - // see the 2026-07-16 audit; that object is NOT null, so a plain `!x` check - // alone misses this case and would show "pass" on 0 checked prefixes). - function isSourceUnavailable(x) { return !x || x.status === "error"; } - var prefixesUnavailable = isSourceUnavailable(prefixData); - var neighboursUnavailable = isSourceUnavailable(neighbourData); - var bogonDataUnavailable = prefixesUnavailable && neighboursUnavailable; - var bogonResult = { - status: bogonDataUnavailable ? "info" : (bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail"), - bogon_prefixes: bogonPrefixes, - bogon_asns_in_paths: bogonAsns, - total_prefixes_checked: allPrefixes.length, - prefixes_source_unavailable: prefixesUnavailable, - neighbours_source_unavailable: neighboursUnavailable, - }; - - // Phase 2: All API-dependent validations in parallel - var validationPromises = {}; - - // 12. IRR Validation - // fetchJSON resolves(null) on timeout/network-error/parse-failure rather than - // rejecting, so the .catch() below never sees the common failure mode -- a - // dead upstream would otherwise silently read as "0 entries, no mismatches, pass". - validationPromises.irr = fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) { - if (irrData === null) { - return { status: "info", message: "IRR Explorer unavailable (fetch failed)", total_entries: 0, mismatches: [], mismatch_count: 0 }; - } - var entries = Array.isArray(irrData) ? irrData : []; - var mismatches = entries.filter(function(e) { - if (!e.bgpOrigins && !e.bgp_origins) return false; - if (!e.irrRoutes && !e.irr_origins) return false; - var bgpArr = e.bgpOrigins || e.bgp_origins || []; - var irrArr = e.irrRoutes || e.irr_origins || []; - var bgpSet = {}; - bgpArr.forEach(function(a) { bgpSet[String(typeof a === "object" ? a.asn : a)] = true; }); - var match = false; - irrArr.forEach(function(a) { if (bgpSet[String(typeof a === "object" ? a.asn : a)]) match = true; }); - return Object.keys(bgpSet).length > 0 && irrArr.length > 0 && !match; - }); - return { - status: mismatches.length === 0 ? "pass" : "warning", - total_entries: entries.length, - mismatches: mismatches.slice(0, 10).map(function(e) { return { prefix: e.prefix, bgp_origins: e.bgpOrigins || e.bgp_origins, irr_origins: e.irrRoutes || e.irr_origins }; }), - mismatch_count: mismatches.length, - }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 13. RPKI ROA Completeness (local validation against Cloudflare RPKI feed - all RIRs) - await ensureAspaCache(); // Ensure ROA data is loaded - validationPromises.rpki_completeness = Promise.all( - allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); }) - ).then(function(rpkiResults) { - var checkedResults = rpkiResults.filter(function(r) { return r.status !== "unavailable"; }); - var unavailableCount = rpkiResults.length - checkedResults.length; - var withRoa = checkedResults.filter(function(r) { return r.status === "valid"; }); - var coverage = checkedResults.length > 0 ? Math.round((withRoa.length / checkedResults.length) * 100) : 0; - var overSpecific = checkedResults.filter(function(r) { - var mask = parseInt((r.prefix || "").split("/")[1] || "0"); - return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid"; - }); - // If the local RPKI DB was unreachable for every prefix, we haven't actually - // checked anything -- say so instead of reporting a misleading pass/fail. - var allUnavailable = rpkiResults.length > 0 && checkedResults.length === 0; - return { - status: allUnavailable ? "info" : coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail", - coverage_pct: coverage, - total_checked: checkedResults.length, - db_unavailable_count: unavailableCount, - with_roa: withRoa.length, - over_specific: overSpecific.map(function(r) { return r.prefix; }), - details: rpkiResults, - }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 14. Abuse Contact Validation - validationPromises.abuse_contact = fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) { - if (data === null) return { status: "info", message: "abuse-contact-finder unavailable (fetch failed)", contacts: [], has_valid_email: false }; - var contacts = data && data.data && data.data.abuse_contacts ? data.data.abuse_contacts : []; - var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); }); - return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 15. Spamhaus DROP/Blocklist - validationPromises.blocklist = Promise.all( - samplePrefixes.slice(0, 5).map(function(pfx) { - return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) { - // fetchRipeStatCached resolves(null) on timeout/network-error rather than - // throwing, so the .catch() below never sees that case -- distinguish it - // explicitly instead of letting a failed lookup read as "not listed". - if (data === null) return { prefix: pfx, listed: false, checked: false }; - var sources = data && data.data && data.data.sources ? data.data.sources : []; - var listed = sources.filter(function(s) { return s.prefix_count > 0 || (s.entries && s.entries.length > 0); }); - return { prefix: pfx, listed: listed.length > 0, checked: true, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) }; - }).catch(function() { return { prefix: pfx, listed: false, checked: false }; }); - }) - ).then(function(results) { - var checkedResults = results.filter(function(r) { return r.checked; }); - var listedPrefixes = checkedResults.filter(function(r) { return r.listed; }); - var allUnchecked = results.length > 0 && checkedResults.length === 0; - return { - status: allUnchecked ? "info" : (listedPrefixes.length === 0 ? "pass" : "fail"), - checked: checkedResults.length, - unavailable: results.length - checkedResults.length, - listed_prefixes: listedPrefixes, - }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 16. MANRS Compliance — scraped from public participants list (24h cache) - validationPromises.manrs = ensureManrsCache().then(function() { - return checkManrsMembership(rawAsn); - }).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable: " + e.message, note: "https://www.manrs.org/netops/participants/" }; }); - - // 17. Reverse DNS Coverage (3 prefix sample — more causes semaphore starvation on large ASNs) - var rdnsSampleSize = Math.min(3, samplePrefixes.length); - validationPromises.rdns = Promise.all( - samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) { - return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) { - if (data === null) return { prefix: pfx, has_rdns: false, details: [], error: true }; - var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {}; - var hasDelegation = false; - var details = []; - // API returns { ipv4: { "prefix": { complete, domains } }, ipv6: { ... } } - ["ipv4", "ipv6"].forEach(function(af) { - var afData = pfxData[af] || {}; - Object.keys(afData).forEach(function(p) { - var entry = afData[p]; - if (entry && entry.complete) hasDelegation = true; - if (entry && entry.domains) { - entry.domains.forEach(function(d) { - if (d.found) hasDelegation = true; - details.push({ domain: d.domain, found: !!d.found }); - }); - } - }); - }); - // Fallback: old array format - if (Array.isArray(pfxData)) { - pfxData.forEach(function(p) { - if (p.ipv4 || p.ipv6 || (p.delegations && p.delegations.length > 0)) hasDelegation = true; - }); - } - return { prefix: pfx, has_rdns: hasDelegation, details: details }; - }).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; }); - }) - ).then(function(results) { - var checkedResults = results.filter(function(r) { return !r.error; }); - var withRdns = checkedResults.filter(function(r) { return r.has_rdns; }); - var coverage = checkedResults.length > 0 ? Math.round((withRdns.length / checkedResults.length) * 100) : 0; - var failedPrefixes = checkedResults.filter(function(r) { return !r.has_rdns; }).map(function(r) { return r.prefix; }); - var allUnavailable = results.length > 0 && checkedResults.length === 0; - return { - status: allUnavailable ? "info" : (coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail"), - coverage_pct: coverage, - checked: checkedResults.length, - unavailable: results.length - checkedResults.length, - results: results, - failed_prefixes: failedPrefixes, - }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 18. BGP Visibility (uses routing-status API which is more reliable than visibility API) - validationPromises.visibility = fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 8000 }).then(function(rsData) { - var vis = rsData && rsData.data && rsData.data.visibility ? rsData.data.visibility : {}; - var v4 = vis.v4 || {}; - var v6 = vis.v6 || {}; - var totalPeers = (v4.total_ris_peers || 0) + (v6.total_ris_peers || 0); - var seeingPeers = (v4.ris_peers_seeing || 0) + (v6.ris_peers_seeing || 0); - var score = totalPeers > 0 ? Math.round((seeingPeers / totalPeers) * 100) : 0; - var observedNeighbours = rsData && rsData.data ? (rsData.data.observed_neighbours || 0) : 0; - // If routing-status returned no data, try bgproutes.io - if (totalPeers === 0 && samplePrefixes[0]) { - return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) { - if (bgprFb && bgprFb.vps_seeing > 0) { - seeingPeers = bgprFb.vps_seeing; - totalPeers = Math.max(bgprFb.vps_seeing, 300); - score = Math.round((seeingPeers / totalPeers) * 100); - } - return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "bgproutes.io_fallback" }; - }).catch(function() { - // Neither RIPE routing-status nor the bgproutes.io fallback returned data -- - // this network hasn't actually been measured, not confirmed at 0% visibility. - return { status: "info", message: "visibility could not be measured (both data sources unavailable)", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" }; - }); - } - return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "ripe_routing_status" }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 19. BGP Communities Analysis - validationPromises.communities = (samplePrefixes.length > 0 - ? (function() { - var now = new Date(); - var end = now.toISOString().replace(/\.\d+Z/, ""); - var startTime = new Date(now.getTime() - 3600000).toISOString().replace(/\.\d+Z/, ""); - return fetchRipeStatCached("https://stat.ripe.net/data/bgp-updates/data.json?resource=" + encodeURIComponent(samplePrefixes[0]) + "&starttime=" + startTime + "&endtime=" + end); - })() - : Promise.resolve(null) - ).then(function(data) { - var updates = data && data.data && data.data.updates ? data.data.updates : []; - var communityMap = {}; - var wellKnown = { "65535:0": "GRACEFUL_SHUTDOWN", "65535:65281": "NO_EXPORT", "65535:65282": "NO_ADVERTISE", "65535:666": "BLACKHOLE" }; - updates.forEach(function(u) { - var attrs = u.attrs || {}; - var communities = attrs.community || []; - communities.forEach(function(c) { - var key = Array.isArray(c) ? c.join(":") : String(c); - if (!communityMap[key]) communityMap[key] = { community: key, count: 0, well_known: wellKnown[key] || null }; - communityMap[key].count++; - }); - }); - var sorted = Object.values(communityMap).sort(function(a, b) { return b.count - a.count; }); - var hasBlackhole = sorted.some(function(c) { return c.well_known === "BLACKHOLE"; }); - return { status: hasBlackhole ? "warning" : "pass", total_updates: updates.length, unique_communities: sorted.length, top_communities: sorted.slice(0, 20), well_known_detected: sorted.filter(function(c) { return c.well_known; }) }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 20. Geolocation Verification - validationPromises.geolocation = (samplePrefixes.length > 0 - ? fetchRipeStatCached("https://stat.ripe.net/data/maxmind-geo-lite-pfx/data.json?resource=" + encodeURIComponent(samplePrefixes[0])) - : Promise.resolve(null) - ).then(function(data) { - var locatedPfxs = data && data.data && data.data.located_resources ? data.data.located_resources : []; - var countries = {}; - locatedPfxs.forEach(function(l) { var locs = l.locations || []; locs.forEach(function(loc) { if (loc.country) countries[loc.country] = true; }); }); - return { status: Object.keys(countries).length > 0 ? "pass" : "warning", geo_countries: Object.keys(countries), sample_prefix: samplePrefixes[0] || null, located_resources: locatedPfxs.length }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 21. RPSL/IRR Object Validation (query all 5 RIRs in parallel) - validationPromises.rpsl = (function() { - // Try RIPE first (has richest policy data), then RDAP for other RIRs - var ripePromise = fetchJSON("https://rest.db.ripe.net/lookup/ripe/aut-num/AS" + rawAsn + ".json", { timeout: 5000 }).then(function(data) { - var objects = data && data.objects && data.objects.object ? data.objects.object : []; - if (objects.length === 0) return null; - var attrs = objects[0] && objects[0].attributes && objects[0].attributes.attribute ? objects[0].attributes.attribute : []; - var hasImport = attrs.some(function(a) { return a.name === "import" || a.name === "mp-import"; }); - var hasExport = attrs.some(function(a) { return a.name === "export" || a.name === "mp-export"; }); - var hasRemarks = attrs.some(function(a) { return a.name === "remarks"; }); - return { status: (hasImport || hasExport) ? "pass" : "warning", exists: true, has_import: hasImport, has_export: hasExport, has_remarks: hasRemarks, has_policy: hasImport || hasExport, source: "RIPE" }; - }).catch(function() { return null; }); - - var rdapEndpoints = [ - { name: "APNIC", url: "https://rdap.apnic.net/autnum/" + rawAsn }, - { name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + rawAsn }, - { name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + rawAsn }, - { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + rawAsn }, - ]; - var rdapPromises = rdapEndpoints.map(function(ep) { - return fetchJSON(ep.url, { timeout: 5000 }).then(function(data) { - if (!data || data.errorCode || !data.handle) return null; - var hasRemarks = !!(data.remarks && data.remarks.length > 0); - var name = data.name || ""; - return { status: hasRemarks ? "pass" : "warning", exists: true, has_import: false, has_export: false, has_remarks: hasRemarks, has_policy: false, source: ep.name, rdap_name: name, rdap_handle: data.handle || "" }; - }).catch(function() { return null; }); - }); - - return Promise.all([ripePromise].concat(rdapPromises)).then(function(results) { - // Take first successful result - for (var ri = 0; ri < results.length; ri++) { - if (results[ri] !== null) return results[ri]; - } - return { status: "warning", exists: false, has_policy: false }; - }); - })(); - - // 22. IXP Route Server Participation (Bug 5 fix: fair scoring for bilateral peering) - // Always use asn= for netixlan (more reliable than net_id when PDB rate-limits) - var ixRsQueryUrl = "/netixlan?asn=" + rawAsn; - { - validationPromises.ix_route_server = fetchPeeringDB(ixRsQueryUrl).then(function(ixData) { - var connections = ixData && ixData.data ? ixData.data : []; - var rsParticipants = connections.filter(function(c) { return c.is_rs_peer === true; }); - var totalIx = connections.length; - var rsCount = rsParticipants.length; - var rsPct = totalIx > 0 ? Math.round((rsCount / totalIx) * 100) : 0; - var status, note; - - if (totalIx > 0 && rsCount > 0) { - // Using route servers - good - status = "pass"; - note = rsCount + " of " + totalIx + " IX connections use route servers (" + rsPct + "%)"; - } else if (totalIx >= 10 && rsCount === 0) { - // Network with 10+ IX connections but no RS = deliberate bilateral peering policy - status = "pass"; - note = "Bilateral peering policy — " + totalIx + " IX connections, all bilateral (no route server usage)"; - } else if (totalIx < 3 && rsCount === 0) { - // Very small IX presence and no RS - status = "warning"; - note = "Only " + totalIx + " IX connection(s) and no route server usage"; - } else { - // Small-medium network (3-9 IX) without RS - informational - status = "info"; - note = totalIx + " IX connections without route server usage — consider enabling RS for broader reachability"; - } - - return { status: status, total_ix_connections: totalIx, rs_peer_count: rsCount, rs_peer_pct: rsPct, note: note }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - } - - // 23. Resource Certification (local RPKI validation - all prefixes, all RIRs) - validationPromises.resource_cert = Promise.all( - allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); }) - ).then(function(results) { - var checked = results.filter(function(r) { return r.status !== "unavailable"; }); - var hasRoa = checked.some(function(r) { return r.status === "valid" || r.validating_roas > 0; }); - var allUnavailable = results.length > 0 && checked.length === 0; - return { status: allUnavailable ? "info" : hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: checked.length, roa_count: checked.filter(function(r) { return r.status === "valid"; }).length }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // Geolocation cross-ref with PeeringDB facilities - var facCountriesPromise = netId - ? fetchPeeringDB("/netfac?net_id=" + netId).then(function(facData) { - return (facData && facData.data ? facData.data : []).map(function(f) { return f.country; }).filter(Boolean); - }).catch(function() { return []; }) - : Promise.resolve([]); - - // Run all validations in parallel — 5s cap per check, total validate bounded to ~10s - var keys = Object.keys(validationPromises); - var promises = keys.map(function(k) { - return Promise.race([ - validationPromises[k], - new Promise(function(resolve) { setTimeout(function() { resolve({ status: "info", message: "timed out" }); }, 5000); }), - ]); - }); - var settled = await Promise.allSettled(promises); - var facCountries = await facCountriesPromise; - - var validations = {}; - keys.forEach(function(key, i) { - if (settled[i].status === "fulfilled") { - validations[key] = settled[i].value; - } else { - validations[key] = { status: "error", error: settled[i].reason ? String(settled[i].reason) : "Unknown error" }; - } - }); - - // Enrich geolocation (Bug 4 fix: handle anycast/CDN/global networks) - if (validations.geolocation && validations.geolocation.status !== "error") { - var uniqueFacCountries = {}; - facCountries.forEach(function(c) { uniqueFacCountries[c] = true; }); - var facCountryCount = Object.keys(uniqueFacCountries).length; - validations.geolocation.pdb_facility_countries = Object.keys(uniqueFacCountries); - var geoSet = {}; - (validations.geolocation.geo_countries || []).forEach(function(c) { geoSet[c] = true; }); - var geoCountryCount = Object.keys(geoSet).length; - var mismatches = Object.keys(geoSet).filter(function(c) { return !uniqueFacCountries[c] && facCountryCount > 0; }); - validations.geolocation.country_mismatches = mismatches; - - // Detect global/anycast networks: 5+ facility countries OR Content/NSP type - var netInfoType = (net.info_type || "").toLowerCase(); - var isGlobalNetwork = facCountryCount >= 5 || netInfoType === "content" || netInfoType === "nsp"; - if (isGlobalNetwork) { - // Global/anycast/CDN network: geo mismatches are expected, not anomalies - validations.geolocation.status = "pass"; - if (geoCountryCount === 0) { - validations.geolocation.note = "Global network (" + facCountryCount + " countries, type: " + (net.info_type || "N/A") + ") - no MaxMind geolocation data available"; - } else { - validations.geolocation.note = "Global/anycast network - multi-country presence expected (" + facCountryCount + " facility countries, type: " + (net.info_type || "N/A") + ")"; - } - validations.geolocation.country_mismatches = []; - } else if (facCountryCount <= 2 && geoCountryCount >= 10) { - // Actual anomaly: small network appearing in many countries - validations.geolocation.status = "warning"; - validations.geolocation.note = "Prefixes geolocated in " + geoCountryCount + " countries but only " + facCountryCount + " facility countries - possible hijack or misconfiguration"; - } - } - - validations.bogon = bogonResult; - - // Calculate overall health score (0-100) - var checks = [ - { key: "bogon", weight: 15 }, - { key: "irr", weight: 10 }, - { key: "rpki_completeness", weight: 15 }, - { key: "abuse_contact", weight: 5 }, - { key: "blocklist", weight: 15 }, - { key: "manrs", weight: 5 }, - { key: "rdns", weight: 5 }, - { key: "visibility", weight: 10 }, - { key: "rpsl", weight: 5 }, - { key: "ix_route_server", weight: 5 }, - { key: "resource_cert", weight: 10 }, - ]; - - var totalWeight = 0; - var earnedScore = 0; - var checkResults = []; - - checks.forEach(function(c) { - var v = validations[c.key]; - var points = 0; - if (v && v.status === "info") { - // "info" = unable to verify (e.g. API auth required) — exclude from scoring - checkResults.push({ check: c.key, weight: c.weight, earned: 0, status: "info" }); - return; - } - if (v && v.status === "pass") points = c.weight; - else if (v && v.status === "warning") points = Math.round(c.weight * 0.5); - totalWeight += c.weight; - earnedScore += points; - checkResults.push({ check: c.key, weight: c.weight, earned: points, status: v ? v.status : "error" }); - }); - - var healthScore = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0; - var duration = Date.now() - start; - - // Build relationships from neighbour data - var relNeighbours = neighbourData && neighbourData.data && neighbourData.data.neighbour_counts - ? neighbourData.data.neighbour_counts : {}; - var relList = neighbourData && neighbourData.data && neighbourData.data.neighbours - ? neighbourData.data.neighbours : []; - var relUpstreams = relList.filter(function(n) { return n.type === "left"; }) - .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) - .slice(0, 20) - .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); - var relDownstreams = relList.filter(function(n) { return n.type === "right"; }) - .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) - .slice(0, 20) - .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); - var relPeers = relList.filter(function(n) { return n.type === "uncertain"; }) - .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) - .slice(0, 30) - .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); - - const validateResult = { - meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString(), total_prefixes: allPrefixes.length, prefixes_sampled: samplePrefixes.length }, - asn: targetAsn, - name: net.name || (overviewData && overviewData.data ? overviewData.data.holder : "") || "Unknown", - health_score: healthScore, - score_breakdown: checkResults, - validations: validations, - relationships: { - counts: { upstreams: relNeighbours.left || relUpstreams.length, downstreams: relNeighbours.right || relDownstreams.length, peers: relNeighbours.unique || relPeers.length, uncertain: relNeighbours.uncertain || 0 }, - upstreams: relUpstreams, - downstreams: relDownstreams, - top_peers: relPeers, - source: "RIPE Stat asn-neighbours", - note: "left=upstream providers, right=downstream customers, uncertain=peers. Sorted by power score.", - }, - }; - // Cache 0-prefix results only briefly (90s) — they may be due to temporary API failures - // Full results with prefixes are cached for the standard 15 minutes - const validateCacheTTL = allPrefixes.length === 0 ? 90 * 1000 : RESULT_CACHE_TTL; - resultCacheSet(validateResultCache, rawAsn, validateResult, validateCacheTTL); - return res.end(JSON.stringify(validateResult, null, 2)); - } catch (err) { - res.writeHead(500); - return res.end(JSON.stringify({ error: "Validation failed", message: err.message })); - } + return validateRoute.handleValidate(req, res, url); } - // ============================================================ // Main lookup endpoint: /api/lookup?asn=X - // ============================================================ if (reqPath === "/api/lookup") { - const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); - if (!rawAsn) { - res.writeHead(400); - return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); - } - const asn = rawAsn; - const cacheKey = "lookup:" + asn; - const cached = cacheGet(cacheKey); - if (cached) { - res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); - return res.end(JSON.stringify(cached)); - } - const start = Date.now(); - - try { - // Phase 0: Get PDB net first — check L2 cache, then API with retry - let pdbNet = pdbSourceCache.get("net", asn); - if (!pdbNet) { - pdbNet = await fetchPeeringDBWithRetry("/net?asn=" + asn); - if (pdbNet) pdbSourceCache.set("net", asn, pdbNet); - } - const net = pdbNet?.data?.[0] || {}; - const netId = net.id; - - // Phase 1: ALL calls in parallel — RIPE Stat (cached+throttled) + PDB IX/Fac (cached) + Atlas + bgp.he.net - const ixQuery = netId - ? "/netixlan?net_id=" + netId + "&limit=1000" - : "/netixlan?asn=" + asn + "&limit=1000"; - const ixCacheKey = netId ? String(netId) : "asn:" + asn; - - // Check PDB source cache for IX/Fac data - let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey); - let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null; - - // Per-source timing tracking — 9s hard cap per source to prevent long-tail blocking - const sourceTiming = {}; - function timedFetch(name, promise) { - const ts = Date.now(); - return Promise.race([ - Promise.resolve(promise), - new Promise(function(r) { setTimeout(function() { r(null); }, 9000); }), - ]) - .then(function(r) { sourceTiming[name] = Date.now() - ts; return r; }) - .catch(function() { sourceTiming[name] = null; return null; }); - } - - const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null; - - // RDAP: check module-level cache first, only hit RIR endpoints on cache miss - const rdapCached = rdapCacheGet(asn); - const rdapPromise = rdapCached !== undefined - ? Promise.resolve(rdapCached) - : Promise.race([ - ...["https://rdap.db.ripe.net/autnum/"+asn, "https://rdap.arin.net/registry/autnum/"+asn, - "https://rdap.apnic.net/autnum/"+asn, "https://rdap.lacnic.net/rdap/autnum/"+asn, - "https://rdap.afrinic.net/rdap/autnum/"+asn].map(url => - fetchJSON(url, { timeout: 4000 }) - .then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {})) - .catch(() => new Promise(() => {})) - ), - new Promise(resolve => setTimeout(() => resolve(null), 5000)), - ]).then(d => { rdapCacheSet(asn, d); return d; }); - - const promises = [ - timedFetch("RIPE Stat Prefixes", localDb ? localDb.getRipeStatAnnouncedPrefixes(asn) : Promise.resolve(null)), - timedFetch("RIPE Stat Neighbours", localDb ? localDb.getRipeStatAsnNeighbours(asn) : Promise.resolve(null)), - timedFetch("RIPE Stat Overview", localDb ? localDb.getRipeStatAsOverview(asn) : Promise.resolve(null)), - timedFetch("RIPE Stat RIR", Promise.resolve(null)), - timedFetch("RIPE Atlas", Promise.resolve(null)), - timedFetch("bgp.he.net", Promise.resolve(null)), - timedFetch("RIPE Stat Visibility", localDb ? localDb.getRipeStatVisibility(asn) : Promise.resolve(null)), - timedFetch("RIPE Stat PrefixSize", localDb ? localDb.getRipeStatPrefixSizeDistribution(asn) : Promise.resolve(null)), - timedFetch("PeeringDB IXLan", cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery)), - timedFetch("PeeringDB Facilities", cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))), - timedFetch("PeeringDB Contacts", pocQuery ? fetchPeeringDB(pocQuery).catch(() => null) : Promise.resolve(null)), - timedFetch("RDAP Registration", rdapPromise), - ]; - const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData, pocData, rdapData] = await Promise.all(promises); - - // Store PDB results in L2 source cache for future lookups - if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData); - if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData); - - // local-db-client's getRipeStat* functions return status:"error" (not "ok") when - // the local Postgres DB query itself failed -- track which sources actually - // failed so we don't cache a DB hiccup's fake-empty result as if it were a - // genuine "this ASN has 0 prefixes" answer for the full 5-minute TTL. - const degradedSources = [ - ["RIPE Stat Prefixes", prefixData], ["RIPE Stat Neighbours", neighbourData], - ["RIPE Stat Overview", overviewData], ["RIPE Stat Visibility", visibilityData], - ["RIPE Stat PrefixSize", prefixSizeData], - ].filter(([, v]) => v && v.status === "error").map(([name]) => name); - - const prefixes = prefixData?.data?.prefixes || []; - const neighbours = neighbourData?.data?.neighbours || []; - const overview = overviewData?.data || {}; - const rirEntries = rirData?.data?.located_resources || rirData?.data?.rir_stats || []; - - // Bug 6 fix: Atlas probe status uses status.name (object), not status_name (flat) - const atlasProbes = atlasProbeData?.results || []; - const atlasConnected = atlasProbes.filter(p => { - const sName = (p.status_name || (p.status && p.status.name) || "").toLowerCase(); - return sName === "connected"; - }); - const atlasAnchors = atlasProbes.filter(p => p.is_anchor === true); - - // RPKI: validate ALL prefixes using local Cloudflare RPKI data (all 5 RIRs, instant) - await ensureAspaCache(); - const allPrefixes = prefixes.map((p) => p.prefix); - const rpkiAllResults = await Promise.all(allPrefixes.map((pfx) => validateRPKIWithCache(asn, pfx))); - - const ixConnections = (ixlanData?.data || []) - .map((ix) => ({ - ix_name: ix.name || "", - ix_id: ix.ix_id, - speed_mbps: ix.speed || 0, - ipv4: ix.ipaddr4 || null, - ipv6: ix.ipaddr6 || null, - city: ix.city || "", - is_rs_peer: ix.is_rs_peer === true, - })) - .sort((a, b) => b.speed_mbps - a.speed_mbps); - - const facilitiesRaw = (facData?.data || []).map((f) => ({ - fac_id: f.fac_id, - name: f.name || "", - city: f.city || "", - country: f.country || "", - })); - - // Batch-fetch facility coordinates for map (max 50 facilities) - const facIds = facilitiesRaw.map(f => f.fac_id).filter(Boolean).slice(0, 50); - let facCoordMap = {}; - if (facIds.length > 0) { - try { - const chunks = []; - for (let i = 0; i < facIds.length; i += 25) chunks.push(facIds.slice(i, i + 25)); - const coordResults = await Promise.race([ - Promise.all(chunks.map(chunk => - fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null) - )), - new Promise(r => setTimeout(() => r([]), 5000)) - ]); - (coordResults || []).forEach(res => { - (res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; }); - }); - } catch(e) { /* graceful degradation */ } - } - const facilities = facilitiesRaw.map(f => ({ - ...f, - latitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lat : null, - longitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lon : null, - })); - - // Get IX locations for map via ixfac -> fac coordinates (max 20 IXs) - const uniqueIxIds = [...new Set(ixConnections.map(c => c.ix_id))].filter(Boolean).slice(0, 20); - let ixLocations = []; - if (uniqueIxIds.length > 0) { - try { - const ixFacData = await Promise.race([ - fetchPeeringDB("/ixfac?ix_id__in=" + uniqueIxIds.join(",")), - new Promise(r => setTimeout(() => r(null), 5000)) - ]); - const ixFacs = ixFacData?.data || []; - // Collect unique fac_ids we don't already have coords for - const extraFacIds = [...new Set(ixFacs.map(f => f.fac_id).filter(id => id && !facCoordMap[id]))].slice(0, 30); - if (extraFacIds.length > 0) { - const extraChunks = []; - for (let i = 0; i < extraFacIds.length; i += 25) extraChunks.push(extraFacIds.slice(i, i + 25)); - const extraRes = await Promise.race([ - Promise.all(extraChunks.map(chunk => - fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null) - )), - new Promise(r => setTimeout(() => r([]), 4000)) - ]); - (extraRes || []).forEach(res => { - (res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; }); - }); - } - // Build IX locations: pick first facility with coords per IX - const ixNameMap = {}; - ixConnections.forEach(c => { if (c.ix_id && c.ix_name) ixNameMap[c.ix_id] = c.ix_name; }); - const seenIx = {}; - ixFacs.forEach(f => { - if (seenIx[f.ix_id]) return; - const coords = facCoordMap[f.fac_id]; - if (coords) { - seenIx[f.ix_id] = true; - ixLocations.push({ ix_id: f.ix_id, name: ixNameMap[f.ix_id] || f.name || "", city: f.city || "", country: f.country || "", latitude: coords.lat, longitude: coords.lon }); - } - }); - } catch(e) { /* graceful degradation */ } - } - - const rpkiStatuses = rpkiAllResults; - const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length; - const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length; - const rpkiUnavailable = rpkiStatuses.filter((r) => r.status === "unavailable").length; - const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid" && r.status !== "unavailable").length; - // Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup - // must not lower a network's displayed RPKI coverage percentage. - const rpkiTotal = rpkiStatuses.length - rpkiUnavailable; - const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0; - - let upstreams = neighbours - .filter((n) => n.type === "left") - .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 })) - .sort((a, b) => b.power - a.power); - let downstreams = neighbours - .filter((n) => n.type === "right") - .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 })) - .sort((a, b) => b.power - a.power); - let peers = neighbours - .filter((n) => n.type === "uncertain" || n.type === "peer") - .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 })) - .sort((a, b) => b.power - a.power); - - // Resolve empty AS names — all in parallel, with 3s timeout - const emptyNameNeighbours = [...upstreams, ...downstreams, ...peers].filter(n => !n.name); - if (emptyNameNeighbours.length > 0) { - const resolvePromise = Promise.all( - emptyNameNeighbours.map(n => - fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 }) - .then(r => { if (r?.data?.holder) n.name = r.data.holder; }) - .catch(() => {}) - ) - ); - await Promise.race([resolvePromise, new Promise(r => setTimeout(r, 3000))]); - } - - // ---- Threat Intelligence Enrichment for Neighbors ---- - // Enrich neighbor data with threat status from local threat_intel table - const threatEnrichNeighbors = async (neighbors) => { - const allNeighbors = [...neighbors]; - const threatMap = {}; - - // Batch threat intel lookups (cap at 50 to avoid overwhelming DB) - const toCheck = allNeighbors.slice(0, 50); - const threatPromises = toCheck.map(async (n) => { - try { - // Try to get threat intel by AS number or typical AS IP pattern - // For now, we'll mark neighbors without direct IP threat data - const asNum = String(n.asn); - const threat = await localDb.getThreatIntel(asNum); - if (threat) { - threatMap[n.asn] = { - threat_level: threat.threat_level, - confidence_score: threat.confidence_score, - source: threat.source, - cached_at: threat.cached_at, - }; - } - } catch (e) { - // Gracefully skip on error - console.error(`[Threat Lookup] Error checking ASN ${n.asn}:`, e.message); - } - }); - - // Run threat lookups with 4s timeout - await Promise.race([ - Promise.all(threatPromises), - new Promise(r => setTimeout(r, 4000)), - ]); - - return threatMap; - }; - - const threatMap = await threatEnrichNeighbors([...upstreams, ...downstreams, ...peers]); - - // Attach threat status to neighbor objects - const addThreatToNeighbor = (n) => ({ - ...n, - threat_level: threatMap[n.asn]?.threat_level || null, - threat_confidence: threatMap[n.asn]?.confidence_score || null, - threat_source: threatMap[n.asn]?.source || null, - }); - - upstreams = upstreams.map(addThreatToNeighbor); - downstreams = downstreams.map(addThreatToNeighbor); - peers = peers.map(addThreatToNeighbor); - - let rir = ""; - let country = ""; - // RIPE Stat rir-stats-country uses 'location' field (not 'country' or 'rir') - if (Array.isArray(rirEntries) && rirEntries.length > 0) { - country = rirEntries[0]?.location || rirEntries[0]?.country || ""; - rir = rirEntries[0]?.rir || ""; - } - if (!rir && rirData?.data) { - const rirField = rirData.data.rirs || []; - if (rirField.length > 0) rir = rirField[0]?.rir || ""; - } - // Derive RIR from rdapData.port43 (e.g. "whois.ripe.net" → "RIPE") - if (!rir && rdapData && rdapData.port43) { - const p43 = (rdapData.port43 || "").toLowerCase(); - if (p43.includes("ripe")) rir = "RIPE"; - else if (p43.includes("arin")) rir = "ARIN"; - else if (p43.includes("apnic")) rir = "APNIC"; - else if (p43.includes("lacnic")) rir = "LACNIC"; - else if (p43.includes("afrinic")) rir = "AFRINIC"; - } - // Also derive RIR from RDAP links (URL of the RDAP endpoint that responded) - if (!rir && rdapData && rdapData.links) { - const selfLink = (rdapData.links.find(l => l.rel === "self") || {}).href || ""; - if (selfLink.includes("ripe")) rir = "RIPE"; - else if (selfLink.includes("arin")) rir = "ARIN"; - else if (selfLink.includes("apnic")) rir = "APNIC"; - else if (selfLink.includes("lacnic")) rir = "LACNIC"; - else if (selfLink.includes("afrinic")) rir = "AFRINIC"; - } - // bgp.he.net country_code fallback (for unannounced/reserve ASNs) - if (!country && bgpHeData && bgpHeData.country_code) { - country = bgpHeData.country_code; - } - // Last resort: derive RIR from country code (common assignments) - if (!rir && country) { - const ARIN_CC = 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: null, disagreements: [], sample_size: 0, comparable_size: 0 }; - let prefixCrossCheck = { ripe_stat: prefixes.length, bgp_he_net: null, agreement: null, note: "" }; - let neighbourCrossCheck = { ripe_stat_total: neighbours.length, bgp_he_net_total: null }; - - try { - // RPKI cross-check: sample up to 5 prefixes against RIPE Validator (with 8s total timeout) - const rpkiCrossPromise = crossCheckRpki(asn, allPrefixes, rpkiStatuses); - const rpkiCrossResult = await Promise.race([ - rpkiCrossPromise, - new Promise((resolve) => setTimeout(() => resolve(null), 8000)), - ]); - if (rpkiCrossResult) rpkiCrossCheck = rpkiCrossResult; - } catch (_e) { /* cross-check failed, keep defaults */ } - - // bgp.he.net fetching is deliberately disabled (see the hardcoded - // Promise.resolve(null) a few hundred lines up, "bgp.he.net" timedFetch) -- - // bgpHeData is therefore always null and this whole branch, along with the - // prefix/neighbour cross-checks below, never actually runs. Left in place - // (not deleted) in case bgp.he.net fetching is re-enabled later, but - // overall_confidence below must not claim a 2-source agreement that never - // happened -- see the sources:1/2 handling further down. - // Prefix count cross-check: compare RIPE Stat vs bgp.he.net - if (bgpHeData) { - const heV4 = bgpHeData.prefixes_v4 || 0; - const heV6 = bgpHeData.prefixes_v6 || 0; - const heTotal = heV4 + heV6; - if (heTotal > 0) { - prefixCrossCheck.bgp_he_net = heTotal; - const ripeStat = prefixes.length; - if (ripeStat > 0 && heTotal > 0) { - const ratio = Math.min(ripeStat, heTotal) / Math.max(ripeStat, heTotal); - prefixCrossCheck.agreement = ratio >= 0.9; - const diff = Math.abs(ripeStat - heTotal); - prefixCrossCheck.note = diff === 0 - ? "Exact match" - : "Difference of " + diff + " prefixes (" + Math.round((1 - ratio) * 100) + "% divergence)"; - } - } else { - prefixCrossCheck.note = "bgp.he.net prefix count unavailable"; - } - - // Neighbour cross-check: compare RIPE Stat vs bgp.he.net peer_count - if (bgpHeData.peer_count != null) { - neighbourCrossCheck.bgp_he_net_total = bgpHeData.peer_count; - } - } else { - prefixCrossCheck.note = "bgp.he.net data unavailable"; - } - - // Compute overall data quality. Only push a score for a cross-check that - // actually ran (agreement_pct !== null) -- an unrun/inconclusive check must - // not silently count as a perfect 100 in the average, and confidence must - // reflect "nothing was actually cross-checked" rather than defaulting high. - const crossCheckScores = []; - if (rpkiCrossCheck.agreement_pct !== null) crossCheckScores.push(rpkiCrossCheck.agreement_pct); - // Prefix agreement: convert to percentage - if (prefixCrossCheck.bgp_he_net != null && prefixes.length > 0) { - const pfxRatio = Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net); - crossCheckScores.push(Math.round(pfxRatio * 100)); - } - // Neighbour agreement - if (neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0) { - const nbrRatio = Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total); - crossCheckScores.push(Math.round(nbrRatio * 100)); - } - const avgAgreement = crossCheckScores.length > 0 - ? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length) - : null; - const overallConfidence = avgAgreement === null ? "unknown" : avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low"; - - const dataQuality = { - sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"], - cross_checks: { - rpki: { sources: rpkiCrossCheck.agreement_pct !== null ? 2 : 1, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, comparable_size: rpkiCrossCheck.comparable_size, disagreements: rpkiCrossCheck.disagreements }, - prefixes: { sources: prefixCrossCheck.bgp_he_net != null ? 2 : 1, agreement_pct: prefixCrossCheck.bgp_he_net != null ? Math.round((Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net || 1)) * 100) : null, ripe_stat: prefixCrossCheck.ripe_stat, bgp_he_net: prefixCrossCheck.bgp_he_net, note: prefixCrossCheck.note }, - neighbours: { sources: neighbourCrossCheck.bgp_he_net_total != null ? 2 : 1, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total }, - }, - overall_confidence: overallConfidence, - overall_agreement_pct: avgAgreement, - }; - - // === IX Location Geocode Fallback === - // Some IXPs have no facility coordinates in PeeringDB. - // Use ix_name city extraction + hard-coded IX→city map as fallback. - var ixIdsWithCoords = new Set(ixLocations.map(function(l) { return l.ix_id; })); - ixConnections.forEach(function(conn) { - if (ixIdsWithCoords.has(conn.ix_id)) return; - var name = conn.ix_name || ""; - if (name) { - var words = name.toLowerCase().replace(/[^a-z\s]/g, " ").split(/\s+/).filter(Boolean); - for (var w = 0; w < words.length; w++) { - if (CITY_COORDS[words[w]]) { - ixLocations.push({ ix_id: conn.ix_id, name: name, city: words[w].charAt(0).toUpperCase() + words[w].slice(1), country: "", latitude: CITY_COORDS[words[w]][0], longitude: CITY_COORDS[words[w]][1], source: "name_geocode" }); - ixIdsWithCoords.add(conn.ix_id); - return; - } - if (w < words.length - 1) { - var tw = words[w] + " " + words[w + 1]; - if (CITY_COORDS[tw]) { - ixLocations.push({ ix_id: conn.ix_id, name: name, city: tw, country: "", latitude: CITY_COORDS[tw][0], longitude: CITY_COORDS[tw][1], source: "name_geocode" }); - ixIdsWithCoords.add(conn.ix_id); - return; - } - } - } - } - }); - // Hard-coded IX ID → city for well-known IXPs whose names don't contain city - var IX_CITY_MAP = { 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, - degraded_sources: degradedSources, - }, - network: { - asn: parseInt(asn), - name: net.name || overview?.holder || (bgpHeData && bgpHeData.name_from_title) || "Unknown", - aka: net.aka || "", - org_name: (net.org && net.org.name) ? net.org.name : "", - website: net.website || "", - type: net.info_type || "", - policy: net.policy_general || "", - traffic: net.info_traffic || "", - ratio: net.info_ratio || "", - scope: net.info_scope || "", - notes: net.notes ? net.notes.substring(0, 500) : "", - peeringdb_id: netId || null, - rir: rir, - country: country, - city: net.city || "", - latitude: (net.latitude != null) ? net.latitude : null, - longitude: (net.longitude != null) ? net.longitude : null, - looking_glass: net.looking_glass || "", - route_server: net.route_server || "", - info_prefixes4: net.info_prefixes4 || 0, - info_prefixes6: net.info_prefixes6 || 0, - status: net.status || "", - peeringdb_created: net.created ? net.created.slice(0, 10) : "", - peeringdb_updated: net.updated ? net.updated.slice(0, 10) : "", - }, - prefixes: { - total: prefixes.length, - ipv4: prefixes.filter((p) => !p.prefix.includes(":")).length, - ipv6: prefixes.filter((p) => p.prefix.includes(":")).length, - list: prefixes.map((p) => p.prefix), - cross_check: prefixCrossCheck, - }, - rpki: { - coverage_percent: rpkiCoverage, - valid: rpkiValid, - invalid: rpkiInvalid, - not_found: rpkiNotFound, - unavailable: rpkiUnavailable, - checked: rpkiTotal, - details: rpkiStatuses, - cross_check: rpkiCrossCheck, - }, - neighbours: { - total: neighbours.length, - upstream_count: upstreams.length, - downstream_count: downstreams.length, - peer_count: peers.length, - upstreams: upstreams.slice(0, 20), - downstreams: downstreams.slice(0, 20), - peers: peers.slice(0, 20), - cross_check: neighbourCrossCheck, - }, - ix_presence: { - total_connections: ixConnections.length, - unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length, - connections: ixConnections, - }, - ix_locations: ixLocations, - facilities: { - total: facilities.length, - list: facilities, - }, - routing: routingInfo, - resilience_score: computeResilienceScore(upstreams, peers, ixConnections, prefixes), - route_leak: computeRouteLeakDetection(upstreams, downstreams, peers), - bgp_he_net: bgpHeData || null, - atlas: { - total_probes: atlasProbes.length, - connected: atlasConnected.length, - disconnected: atlasProbes.length - atlasConnected.length, - anchors: atlasAnchors.length, - probes: atlasProbes.slice(0, 100).map(p => ({ - id: p.id, - status: p.status_name || p.status || "Unknown", - is_anchor: p.is_anchor || false, - country: p.country_code || "", - prefix_v4: p.prefix_v4 || "", - prefix_v6: p.prefix_v6 || "", - description: p.description || "", - })), - }, - data_quality: dataQuality, - source_timing: sourceTiming, - contacts: (() => { - const pocs = (pocData && pocData.data) ? pocData.data : []; - return pocs.slice(0, 20).map(p => ({ - role: p.role || "", - name: p.name || "", - email: p.email || "", - url: p.url || "", - visible: p.visible || "", - })); - })(), - registration: (() => { - const events = (rdapData && rdapData.events) ? rdapData.events : []; - const created = (events.find(e => e.eventAction === "registration") || {}).eventDate || ""; - const lastChg = (events.find(e => e.eventAction === "last changed") || {}).eventDate || ""; - return { - created: created ? created.slice(0, 10) : "", - last_modified: lastChg ? lastChg.slice(0, 10) : "", - rir: rir || "", - handle: (rdapData && rdapData.handle) ? rdapData.handle : ("AS" + asn), - rdap_source: (rdapData && rdapData.port43) ? rdapData.port43 : "", - }; - })(), - _provenance: { - prefixes: { source: "RIPE Stat announced-prefixes", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net prefix count daily" }, - neighbours: { source: "RIPE Stat asn-neighbours", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net peer count daily" }, - rpki: { source: "Cloudflare RPKI + RIPE Validator", validation: "cross-validated", confidence: "high", note: "Two independent RPKI sources compared" }, - ix_presence: { source: "PeeringDB netixlan (local mirror)", validation: "cross-validated", confidence: "high", note: "PeeringDB mirror refreshed daily" }, - facilities: { source: "PeeringDB netfac (local mirror)", validation: "single-source", confidence: "medium" }, - bgp_he_net: { source: "bgp.he.net HTML scrape", validation: "single-source", confidence: "medium", note: "HTML scrape, no official API — may have parsing drift" }, - atlas: { source: "RIPE Atlas API", validation: "single-source", confidence: "medium", note: "Probe availability varies by region" }, - resilience_score: { source: "Computed from RIPE Stat + PeeringDB", validation: "computed", confidence: "high", note: "All inputs cross-validated daily" }, - route_leak: { source: "RIPE Stat asn-neighbours heuristic", validation: "heuristic", confidence: "medium", note: "Pattern-based, not real-time — false positives possible" }, - registration: { source: "RDAP (RIR registry)", validation: "single-source", confidence: "high" }, - contacts: { source: "PeeringDB POC API", validation: "single-source", confidence: "medium", note: "Subject to PeeringDB rate limiting" }, - }, - }; - - // Update duration to include cross-check time - result.meta.duration_ms = Date.now() - start; - - // A DB hiccup shouldn't get cached as this ASN's answer for the full 5min -- - // retry soon instead of repeating a degraded result to every visitor. - cacheSet(cacheKey, result, degradedSources.length > 0 ? CACHE_TTL_LOOKUP_DEGRADED : CACHE_TTL_LOOKUP); - res.end(JSON.stringify(result, null, 2)); - } catch (err) { - const duration = Date.now() - start; - res.writeHead(500); - res.end(JSON.stringify({ error: "Lookup failed", message: err.message, duration_ms: duration })); - } - return; + return lookupRoute.handleLookup(req, res, url); } - // ============================================================ - // AS Relationships endpoint: /api/relationships?asn=X - // Returns upstream providers, downstream customers, and peers - // with resolved names. Based on RIPE Stat asn-neighbours. - // ============================================================ if (reqPath === "/api/relationships") { - const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); - if (!rawAsn) { - res.writeHead(400); - return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); - } - const cacheKey = "relationships:" + rawAsn; - const cached = cacheGet(cacheKey); - if (cached) { - res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); - return res.end(JSON.stringify(cached)); - } - const start = Date.now(); - try { - const neighbourData = await fetchRipeStatCached( - "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn + "&lod=1", - { timeout: 8000 } - ); - const neighbours = (neighbourData && neighbourData.data && neighbourData.data.neighbours) || []; - const counts = (neighbourData && neighbourData.data && neighbourData.data.neighbour_counts) || {}; - - const upstreams = neighbours.filter(n => n.type === "left").sort((a,b) => (b.power||0)-(a.power||0)); - const downstreams = neighbours.filter(n => n.type === "right").sort((a,b) => (b.power||0)-(a.power||0)); - const peers = neighbours.filter(n => n.type === "uncertain").sort((a,b) => (b.power||0)-(a.power||0)); - - // Resolve AS names for top entries (upstreams + downstreams all, top 20 peers) - const toResolve = [...upstreams, ...downstreams, ...peers.slice(0, 20)]; - const resolvedNames = {}; - await Promise.race([ - Promise.all(toResolve.map(n => - fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 }) - .then(r => { if (r && r.data && r.data.holder) resolvedNames[n.asn] = r.data.holder; }) - .catch(() => {}) - )), - new Promise(r => setTimeout(r, 5000)), - ]); - - // ---- Threat Intelligence Enrichment ---- - // Enrich neighbors with threat status from local threat_intel table - const threatMap = {}; - const allNeighborsForThreat = [...upstreams, ...downstreams, ...peers]; - const threatPromises = allNeighborsForThreat.slice(0, 100).map(async (n) => { - try { - const asNum = String(n.asn); - const threat = await localDb.getThreatIntel(asNum); - if (threat) { - threatMap[n.asn] = { - threat_level: threat.threat_level, - confidence_score: threat.confidence_score, - source: threat.source, - }; - } - } catch (e) { - console.error(`[Relationships Threat Lookup] Error checking ASN ${n.asn}:`, e.message); - } - }); - await Promise.race([ - Promise.all(threatPromises), - new Promise(r => setTimeout(r, 3000)), - ]); - - const fmt = n => ({ - asn: n.asn, - name: resolvedNames[n.asn] || "", - power: n.power || 0, - v4_peers: n.v4_peers || 0, - v6_peers: n.v6_peers || 0, - threat_level: threatMap[n.asn]?.threat_level || null, - threat_confidence: threatMap[n.asn]?.confidence_score || null, - threat_source: threatMap[n.asn]?.source || null, - }); - - const result = { - asn: parseInt(rawAsn), - query_time: new Date().toISOString(), - duration_ms: Date.now() - start, - counts: { - upstreams: counts.left || upstreams.length, - downstreams: counts.right || downstreams.length, - peers_total: counts.unique || peers.length, - uncertain: counts.uncertain || peers.length, - }, - upstreams: upstreams.map(fmt), - downstreams: downstreams.map(fmt), - peers: peers.slice(0, 50).map(fmt), - methodology: "RIPE Stat asn-neighbours API. left=upstream providers (carry your traffic), right=downstream customers (you carry their traffic), uncertain=lateral peers. Sorted by power score (number of prefixes seen via this relationship).", - source_url: "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, - }; - - // A neighbourData fetch failure (not a genuine zero-neighbour ASN) would - // otherwise get cached as "0 upstreams/downstreams/peers" for the full 10min -- - // match the existing /api/validate precedent (90s TTL for suspicious zero counts). - cacheSet(cacheKey, result, neighbourData ? 10 * 60 * 1000 : 90 * 1000); - res.writeHead(200, { "Content-Type": "application/json" }); - return res.end(JSON.stringify(result, null, 2)); - } catch (err) { - res.writeHead(500); - return res.end(JSON.stringify({ error: "Relationships lookup failed", message: err.message })); - } + return relationshipsRoute.handleRelationships(req, res, url); } - // ============================================================ // 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; + return compareRoute.handleCompare(req, res, url); } - - // ============================================================ // 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 })); - } + return quickIxRoute.handleQuickIx(req, res, url); } - // ============================================================ // 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 })); - } + return peersFindRoute.handlePeersFind(req, res, url); } - // ============================================================ // 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 })); - } + return prefixDetailRoute.handlePrefixDetail(req, res, url); } - // ============================================================ // 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 })); - } + return ixDetailRoute.handleIxDetail(req, res, url); } - - // ============================================================ // 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 })); - } + return topologyRoute.handleTopology(req, res, url); } - // ============================================================ // 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 })); - } + return whoisRoute.handleWhoisRoute(req, res, url); } // 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) - // ── Submarine Cables map data ───────────────────────────────── - if (reqPath === '/api/submarine-cables') { - return proxyToApiServer(req, res, req.url); - } - - // ── Global datacenter/IXP map (PeeringDB proxy) ─────────────── - if (reqPath === '/api/global-infra') { - return proxyToApiServer(req, res, req.url); + return enrichRoute.handleEnrich(req, res, url); } - // ── Changelog page ───────────────────────────────────────── - if (reqPath === '/changelog') { - try { - const md = fs.readFileSync('/opt/peercortex-app/CHANGELOG.md', 'utf8'); - const lines = md.split('\n'); - let html = ''; - for (const line of lines) { - if (line.startsWith('## ')) { - html += `<h2 style="font-family:var(--serif);font-size:1.4rem;font-weight:800;margin:2rem 0 .5rem;border-top:2px solid var(--text);padding-top:1rem">${line.slice(3)}</h2>`; - } else if (line.startsWith('### ')) { - html += `<h3 style="font-family:var(--body);font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:1rem 0 .4rem">${line.slice(4)}</h3>`; - } else if (line.startsWith('- **')) { - const m = line.replace(/^- \*\*(.+?)\*\*(.*)$/, '<strong>$1</strong>$2'); - html += `<p style="font-family:var(--body);font-size:.85rem;margin:.2rem 0;padding-left:1rem;border-left:2px solid var(--border)">· ${m}</p>`; - } else if (line.startsWith('- ')) { - html += `<p style="font-family:var(--body);font-size:.82rem;margin:.15rem 0;color:var(--muted);padding-left:1rem">· ${line.slice(2)}</p>`; - } else if (line.startsWith('# ')) { - html += `<h1 style="font-family:var(--serif);font-size:2rem;font-weight:900;margin-bottom:.25rem">${line.slice(2)}</h1>`; - } - } - const page = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>PeerCortex Changelog - - - -← 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'); - } + // Feature 28: Submarine Cable overlay + other already-migrated routes, + // proxied through to the separate Fastify API server. + if (proxyStubsRoute.isProxyStubPath(reqPath)) { + return proxyStubsRoute.handleProxyStub(req, res); } - // ── BGP Community Decoder ──────────────────────────────────── - if (reqPath === '/api/communities') { - return proxyToApiServer(req, res, req.url); + // Changelog page + if (reqPath === "/changelog") { + return changelogRoute.handleChangelog(req, res); } - // ── IRR Audit ───────────────────────────────────────────────── - if (reqPath === '/api/irr-audit') { - return proxyToApiServer(req, res, req.url); + // CORS preflight for all /api/webhooks + /api/hijacks + if (hijackAlertsRoute.matchesHijackGroup(reqPath) && req.method === "OPTIONS") { + return hijackAlertsRoute.handleOptions(req, res); } - // ── AS-SET Expander ─────────────────────────────────────────── - if (reqPath === '/api/asset-expand') { - return proxyToApiServer(req, res, req.url); + // Webhook: Register. POST /api/webhooks?asn=X + if (reqPath === "/api/webhooks" && req.method === "POST") { + return hijackAlertsRoute.handleWebhookRegister(req, res); } - // ── Routing History (prefix table via RIPE Stat routing-history) ── - if (reqPath === '/api/rpki-history') { - return proxyToApiServer(req, res, req.url); + // Webhook: List. GET /api/webhooks?asn=X + if (reqPath === "/api/webhooks" && req.method === "GET") { + return hijackAlertsRoute.handleWebhookList(req, res); } - // ── AS-PATH Visualizer (RIPE Stat looking-glass) ──────────────── - if (reqPath === '/api/aspath') { - return proxyToApiServer(req, res, req.url); + // Webhook: Delete. DELETE /api/webhooks/:id + if (reqPath.startsWith("/api/webhooks/") && req.method === "DELETE" && !reqPath.includes("/test")) { + return hijackAlertsRoute.handleWebhookDelete(req, res, reqPath); } - // ── Looking Glass (RIPE Stat) ───────────────────────────────── - if (reqPath === '/api/looking-glass') { - return proxyToApiServer(req, res, req.url); + // Webhook: Test. POST /api/webhooks/:id/test + if (reqPath.match(/^\/api\/webhooks\/[^/]+\/test$/) && req.method === "POST") { + return hijackAlertsRoute.handleWebhookTest(req, res, reqPath); } - // ── IXP Peering Matrix ──────────────────────────────────────── - if (reqPath === '/api/ix-matrix') { - return proxyToApiServer(req, res, req.url); + // Hijack Events: List. GET /api/hijacks?asn=X&limit=50&resolved=false + if (reqPath === "/api/hijacks" && req.method === "GET") { + return hijackAlertsRoute.handleHijacksList(req, res); } - - // ── CORS preflight for all /api/webhooks + /api/hijacks ────── - if ((reqPath.startsWith('/api/webhooks') || reqPath.startsWith('/api/hijacks') || reqPath === '/api/hijack-subscribe') && req.method === 'OPTIONS') { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - res.writeHead(204); return res.end(); + // Hijack Events: Resolve. POST /api/hijacks/:id/resolve + if (reqPath.match(/^\/api\/hijacks\/[^/]+\/resolve$/) && req.method === "POST") { + return hijackAlertsRoute.handleHijackResolve(req, res, reqPath); } - // ── Webhook: Register ───────────────────────────────────────── - // POST /api/webhooks?asn=X body: { endpoint_url, timeout_ms?, max_retries? } - if (reqPath === '/api/webhooks' && req.method === 'POST') { - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - const params = new URL(req.url, 'http://localhost').searchParams; - const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : ''; - if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn query param required'})); } - let body = ''; - req.on('data', c => body += c); - req.on('end', () => { - try { - const { endpoint_url, timeout_ms, max_retries } = JSON.parse(body || '{}'); - if (!endpoint_url || !endpoint_url.startsWith('http')) { - res.writeHead(400); return res.end(JSON.stringify({error:'endpoint_url required (http/https)'})); - } - const subs = loadWebhookSubs(); - const exists = subs.find(s => s.asn === asn && s.endpoint_url === endpoint_url); - if (exists) { res.writeHead(200); return res.end(JSON.stringify({id: exists.id, already_exists: true, secret_key: exists.secret})); } - const webhookToken = crypto.randomBytes(32).toString('hex'); - const entry = { - id: crypto.randomBytes(8).toString('hex'), - asn, endpoint_url, - secret: webhookToken, - timeout_ms: timeout_ms || 10000, - max_retries: max_retries || 5, - active: true, - created_at: new Date().toISOString(), - last_triggered_at: null, - failure_count: 0 - }; - subs.push(entry); - saveWebhookSubs(subs); - // Also ensure this ASN is in hijack monitoring - const hijackSubs = loadHijackSubs(); - if (!hijackSubs.find(s => s.asn === asn)) { - checkHijacksForAsn(asn).then(prefixes => { - // null (lookup failed) becomes [] here so this record's shape stays - // consistent; runHijackCheck retries the real baseline next cycle. - hijackSubs.push({ asn, prefixes: prefixes || [], subscribed: new Date().toISOString() }); - saveHijackSubs(hijackSubs); - }); - } - res.writeHead(201); - res.end(JSON.stringify({ id: entry.id, asn, endpoint_url, secret_key: secret, created_at: entry.created_at, note: 'Store secret_key safely — it signs all webhook payloads' })); - } catch(e) { res.writeHead(400); res.end(JSON.stringify({error: e.message})); } - }); - return; + // Hijack Subscribe (legacy) + if (reqPath === "/api/hijack-subscribe" && req.method === "POST") { + return hijackAlertsRoute.handleHijackSubscribe(req, res); } - // ── Webhook: List ───────────────────────────────────────────── - // GET /api/webhooks?asn=X - if (reqPath === '/api/webhooks' && req.method === 'GET') { - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - const params = new URL(req.url, 'http://localhost').searchParams; - const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : ''; - const subs = loadWebhookSubs(); - const result = (asn ? subs.filter(s => s.asn === asn) : subs) - .map(s => ({ id: s.id, asn: s.asn, endpoint_url: s.endpoint_url, active: s.active, failure_count: s.failure_count, last_triggered_at: s.last_triggered_at, created_at: s.created_at })); - res.writeHead(200); - return res.end(JSON.stringify({ webhooks: result, total: result.length })); + // Hijack Alerts (legacy endpoint) + if (reqPath.startsWith("/api/hijack-alerts")) { + return hijackAlertsRoute.handleHijackAlertsLegacy(req, res); } - // ── Webhook: Delete ─────────────────────────────────────────── - // DELETE /api/webhooks/:id - if (reqPath.startsWith('/api/webhooks/') && req.method === 'DELETE' && !reqPath.includes('/test')) { - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - const id = reqPath.split('/')[3]; - const subs = loadWebhookSubs(); - const idx = subs.findIndex(s => s.id === id); - if (idx === -1) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); } - subs.splice(idx, 1); - saveWebhookSubs(subs); - res.writeHead(200); - return res.end(JSON.stringify({ deleted: true, id })); + // PDF Export + if (reqPath === "/api/export/pdf" && req.method === "GET") { + return pdfExportRoute.handleExportPdf(req, res, url); } - // ── Webhook: Test ───────────────────────────────────────────── - // POST /api/webhooks/:id/test - if (reqPath.match(/^\/api\/webhooks\/[^/]+\/test$/) && req.method === 'POST') { - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - const id = reqPath.split('/')[3]; - const subs = loadWebhookSubs(); - const sub = subs.find(s => s.id === id); - if (!sub) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); } - const testPayload = { event: 'test', asn: sub.asn, message: 'PeerCortex webhook test — if you receive this, delivery works!', timestamp: new Date().toISOString() }; - const start = Date.now(); - deliverWebhook(sub, testPayload, 1).then(result => { - res.writeHead(result.ok ? 200 : 502); - res.end(JSON.stringify({ ok: result.ok, response_time_ms: Date.now() - start, status: result.status, error: result.error || null })); - }); - return; + if (reqPath === "/api/export/pdf/compare" && req.method === "GET") { + return pdfExportRoute.handleExportPdfCompare(req, res, url); } - // ── Hijack Events: List ─────────────────────────────────────── - // GET /api/hijacks?asn=X&limit=50&resolved=false - if (reqPath === '/api/hijacks' && req.method === 'GET') { - const params = new URL(req.url, 'http://localhost').searchParams; - const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : ''; - const limit = Math.min(parseInt(params.get('limit') || '50', 10), 200); - const resolvedFilter = params.get('resolved'); - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'no-store'); - let events = loadHijackAlerts(); - if (asn) events = events.filter(a => String(a.asn) === asn); - if (resolvedFilter === 'false') events = events.filter(a => !a.resolved); - if (resolvedFilter === 'true') events = events.filter(a => a.resolved); - const total = events.length; - events = events.slice(-limit).reverse(); - res.writeHead(200); - return res.end(JSON.stringify({ total, events })); + if (reqPath.startsWith("/api/export/") && req.method === "OPTIONS") { + return pdfExportRoute.handleExportOptions(req, res); } - // ── Hijack Events: Resolve ──────────────────────────────────── - // POST /api/hijacks/:id/resolve body: { resolution_notes? } - if (reqPath.match(/^\/api\/hijacks\/[^/]+\/resolve$/) && req.method === 'POST') { - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - const id = reqPath.split('/')[3]; - let body = ''; - req.on('data', c => body += c); - req.on('end', () => { - const alerts = loadHijackAlerts(); - const alert = alerts.find(a => a.id === id); - if (!alert) { res.writeHead(404); return res.end(JSON.stringify({error:'event not found'})); } - alert.resolved = true; - alert.resolved_at = new Date().toISOString(); - try { const { resolution_notes } = JSON.parse(body || '{}'); if (resolution_notes) alert.resolution_notes = resolution_notes; } catch(_){} - saveHijackAlerts(alerts); - res.writeHead(200); - res.end(JSON.stringify({ resolved: true, id, resolved_at: alert.resolved_at })); - }); - return; + // ASPA Adoption Tracker + if (reqPath === "/aspa-adoption") { + return aspaAdoptionRoute.handleDashboard(req, res); } - // ── Hijack Subscribe (legacy, kept for backwards compatibility) ─ - if (reqPath === '/api/hijack-subscribe' && req.method === 'POST') { - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - let body = ''; - req.on('data', c => body += c); - req.on('end', async () => { - try { - const { asn } = JSON.parse(body || '{}'); - const asnNum = String(asn || '').replace(/[^0-9]/g,''); - if (!asnNum) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); } - const subs = loadHijackSubs(); - const exists = subs.find(s => s.asn === asnNum); - if (!exists) { - const prefixes = await checkHijacksForAsn(asnNum); - // prefixes may be null if the lookup failed just now -- store an empty - // array so this record shape stays consistent; runHijackCheck's own - // "!sub.prefixes.length" check will retry establishing the real - // baseline on its next cycle rather than treating this as permanent. - subs.push({ asn: asnNum, prefixes: prefixes || [], subscribed: new Date().toISOString() }); - saveHijackSubs(subs); - } - const sub = subs.find(s => s.asn === asnNum) || { prefixes: [] }; - res.writeHead(200); - res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: (sub.prefixes || []).length })); - } catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); } - }); - return; + if (reqPath === "/api/aspa-adoption-stats" && req.method === "GET") { + return aspaAdoptionRoute.handleStats(req, res, url); } - // ── Hijack Alerts (legacy endpoint, kept for backwards compatibility) ─ - if (reqPath.startsWith('/api/hijack-alerts')) { - const params = new URL(req.url, 'http://localhost').searchParams; - const asn = (params.get('asn') || '').replace(/[^0-9]/g,''); - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'no-store'); - const allAlerts = loadHijackAlerts(); - const alerts = asn ? allAlerts.filter(a => String(a.asn) === asn) : allAlerts; - const subs = loadHijackSubs(); - const sub = subs.find(s => s.asn === asn); - res.writeHead(200); - return res.end(JSON.stringify({ asn, alerts: alerts.slice(-50), monitoring: !!sub, prefix_count: sub ? sub.prefixes.length : 0 })); + if (reqPath === "/api/aspa-adoption-stats/export" && req.method === "GET") { + return aspaAdoptionRoute.handleStatsExport(req, res, url); } - - // ── Hijack Alerts (legacy read) ─────────────────────────────── - // src/routes/hijack-alerts.ts registers /api/webhooks + /api/hijacks under - // the same paths this file already serves above (file-backed, live). - // Deliberately NOT proxied here — that would shadow working data with an - // empty Postgres-backed implementation. Not a gap, just two feature - // branches that ended up naming their routes the same thing. - - // ── Changelog JSON API ──────────────────────────────────────── - if (reqPath === '/changelog-data') { - return proxyToApiServer(req, res, req.url); + if (reqPath === "/api/ipv6-adoption-stats" && req.method === "GET") { + return aspaAdoptionRoute.handleIpv6Stats(req, res, url); } - // ── bio-rd RIB routes ───────────────────────────────────────── - if (reqPath.startsWith('/api/rib/')) { - return proxyToApiServer(req, res, req.url); - } - - // ── Prefix Changes ────────────────────────────────────────────── - if (reqPath === '/api/prefix-changes') { - return proxyToApiServer(req, res, req.url); - } - - // ============================================================ - // FEATURE 2: PDF Export - // GET /api/export/pdf?asn=X&format=report|executive|technical - // GET /api/export/pdf/compare?asn1=X&asn2=Y - // ============================================================ - - // Helper: fetch JSON from internal endpoint - async function fetchInternal(path) { - return new Promise((resolve, reject) => { - const opts = { hostname: '127.0.0.1', port: PORT, path, method: 'GET' }; - const req2 = http.request(opts, res2 => { - let body = ''; - res2.on('data', c => body += c); - res2.on('end', () => { - try { resolve(JSON.parse(body)); } - catch(e) { reject(new Error('JSON parse failed: ' + body.slice(0, 200))); } - }); - }); - req2.on('error', reject); - req2.setTimeout(30000, () => { req2.destroy(); reject(new Error('Internal request timeout')); }); - req2.end(); - }); - } - - if (reqPath === '/api/export/pdf' && req.method === 'GET') { - const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, ''); - const format = ['executive', 'technical', 'report'].includes(url.searchParams.get('format')) - ? url.searchParams.get('format') : 'report'; - if (!rawAsn) { - res.writeHead(400, {'Content-Type':'application/json'}); - return res.end(JSON.stringify({ error: 'Missing asn parameter' })); - } - const cacheKey = `pdf:${rawAsn}:${format}`; - const cached = pdfCacheGet(cacheKey); - if (cached) { - res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length }); - return res.end(cached); - } - try { - const [validateData, aspaData] = await Promise.all([ - fetchInternal(`/api/validate?asn=${rawAsn}`), - fetchInternal(`/api/aspa?asn=${rawAsn}`).catch(() => null), - ]); - if (validateData.error) { - res.writeHead(400, {'Content-Type':'application/json'}); - return res.end(JSON.stringify({ error: validateData.error })); - } - const html = buildPdfHtml(validateData, aspaData, format); - const pdfBuf = await renderHtmlToPdf(html); - pdfCacheSet(cacheKey, pdfBuf); - res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'Content-Length': pdfBuf.length }); - return res.end(pdfBuf); - } catch (err) { - console.error('[PDF] Error:', err.message); - res.writeHead(503, {'Content-Type':'application/json'}); - return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message })); - } - } - - if (reqPath === '/api/export/pdf/compare' && req.method === 'GET') { - const asn1 = (url.searchParams.get('asn1') || '').replace(/[^0-9]/g, ''); - const asn2 = (url.searchParams.get('asn2') || '').replace(/[^0-9]/g, ''); - if (!asn1 || !asn2) { - res.writeHead(400, {'Content-Type':'application/json'}); - return res.end(JSON.stringify({ error: 'Missing asn1 or asn2 parameter' })); - } - if (asn1 === asn2) { - res.writeHead(400, {'Content-Type':'application/json'}); - return res.end(JSON.stringify({ error: 'asn1 and asn2 must be different' })); - } - const cacheKey = `pdf:compare:${Math.min(+asn1,+asn2)}:${Math.max(+asn1,+asn2)}`; - const cached = pdfCacheGet(cacheKey); - if (cached) { - res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length }); - return res.end(cached); - } - try { - const [v1, a1, l1, v2, a2, l2] = await Promise.all([ - fetchInternal(`/api/validate?asn=${asn1}`), - fetchInternal(`/api/aspa?asn=${asn1}`).catch(() => null), - fetchInternal(`/api/lookup?asn=${asn1}`).catch(() => null), - fetchInternal(`/api/validate?asn=${asn2}`), - fetchInternal(`/api/aspa?asn=${asn2}`).catch(() => null), - fetchInternal(`/api/lookup?asn=${asn2}`).catch(() => null), - ]); - if (v1.error || v2.error) { - res.writeHead(400, {'Content-Type':'application/json'}); - return res.end(JSON.stringify({ error: v1.error || v2.error })); - } - const html = buildComparisonPdfHtml(v1, a1, l1, v2, a2, l2); - const pdfBuf = await renderHtmlToPdf(html); - pdfCacheSet(cacheKey, pdfBuf); - res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'Content-Length': pdfBuf.length }); - return res.end(pdfBuf); - } catch (err) { - console.error('[PDF] Error:', err.message); - res.writeHead(503, {'Content-Type':'application/json'}); - return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message })); - } - } - - // CORS preflight for PDF export (already handled globally above, belt-and-suspenders) - if (reqPath.startsWith('/api/export/') && req.method === 'OPTIONS') { - res.writeHead(204); - return res.end(); - } - - // ============================================================ - // FEATURE 3: ASPA Adoption Tracker — API + Dashboard - // GET /aspa-adoption → dashboard HTML - // GET /api/aspa-adoption-stats?period=7d|30d|1y → JSON trend - // GET /api/aspa-adoption-stats/export?format=csv|json&period=30d - // ============================================================ - - if (reqPath === '/aspa-adoption') { - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.writeHead(200); - return res.end(buildAspaAdoptionDashboard()); - } - - if (reqPath === '/api/aspa-adoption-stats' && req.method === 'GET') { - const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period')) - ? url.searchParams.get('period') : '30d'; - const trend = getAdoptionTrend(period); - const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1]; - const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2]; - - // Linear regression forecast - function forecast(data, daysAhead) { - if (data.length < 2) return null; - const n = data.length; - const xs = data.map((_, i) => i); - const ys = data.map(d => d.coverage_pct_atlas); - const xMean = xs.reduce((a,b)=>a+b,0)/n; - const yMean = ys.reduce((a,b)=>a+b,0)/n; - const num = xs.reduce((s,x,i)=>s+(x-xMean)*(ys[i]-yMean),0); - const den = xs.reduce((s,x)=>s+(x-xMean)**2,0); - if (den===0) return yMean; - const slope = num/den; - return Math.max(0, Math.min(100, Math.round((yMean + slope*(n-1+daysAhead))*100)/100)); - } - - const trend30 = getAdoptionTrend('30d'); - const result = { - period, - current: { - date: latest?.date, - aspa_objects: latest?.aspa_objects ?? rpkiAspaMap.size, - roa_count: latest?.roa_count, - atlas_asns_total: latest?.atlas_asns_total ?? 0, - atlas_asns_with_aspa: latest?.atlas_asns_with_aspa ?? 0, - coverage_pct_atlas: latest?.coverage_pct_atlas ?? 0, - delta_from_previous: prev ? (latest?.aspa_objects ?? 0) - prev.aspa_objects : 0, - }, - trend: trend.map(s => ({ - date: s.date, - aspa_objects: s.aspa_objects, - coverage_pct_atlas: s.coverage_pct_atlas, - atlas_asns_total: s.atlas_asns_total, - })), - forecast: { - predicted_90d: forecast(trend30, 90), - confidence: trend30.length >= 10 ? 0.75 : 0.5, - method: 'linear_regression', - based_on_samples: trend30.length, - }, - meta: { - total_snapshots: aspaAdoptionDailyHistory.length, - last_updated: latest?.date ?? null, - denominator: 'atlas_visible_asns', - source: 'rpki_cloudflare_feed', - } - }; - res.setHeader('Content-Type', 'application/json'); - res.writeHead(200); - return res.end(JSON.stringify(result, null, 2)); - } - - if (reqPath === '/api/aspa-adoption-stats/export' && req.method === 'GET') { - const fmt = url.searchParams.get('format') === 'csv' ? 'csv' : 'json'; - const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period')) - ? url.searchParams.get('period') : '30d'; - const data = getAdoptionTrend(period); - - if (fmt === 'csv') { - const header = 'date,aspa_objects,roa_count,atlas_asns_total,atlas_asns_with_aspa,coverage_pct_atlas,aspa_delta\n'; - const rows = data.map(s => - `${s.date},${s.aspa_objects},${s.roa_count},${s.atlas_asns_total},${s.atlas_asns_with_aspa},${s.coverage_pct_atlas},${s.aspa_delta??0}` - ).join('\n'); - res.setHeader('Content-Type', 'text/csv'); - res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.csv"`); - res.writeHead(200); - return res.end(header + rows); - } - - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.json"`); - res.writeHead(200); - return res.end(JSON.stringify({ period, count: data.length, data }, null, 2)); - } - - // GET /api/ipv6-adoption-stats?refresh=1 - if (reqPath === '/api/ipv6-adoption-stats' && req.method === 'GET') { - const force = url.searchParams.get('refresh') === '1'; - try { - const data = await fetchIpv6AdoptionStats(force); - res.setHeader('Content-Type', 'application/json'); - res.writeHead(200); - return res.end(JSON.stringify(data, null, 2)); - } catch (err) { - res.writeHead(503); - return res.end(JSON.stringify({ error: 'IPv6 stats unavailable', message: err.message })); - } - } // 404 res.writeHead(404); @@ -6589,147 +467,9 @@ ${html} }); -// ============================================================ -// Atlas Probe Cache (for Lia's Atlas Paradise) -// ============================================================ -let atlasProbeCache = null; -let atlasProbeFetching = false; +const { atlasState, fetchAllAtlasProbes } = require("./server/atlas-probes"); -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 { pdbOrgState, fetchPdbOrgCountries } = require("./server/pdb-org-countries"); const PORT = process.env.PORT || 3101; @@ -6746,7 +486,7 @@ loadRipeStatCacheFromDisk("/opt/peercortex-app/.ripe-stat-cache.json"); ensureManrsCache(); // fire-and-forget, 24h cache Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => { // Re-record ASPA adoption snapshot now that Atlas data is fully loaded - recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count); + recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count, rpkiAspaMap); server.listen(PORT, "0.0.0.0", () => { console.log("PeerCortex v0.6.5 running on http://0.0.0.0:" + PORT); diff --git a/server/__smoke__/baseline/baseline.json b/server/__smoke__/baseline/baseline.json new file mode 100644 index 0000000..8788df7 --- /dev/null +++ b/server/__smoke__/baseline/baseline.json @@ -0,0 +1,3505 @@ +{ + "root": { + "status": 500, + "body": { + "__non_json__": true, + "status": 500, + "bodyLength": 20 + } + }, + "search": { + "status": 200, + "body": { + "q": "cloudflare", + "results": [ + { + "asn": "13335", + "label": "CLOUDFLARENET - Cloudflare, Inc.", + "description": "", + "source": "RIPE Stat" + }, + { + "asn": "14789", + "label": "CLOUDFLARENET - Cloudflare, Inc.", + "description": "", + "source": "RIPE Stat" + }, + { + "asn": "132892", + "label": "CLOUDFLARE - Cloudflare, Inc.", + "description": "", + "source": "RIPE Stat" + }, + { + "asn": "133877", + "label": "CLOUDFLARE - Cloudflare, Inc.", + "description": "", + "source": "RIPE Stat" + }, + { + "asn": "202623", + "label": "CLOUDFLARENET-CORE Cloudflare Inc", + "description": "", + "source": "RIPE Stat" + }, + { + "asn": "203898", + "label": "CLOUDFLARENET-UK Cloudflare Inc", + "description": "", + "source": "RIPE Stat" + }, + { + "asn": "209242", + "label": "CLOUDFLARESPECTRUM Cloudflare London, LLC", + "description": "", + "source": "RIPE Stat" + }, + { + "asn": "394536", + "label": "CLOUDFLARENET-SFO - Cloudflare, Inc.", + "description": "", + "source": "RIPE Stat" + }, + { + "asn": "395747", + "label": "CLOUDFLARENET-SFO05 - Cloudflare, Inc.", + "description": "", + "source": "RIPE Stat" + }, + { + "asn": "400095", + "label": "CLOUDFLARENET - Cloudflare, Inc.", + "description": "", + "source": "RIPE Stat" + }, + { + "asn": "402542", + "label": "CLOUDFLARENET - Cloudflare,", + "description": "", + "source": "RIPE Stat" + }, + { + "asn": "4436", + "label": "GTT Communications (AS4436)", + "description": "NSP", + "source": "PeeringDB" + } + ] + } + }, + "visitors": { + "status": 200, + "body": { + "visitors": 1 + } + }, + "health": { + "status": 200, + "body": { + "status": "degraded", + "service": "PeerCortex", + "version": "0.6.9", + "bgproutes_configured": false, + "caches": { + "aspa_map": {}, + "pdb_net": { + "entries": 0, + "hit_rate_pct": 0 + }, + "pdb_netixlan": { + "entries": 0 + }, + "pdb_netfac": { + "entries": 0 + }, + "ripe_stat": { + "entries": 0 + }, + "response_cache": { + "entries": 0 + } + }, + "local_db": null, + "aspa_adoption": {} + } + }, + "aspa-verify": { + "status": 200, + "body": { + "meta": { + "query": "AS13335", + "paths_analyzed": 0, + "total_paths_seen": 0 + }, + "asn": 13335, + "readiness_score": { + "total": 0, + "breakdown": { + "roa_coverage": { + "score": 0, + "max": 25, + "value": 0 + }, + "aspa_object": { + "score": 0, + "max": 25, + "value": false + }, + "provider_completeness": { + "score": 0, + "max": 25, + "value": 0 + }, + "path_validation": { + "score": 0, + "max": 25, + "value": 0 + } + } + }, + "aspa_object_exists": false, + "aspa_feed_healthy": true, + "detected_providers": [], + "provider_audit": { + "declared_count": 0, + "detected_count": 0, + "completeness_pct": 0, + "missing_from_aspa": [], + "extra_in_aspa": [] + }, + "path_verification": { + "total": 0, + "valid": 0, + "invalid": 0, + "unknown": 0, + "as_set_flagged": 0, + "valley_detected": 0, + "valid_pct": 0, + "not_invalid_pct": 0, + "results": [] + }, + "rpki_coverage": 0 + } + }, + "aspa-legacy": { + "status": 200, + "body": { + "meta": { + "query": "AS13335" + }, + "asn": 13335, + "detected_providers": [], + "provider_count": 0, + "aspa_object_exists": false, + "aspa_feed_healthy": true, + "aspa_declared_providers": [], + "aspa_declared_count": 0, + "recommended_aspa": "aut-num: AS13335\n# Recommended ASPA object:\n# customer: AS13335\n# provider-set: \n# AFI: ipv4, ipv6\n#\n# Detected providers from BGP path analysis:\n", + "path_analysis": { + "total_paths_seen": 0, + "sample_paths": [] + } + } + }, + "bgp": { + "status": 200, + "body": { + "meta": { + "source": "local_bgp_db" + }, + "bgp_status": { + "asn": "13335", + "announced": false, + "announced_count": 0, + "message": "No prefixes found for this ASN in local BGP table", + "source": "local_bgp" + }, + "threat_intel": null + } + }, + "validate": { + "status": 200, + "body": { + "meta": { + "query": "AS13335", + "total_prefixes": 0, + "prefixes_sampled": 0 + }, + "asn": 13335, + "name": "Unknown", + "validations": { + "irr": { + "status": "info", + "message": "timed out" + }, + "rpki_completeness": { + "status": "fail", + "coverage_pct": 0, + "total_checked": 0, + "db_unavailable_count": 0, + "with_roa": 0, + "over_specific": [], + "details": [] + }, + "abuse_contact": { + "status": "pass", + "contacts": [ + "abuse@cloudflare.com" + ], + "has_valid_email": true + }, + "blocklist": { + "status": "pass", + "checked": 0, + "unavailable": 0, + "listed_prefixes": [] + }, + "manrs": { + "status": "info", + "participant": "unknown", + "message": "MANRS data not yet loaded", + "note": "https://www.manrs.org/netops/participants/" + }, + "rdns": { + "status": "fail", + "coverage_pct": 0, + "checked": 0, + "unavailable": 0, + "results": [], + "failed_prefixes": [] + }, + "communities": { + "status": "pass", + "total_updates": 0, + "unique_communities": 0, + "top_communities": [], + "well_known_detected": [] + }, + "geolocation": { + "status": "warning", + "geo_countries": [], + "sample_prefix": null, + "located_resources": 0, + "pdb_facility_countries": [], + "country_mismatches": [] + }, + "rpsl": { + "status": "pass", + "exists": true, + "has_import": false, + "has_export": false, + "has_remarks": true, + "has_policy": false, + "source": "ARIN", + "rdap_name": "CLOUDFLARENET", + "rdap_handle": "AS13335" + }, + "ix_route_server": { + "status": "warning", + "total_ix_connections": 0, + "rs_peer_count": 0, + "rs_peer_pct": 0, + "note": "Only 0 IX connection(s) and no route server usage" + }, + "resource_cert": { + "status": "fail", + "has_roas": false, + "checked": 0 + }, + "bogon": { + "status": "pass", + "bogon_prefixes": [], + "bogon_asns_in_paths": [], + "total_prefixes_checked": 0, + "prefixes_source_unavailable": false, + "neighbours_source_unavailable": true + } + }, + "relationships": { + "counts": { + "upstreams": 0, + "downstreams": 0, + "peers": 0, + "uncertain": 0 + }, + "upstreams": [], + "downstreams": [], + "top_peers": [], + "source": "RIPE Stat asn-neighbours", + "note": "left=upstream providers, right=downstream customers, uncertain=peers. Sorted by power score." + } + } + }, + "lookup": { + "status": 200, + "body": { + "meta": { + "service": "PeerCortex", + "version": "0.6.9", + "query": "AS13335", + "sources": [ + "PeeringDB", + "RIPE Stat", + "bgp.he.net", + "Cloudflare RPKI", + "RIPE RPKI Validator", + "Route Views" + ], + "rpki_prefixes_checked": 0, + "total_prefixes": 0, + "degraded_sources": [ + "RIPE Stat Neighbours", + "RIPE Stat Overview", + "RIPE Stat Visibility", + "RIPE Stat PrefixSize" + ] + }, + "network": { + "asn": 13335, + "name": "Unknown", + "aka": "", + "org_name": "", + "website": "", + "type": "", + "policy": "", + "traffic": "", + "ratio": "", + "scope": "", + "notes": "", + "peeringdb_id": null, + "rir": "ARIN", + "country": "", + "city": "", + "latitude": null, + "longitude": null, + "looking_glass": "", + "route_server": "", + "info_prefixes4": 0, + "info_prefixes6": 0, + "status": "", + "peeringdb_created": "", + "peeringdb_updated": "" + }, + "prefixes": { + "total": 0, + "ipv4": 0, + "ipv6": 0, + "list": [], + "cross_check": { + "ripe_stat": 0, + "bgp_he_net": null, + "agreement": null, + "note": "bgp.he.net data unavailable" + } + }, + "rpki": { + "coverage_percent": 0, + "valid": 0, + "invalid": 0, + "not_found": 0, + "unavailable": 0, + "checked": 0, + "details": [], + "cross_check": { + "cloudflare_valid": 0, + "ripe_valid": 0, + "agreement_pct": null, + "disagreements": [], + "sample_size": 0, + "comparable_size": 0 + } + }, + "neighbours": { + "total": 0, + "upstream_count": 0, + "downstream_count": 0, + "peer_count": 0, + "upstreams": [], + "downstreams": [], + "peers": [], + "cross_check": { + "ripe_stat_total": 0, + "bgp_he_net_total": null + } + }, + "ix_presence": { + "total_connections": 0, + "unique_ixps": 0, + "connections": [] + }, + "ix_locations": [], + "facilities": { + "total": 0, + "list": [] + }, + "routing": { + "ipv4_prefixes": 0, + "ipv6_prefixes": 0, + "ipv4_visibility_avg": 0, + "ipv6_visibility_avg": 0, + "total_ris_peers_v4": 0, + "total_ris_peers_v6": 0, + "prefix_sizes_v4": [], + "prefix_sizes_v6": [] + }, + "resilience_score": null, + "route_leak": { + "detected": false, + "patterns": [], + "tier1_upstream_count": 0, + "tier1_downstream_count": 0, + "_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." + } + }, + "bgp_he_net": null, + "atlas": { + "connected": 0, + "disconnected": 0, + "anchors": 0, + "probes": [] + }, + "data_quality": { + "sources_queried": [ + "PeeringDB", + "RIPE Stat", + "bgp.he.net", + "Cloudflare RPKI", + "RIPE RPKI Validator" + ], + "cross_checks": { + "rpki": { + "sources": 1, + "agreement_pct": null, + "sample_size": 0, + "comparable_size": 0, + "disagreements": [] + }, + "prefixes": { + "sources": 1, + "agreement_pct": null, + "ripe_stat": 0, + "bgp_he_net": null, + "note": "bgp.he.net data unavailable" + }, + "neighbours": { + "sources": 1, + "agreement_pct": null, + "ripe_stat_total": 0, + "bgp_he_net_total": null + } + }, + "overall_confidence": "unknown", + "overall_agreement_pct": null + }, + "contacts": [], + "registration": { + "created": "2010-07-14", + "last_modified": "2017-02-17", + "rir": "ARIN", + "handle": "AS13335", + "rdap_source": "whois.arin.net" + }, + "_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" + } + } + } + }, + "relationships": { + "status": 200, + "body": { + "asn": 13335, + "counts": { + "upstreams": 0, + "downstreams": 0, + "peers_total": 0, + "uncertain": 0 + }, + "upstreams": [], + "downstreams": [], + "peers": [], + "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=AS13335" + } + }, + "compare": { + "status": 200, + "body": { + "meta": {}, + "asn1": { + "asn": 13335, + "name": "Unknown", + "ix_count": 0, + "fac_count": 0, + "upstream_count": 0, + "rpki_coverage": 0 + }, + "asn2": { + "asn": 15169, + "name": "Unknown", + "ix_count": 0, + "fac_count": 0, + "upstream_count": 0, + "rpki_coverage": 0 + }, + "common_ixps": [], + "only_asn1_ixps": [], + "only_asn2_ixps": [], + "common_facilities": [], + "common_upstreams": [], + "rpki_comparison": { + "asn1_coverage": 0, + "asn2_coverage": 0, + "asn1_checked": 0, + "asn2_checked": 0, + "better": "equal" + } + } + }, + "quick-ix": { + "status": 200, + "body": { + "asn": 13335 + } + }, + "peers-find": { + "status": 400, + "body": { + "error": "Missing ix parameter (IX name)" + } + }, + "prefix-detail": { + "status": 200, + "body": { + "meta": {}, + "prefix": "1.1.1.0/24", + "rpki": { + "status": "unknown", + "validating_roas": 0 + }, + "irr_status": "found", + "visibility": { + "ris_peers_seeing": 0, + "source": "ripe_stat" + } + } + }, + "ix-detail": { + "status": 400, + "body": { + "error": "Missing ix_id parameter" + } + }, + "topology": { + "status": 200, + "body": { + "nodes": [ + { + "asn": 13335, + "name": "", + "type": "target", + "depth": 0 + } + ], + "edges": [], + "target_asn": 13335, + "depth": 2, + "meta": { + "query": "AS13335", + "depth": 2, + "node_count": 1, + "edge_count": 0 + } + } + }, + "whois": { + "status": 400, + "body": { + "error": "Missing resource parameter (ASN, prefix, or domain)" + } + }, + "enrich": { + "status": 200, + "body": { + "asn": "13335", + "description": null, + "wiki_url": null + } + }, + "changelog": { + "status": 500, + "body": { + "__non_json__": true, + "status": 500, + "bodyLength": 23 + } + }, + "webhooks-list": { + "status": 200, + "body": { + "webhooks": [], + "total": 0 + } + }, + "hijacks-list": { + "status": 200, + "body": { + "total": 1000, + "events": [ + { + "id": "c37eb29c5c54c5a1", + "asn": "132372", + "ts": "2026-07-16T06:29:59.070Z", + "severity": "HIGH", + "unexpected": [ + "103.91.194.0/24", + "2a0d:9842::/48" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "5d4e90a8549f3771", + "asn": "22616", + "ts": "2026-07-16T04:59:53.845Z", + "severity": "CRITICAL", + "unexpected": [ + "137.31.24.0/23", + "137.31.101.0/24", + "205.220.14.0/23", + "137.31.120.0/23", + "137.31.114.0/23", + "137.31.119.0/24", + "2605:4300:e500::/40", + "137.31.26.0/23", + "137.31.126.0/23", + "137.31.122.0/23", + "140.232.189.0/24", + "137.31.128.0/23", + "140.232.190.0/24", + "137.31.124.0/23", + "155.95.86.0/24", + "165.225.99.0/24", + "205.220.12.0/23", + "140.232.188.0/24", + "89.18.89.0/24", + "89.18.90.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing" + }, + { + "id": "6dd656f19f6a1924", + "asn": "396986", + "ts": "2026-07-16T04:29:51.725Z", + "severity": "CRITICAL", + "unexpected": [ + "101.45.18.0/24", + "101.45.199.0/24", + "2605:340:f04a::/48", + "101.45.202.0/24", + "71.18.66.0/24", + "101.45.15.0/24", + "71.18.44.0/24", + "101.45.6.0/24", + "71.18.45.0/24", + "71.18.64.0/24", + "71.18.71.0/24", + "71.18.67.0/24", + "71.18.65.0/24", + "2605:340:f049::/48", + "101.45.14.0/24", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "9088627d62eade29", + "asn": "34702", + "ts": "2026-07-16T03:59:52.690Z", + "severity": "MEDIUM", + "unexpected": [ + "185.200.196.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing" + }, + { + "id": "496424b0fc064555", + "asn": "3491", + "ts": "2026-07-16T02:59:52.248Z", + "severity": "CRITICAL", + "unexpected": [ + "63.219.192.0/24", + "2400:8800:f8fa::/48", + "2400:8800:f8fb::/48", + "2400:8800:f8f0::/48", + "209.8.154.0/23" + ], + "missing": [ + "72.57.243.0/24", + "45.195.111.0/24", + "72.57.240.0/24", + "72.57.250.0/24", + "72.57.244.0/24", + "72.57.249.0/24", + "72.57.246.0/24", + "72.57.248.0/24", + "72.57.241.0/24", + "72.57.253.0/24", + "154.194.34.0/24", + "101.60.254.0/24", + "72.57.247.0/24", + "72.57.254.0/24", + "2400:8800:f827::/48", + "207.176.31.0/24", + "72.57.252.0/24", + "72.57.245.0/24", + "72.57.255.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing" + }, + { + "id": "8b02d291a1af8c3d", + "asn": "15290", + "ts": "2026-07-16T01:59:53.019Z", + "severity": "HIGH", + "unexpected": [], + "missing": [ + "2001:4c8:1005:1::/64", + "209.112.10.0/24", + "2001:4c8:107f:100::/56", + "2001:4c8:1097::/48", + "2001:4c8:1092::/48", + "192.135.87.0/24", + "2001:4c8:4:1::/64", + "216.123.66.0/24", + "2001:4c8:1089::/48", + "216.123.80.0/24", + "203.186.117.0/24", + "206.191.100.0/24", + "2001:4c8:107f:8000::/56", + "216.129.75.0/24", + "203.186.119.0/24", + "74.216.180.0/24", + "209.82.49.0/24", + "2001:4c8:108e::/48", + "159.18.219.0/24", + "209.112.42.0/24", + "2001:4c8:100f:2::/64", + "216.13.190.0/24", + "2001:4c8:108a::/48", + "2001:4c8:1080::/48", + "2001:4c8:101f::/48", + "204.138.183.0/24", + "2001:4c8:e:10::/64", + "216.13.26.0/24", + "2001:4c8:1090::/48", + "216.13.36.0/24", + "216.13.115.0/24", + "74.216.227.0/24", + "2001:4c8:107f:8200::/56", + "2001:4c8:107f:200::/56", + "203.186.116.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing" + }, + { + "id": "b91f3e602fd81ca1", + "asn": "138699", + "ts": "2026-07-16T01:59:52.148Z", + "severity": "HIGH", + "unexpected": [ + "101.45.200.0/23", + "2404:9dc0:cd04::/48", + "2404:9dc0:c002::/48", + "101.45.198.0/24" + ], + "missing": [ + "118.26.132.0/24", + "103.136.222.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing" + }, + { + "id": "6daaace7b94cbf39", + "asn": "24940", + "ts": "2026-07-16T01:59:51.622Z", + "severity": "HIGH", + "unexpected": [ + "2.29.0.0/16", + "193.105.82.0/24", + "2.28.0.0/16" + ], + "missing": [ + "2a11:e980::/29" + ], + "resolved": false, + "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing" + }, + { + "id": "db1240cf335bd1fb", + "asn": "132372", + "ts": "2026-07-16T00:29:53.114Z", + "severity": "HIGH", + "unexpected": [ + "2a0d:9842::/48", + "103.91.194.0/24" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "abf9968f49c729b7", + "asn": "22616", + "ts": "2026-07-15T22:29:53.837Z", + "severity": "CRITICAL", + "unexpected": [ + "140.232.189.0/24", + "137.31.114.0/23", + "155.95.86.0/24", + "165.225.99.0/24", + "137.31.124.0/23", + "2605:4300:e500::/40", + "137.31.101.0/24", + "205.220.12.0/23", + "137.31.119.0/24", + "205.220.14.0/23", + "137.31.128.0/23", + "137.31.26.0/23", + "137.31.120.0/23", + "137.31.24.0/23", + "140.232.190.0/24", + "140.232.188.0/24", + "137.31.122.0/23", + "137.31.126.0/23", + "89.18.89.0/24", + "89.18.90.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing" + }, + { + "id": "3497eb91f0a1804b", + "asn": "396986", + "ts": "2026-07-15T22:29:51.711Z", + "severity": "CRITICAL", + "unexpected": [ + "2605:340:f04a::/48", + "101.45.199.0/24", + "71.18.65.0/24", + "71.18.66.0/24", + "71.18.71.0/24", + "71.18.67.0/24", + "71.18.44.0/24", + "101.45.15.0/24", + "101.45.6.0/24", + "101.45.202.0/24", + "2605:340:f049::/48", + "101.45.14.0/24", + "101.45.18.0/24", + "71.18.45.0/24", + "71.18.64.0/24", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "176f637eb9f4b8a0", + "asn": "34702", + "ts": "2026-07-15T21:29:53.213Z", + "severity": "MEDIUM", + "unexpected": [ + "185.200.196.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing" + }, + { + "id": "0739880a09b2e60e", + "asn": "3491", + "ts": "2026-07-15T20:59:51.838Z", + "severity": "CRITICAL", + "unexpected": [ + "2400:8800:f8fb::/48", + "63.219.192.0/24", + "2400:8800:f8fa::/48", + "2400:8800:f8f0::/48", + "209.8.154.0/23" + ], + "missing": [ + "72.57.243.0/24", + "45.195.111.0/24", + "72.57.240.0/24", + "72.57.250.0/24", + "72.57.244.0/24", + "72.57.249.0/24", + "72.57.246.0/24", + "72.57.248.0/24", + "72.57.241.0/24", + "72.57.253.0/24", + "154.194.34.0/24", + "101.60.254.0/24", + "72.57.247.0/24", + "72.57.254.0/24", + "2400:8800:f827::/48", + "207.176.31.0/24", + "72.57.252.0/24", + "72.57.245.0/24", + "72.57.255.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing" + }, + { + "id": "341a4a5416d53fb4", + "asn": "24940", + "ts": "2026-07-15T19:59:51.372Z", + "severity": "HIGH", + "unexpected": [ + "2.29.0.0/16", + "193.105.82.0/24", + "2.28.0.0/16" + ], + "missing": [ + "2a11:e980::/29" + ], + "resolved": false, + "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing" + }, + { + "id": "bc655a6f3f565c2b", + "asn": "15290", + "ts": "2026-07-15T19:29:53.248Z", + "severity": "HIGH", + "unexpected": [], + "missing": [ + "2001:4c8:1005:1::/64", + "209.112.10.0/24", + "2001:4c8:107f:100::/56", + "2001:4c8:1097::/48", + "2001:4c8:1092::/48", + "192.135.87.0/24", + "2001:4c8:4:1::/64", + "216.123.66.0/24", + "2001:4c8:1089::/48", + "216.123.80.0/24", + "203.186.117.0/24", + "206.191.100.0/24", + "2001:4c8:107f:8000::/56", + "216.129.75.0/24", + "203.186.119.0/24", + "74.216.180.0/24", + "209.82.49.0/24", + "2001:4c8:108e::/48", + "159.18.219.0/24", + "209.112.42.0/24", + "2001:4c8:100f:2::/64", + "216.13.190.0/24", + "2001:4c8:108a::/48", + "2001:4c8:1080::/48", + "2001:4c8:101f::/48", + "204.138.183.0/24", + "2001:4c8:e:10::/64", + "216.13.26.0/24", + "2001:4c8:1090::/48", + "216.13.36.0/24", + "216.13.115.0/24", + "74.216.227.0/24", + "2001:4c8:107f:8200::/56", + "2001:4c8:107f:200::/56", + "203.186.116.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing" + }, + { + "id": "fe4018f500c85f61", + "asn": "138699", + "ts": "2026-07-15T19:29:52.219Z", + "severity": "HIGH", + "unexpected": [ + "101.45.200.0/23", + "2404:9dc0:cd04::/48", + "2404:9dc0:c002::/48", + "101.45.198.0/24" + ], + "missing": [ + "118.26.132.0/24", + "103.136.222.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing" + }, + { + "id": "3c915ae1bf8a755e", + "asn": "132372", + "ts": "2026-07-15T18:29:53.011Z", + "severity": "HIGH", + "unexpected": [ + "2a0d:9842::/48", + "103.91.194.0/24" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "14d4c48ce7317e03", + "asn": "22616", + "ts": "2026-07-15T16:29:53.752Z", + "severity": "CRITICAL", + "unexpected": [ + "140.232.189.0/24", + "140.232.190.0/24", + "155.95.86.0/24", + "140.232.188.0/24", + "137.31.119.0/24", + "2605:4300:e500::/40", + "137.31.128.0/23", + "137.31.24.0/23", + "165.225.99.0/24", + "137.31.101.0/24", + "205.220.12.0/23", + "137.31.120.0/23", + "137.31.124.0/23", + "137.31.26.0/23", + "137.31.122.0/23", + "137.31.126.0/23", + "137.31.114.0/23", + "205.220.14.0/23", + "89.18.89.0/24", + "89.18.90.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing" + }, + { + "id": "ef7fe7860371b6c7", + "asn": "396986", + "ts": "2026-07-15T16:29:51.704Z", + "severity": "CRITICAL", + "unexpected": [ + "101.45.6.0/24", + "71.18.71.0/24", + "71.18.65.0/24", + "71.18.64.0/24", + "101.45.18.0/24", + "2605:340:f049::/48", + "71.18.45.0/24", + "101.45.15.0/24", + "71.18.66.0/24", + "101.45.202.0/24", + "2605:340:f04a::/48", + "71.18.44.0/24", + "101.45.199.0/24", + "71.18.67.0/24", + "101.45.14.0/24", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "8df39b422cdb9693", + "asn": "34702", + "ts": "2026-07-15T14:59:52.749Z", + "severity": "MEDIUM", + "unexpected": [ + "185.200.196.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing" + }, + { + "id": "1275ffa07a209ece", + "asn": "3491", + "ts": "2026-07-15T14:29:51.794Z", + "severity": "CRITICAL", + "unexpected": [ + "2400:8800:f8f0::/48", + "2400:8800:f8fa::/48", + "63.219.192.0/24", + "2400:8800:f8fb::/48", + "209.8.154.0/23" + ], + "missing": [ + "72.57.243.0/24", + "45.195.111.0/24", + "72.57.240.0/24", + "72.57.250.0/24", + "72.57.244.0/24", + "72.57.249.0/24", + "72.57.246.0/24", + "72.57.248.0/24", + "72.57.241.0/24", + "72.57.253.0/24", + "154.194.34.0/24", + "101.60.254.0/24", + "72.57.247.0/24", + "72.57.254.0/24", + "2400:8800:f827::/48", + "207.176.31.0/24", + "72.57.252.0/24", + "72.57.245.0/24", + "72.57.255.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing" + }, + { + "id": "11d64812f7d25748", + "asn": "24940", + "ts": "2026-07-15T13:59:51.366Z", + "severity": "HIGH", + "unexpected": [ + "2.29.0.0/16", + "2.28.0.0/16", + "193.105.82.0/24" + ], + "missing": [ + "2a11:e980::/29" + ], + "resolved": false, + "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing" + }, + { + "id": "5943d1de345838ac", + "asn": "15290", + "ts": "2026-07-15T13:29:52.431Z", + "severity": "HIGH", + "unexpected": [], + "missing": [ + "2001:4c8:1005:1::/64", + "209.112.10.0/24", + "2001:4c8:107f:100::/56", + "2001:4c8:1097::/48", + "2001:4c8:1092::/48", + "192.135.87.0/24", + "2001:4c8:4:1::/64", + "216.123.66.0/24", + "2001:4c8:1089::/48", + "216.123.80.0/24", + "203.186.117.0/24", + "206.191.100.0/24", + "2001:4c8:107f:8000::/56", + "216.129.75.0/24", + "203.186.119.0/24", + "74.216.180.0/24", + "209.82.49.0/24", + "2001:4c8:108e::/48", + "159.18.219.0/24", + "209.112.42.0/24", + "2001:4c8:100f:2::/64", + "216.13.190.0/24", + "2001:4c8:108a::/48", + "2001:4c8:1080::/48", + "2001:4c8:101f::/48", + "204.138.183.0/24", + "2001:4c8:e:10::/64", + "216.13.26.0/24", + "2001:4c8:1090::/48", + "216.13.36.0/24", + "216.13.115.0/24", + "74.216.227.0/24", + "2001:4c8:107f:8200::/56", + "2001:4c8:107f:200::/56", + "203.186.116.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing" + }, + { + "id": "8fdaa6a50cb01891", + "asn": "138699", + "ts": "2026-07-15T13:29:51.615Z", + "severity": "HIGH", + "unexpected": [ + "101.45.198.0/24", + "2404:9dc0:c002::/48", + "101.45.200.0/23", + "2404:9dc0:cd04::/48" + ], + "missing": [ + "118.26.132.0/24", + "103.136.222.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing" + }, + { + "id": "4a2318801159ada4", + "asn": "132372", + "ts": "2026-07-15T11:59:53.119Z", + "severity": "HIGH", + "unexpected": [ + "2a0d:9842::/48", + "103.91.194.0/24" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "b72903d54d8d8705", + "asn": "396986", + "ts": "2026-07-15T10:29:51.682Z", + "severity": "CRITICAL", + "unexpected": [ + "71.18.67.0/24", + "101.45.14.0/24", + "2605:340:f04a::/48", + "101.45.202.0/24", + "71.18.44.0/24", + "101.45.15.0/24", + "71.18.45.0/24", + "71.18.66.0/24", + "71.18.64.0/24", + "71.18.71.0/24", + "101.45.6.0/24", + "101.45.199.0/24", + "2605:340:f049::/48", + "101.45.18.0/24", + "71.18.65.0/24", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "32a6ba0b811278bb", + "asn": "22616", + "ts": "2026-07-15T09:59:53.724Z", + "severity": "CRITICAL", + "unexpected": [ + "137.31.128.0/23", + "140.232.189.0/24", + "137.31.122.0/23", + "137.31.124.0/23", + "137.31.120.0/23", + "2605:4300:e500::/40", + "137.31.101.0/24", + "137.31.119.0/24", + "140.232.188.0/24", + "137.31.126.0/23", + "205.220.14.0/23", + "137.31.24.0/23", + "155.95.86.0/24", + "205.220.12.0/23", + "140.232.190.0/24", + "137.31.114.0/23", + "137.31.26.0/23", + "165.225.99.0/24", + "89.18.89.0/24", + "89.18.90.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing" + }, + { + "id": "9fde972e84297ad9", + "asn": "34702", + "ts": "2026-07-15T08:59:52.292Z", + "severity": "MEDIUM", + "unexpected": [ + "185.200.196.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing" + }, + { + "id": "67491773ba454e26", + "asn": "3491", + "ts": "2026-07-15T07:59:53.053Z", + "severity": "CRITICAL", + "unexpected": [ + "2400:8800:f8f0::/48", + "2400:8800:f8fb::/48", + "2400:8800:f8fa::/48", + "63.219.192.0/24", + "209.8.154.0/23" + ], + "missing": [ + "72.57.243.0/24", + "45.195.111.0/24", + "72.57.240.0/24", + "72.57.250.0/24", + "72.57.244.0/24", + "72.57.249.0/24", + "72.57.246.0/24", + "72.57.248.0/24", + "72.57.241.0/24", + "72.57.253.0/24", + "154.194.34.0/24", + "101.60.254.0/24", + "72.57.247.0/24", + "72.57.254.0/24", + "2400:8800:f827::/48", + "207.176.31.0/24", + "72.57.252.0/24", + "72.57.245.0/24", + "72.57.255.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing" + }, + { + "id": "a46b8ca977e0cfc5", + "asn": "24940", + "ts": "2026-07-15T07:59:51.363Z", + "severity": "HIGH", + "unexpected": [ + "193.105.82.0/24", + "2.29.0.0/16", + "2.28.0.0/16" + ], + "missing": [ + "2a11:e980::/29" + ], + "resolved": false, + "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing" + }, + { + "id": "23031dac4ec96573", + "asn": "15290", + "ts": "2026-07-15T06:59:52.674Z", + "severity": "HIGH", + "unexpected": [], + "missing": [ + "2001:4c8:1005:1::/64", + "209.112.10.0/24", + "2001:4c8:107f:100::/56", + "2001:4c8:1097::/48", + "2001:4c8:1092::/48", + "192.135.87.0/24", + "2001:4c8:4:1::/64", + "216.123.66.0/24", + "2001:4c8:1089::/48", + "216.123.80.0/24", + "203.186.117.0/24", + "206.191.100.0/24", + "2001:4c8:107f:8000::/56", + "216.129.75.0/24", + "203.186.119.0/24", + "74.216.180.0/24", + "209.82.49.0/24", + "2001:4c8:108e::/48", + "159.18.219.0/24", + "209.112.42.0/24", + "2001:4c8:100f:2::/64", + "216.13.190.0/24", + "2001:4c8:108a::/48", + "2001:4c8:1080::/48", + "2001:4c8:101f::/48", + "204.138.183.0/24", + "2001:4c8:e:10::/64", + "216.13.26.0/24", + "2001:4c8:1090::/48", + "216.13.36.0/24", + "216.13.115.0/24", + "74.216.227.0/24", + "2001:4c8:107f:8200::/56", + "2001:4c8:107f:200::/56", + "203.186.116.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing" + }, + { + "id": "f50fd3d9b019f60f", + "asn": "138699", + "ts": "2026-07-15T06:59:51.698Z", + "severity": "HIGH", + "unexpected": [ + "101.45.200.0/23", + "2404:9dc0:cd04::/48", + "101.45.198.0/24", + "2404:9dc0:c002::/48" + ], + "missing": [ + "118.26.132.0/24", + "103.136.222.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing" + }, + { + "id": "5e648fa861caabcb", + "asn": "132372", + "ts": "2026-07-15T05:59:52.969Z", + "severity": "HIGH", + "unexpected": [ + "103.91.194.0/24", + "2a0d:9842::/48" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "aba848e0c0078785", + "asn": "396986", + "ts": "2026-07-15T03:59:51.702Z", + "severity": "CRITICAL", + "unexpected": [ + "71.18.67.0/24", + "101.45.14.0/24", + "2605:340:f04a::/48", + "101.45.202.0/24", + "71.18.44.0/24", + "101.45.15.0/24", + "71.18.45.0/24", + "71.18.66.0/24", + "71.18.64.0/24", + "71.18.71.0/24", + "101.45.6.0/24", + "101.45.199.0/24", + "2605:340:f049::/48", + "101.45.18.0/24", + "71.18.65.0/24", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "44d63311918b4fd7", + "asn": "22616", + "ts": "2026-07-15T03:29:54.203Z", + "severity": "CRITICAL", + "unexpected": [ + "137.31.128.0/23", + "140.232.189.0/24", + "137.31.122.0/23", + "137.31.124.0/23", + "137.31.120.0/23", + "2605:4300:e500::/40", + "137.31.101.0/24", + "137.31.119.0/24", + "140.232.188.0/24", + "137.31.126.0/23", + "205.220.14.0/23", + "137.31.24.0/23", + "155.95.86.0/24", + "205.220.12.0/23", + "140.232.190.0/24", + "137.31.114.0/23", + "137.31.26.0/23", + "165.225.99.0/24", + "89.18.89.0/24", + "89.18.90.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing" + }, + { + "id": "a9658646462e1416", + "asn": "34702", + "ts": "2026-07-15T02:29:52.810Z", + "severity": "MEDIUM", + "unexpected": [ + "185.200.196.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing" + }, + { + "id": "7e19c9cf7db18df2", + "asn": "3491", + "ts": "2026-07-15T01:59:52.321Z", + "severity": "CRITICAL", + "unexpected": [ + "2400:8800:f8fa::/48", + "63.219.192.0/24", + "2400:8800:f8fb::/48", + "2400:8800:f8f0::/48", + "209.8.154.0/23" + ], + "missing": [ + "72.57.243.0/24", + "45.195.111.0/24", + "72.57.240.0/24", + "72.57.250.0/24", + "72.57.244.0/24", + "72.57.249.0/24", + "72.57.246.0/24", + "72.57.248.0/24", + "72.57.241.0/24", + "72.57.253.0/24", + "154.194.34.0/24", + "101.60.254.0/24", + "72.57.247.0/24", + "72.57.254.0/24", + "2400:8800:f827::/48", + "207.176.31.0/24", + "72.57.252.0/24", + "72.57.245.0/24", + "72.57.255.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing" + }, + { + "id": "366e6cbbbc89340c", + "asn": "24940", + "ts": "2026-07-15T01:59:51.361Z", + "severity": "HIGH", + "unexpected": [ + "193.105.82.0/24", + "2.28.0.0/16", + "2.29.0.0/16" + ], + "missing": [ + "2a11:e980::/29" + ], + "resolved": false, + "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing" + }, + { + "id": "268febdf4f8b8be3", + "asn": "15290", + "ts": "2026-07-15T00:59:52.476Z", + "severity": "HIGH", + "unexpected": [], + "missing": [ + "2001:4c8:1005:1::/64", + "209.112.10.0/24", + "2001:4c8:107f:100::/56", + "2001:4c8:1097::/48", + "2001:4c8:1092::/48", + "192.135.87.0/24", + "2001:4c8:4:1::/64", + "216.123.66.0/24", + "2001:4c8:1089::/48", + "216.123.80.0/24", + "203.186.117.0/24", + "206.191.100.0/24", + "2001:4c8:107f:8000::/56", + "216.129.75.0/24", + "203.186.119.0/24", + "74.216.180.0/24", + "209.82.49.0/24", + "2001:4c8:108e::/48", + "159.18.219.0/24", + "209.112.42.0/24", + "2001:4c8:100f:2::/64", + "216.13.190.0/24", + "2001:4c8:108a::/48", + "2001:4c8:1080::/48", + "2001:4c8:101f::/48", + "204.138.183.0/24", + "2001:4c8:e:10::/64", + "216.13.26.0/24", + "2001:4c8:1090::/48", + "216.13.36.0/24", + "216.13.115.0/24", + "74.216.227.0/24", + "2001:4c8:107f:8200::/56", + "2001:4c8:107f:200::/56", + "203.186.116.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing" + }, + { + "id": "0b0a20947ca1b4a1", + "asn": "138699", + "ts": "2026-07-15T00:59:51.661Z", + "severity": "HIGH", + "unexpected": [ + "2404:9dc0:cd04::/48", + "2404:9dc0:c002::/48", + "101.45.198.0/24", + "101.45.200.0/23" + ], + "missing": [ + "118.26.132.0/24", + "103.136.222.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing" + }, + { + "id": "9a4fa58e438b1a35", + "asn": "132372", + "ts": "2026-07-14T23:29:56.722Z", + "severity": "HIGH", + "unexpected": [ + "103.91.194.0/24", + "2a0d:9842::/48" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "acedee638859fec5", + "asn": "396986", + "ts": "2026-07-14T21:59:51.677Z", + "severity": "CRITICAL", + "unexpected": [ + "101.45.15.0/24", + "101.45.6.0/24", + "71.18.66.0/24", + "2605:340:f04a::/48", + "71.18.64.0/24", + "2605:340:f049::/48", + "71.18.67.0/24", + "101.45.199.0/24", + "101.45.14.0/24", + "101.45.18.0/24", + "101.45.202.0/24", + "71.18.44.0/24", + "71.18.45.0/24", + "71.18.65.0/24", + "71.18.71.0/24", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "3bbe047e16df0480", + "asn": "22616", + "ts": "2026-07-14T20:59:54.362Z", + "severity": "CRITICAL", + "unexpected": [ + "140.232.189.0/24", + "137.31.101.0/24", + "205.220.12.0/23", + "155.95.86.0/24", + "140.232.190.0/24", + "137.31.24.0/23", + "137.31.128.0/23", + "140.232.188.0/24", + "137.31.119.0/24", + "2605:4300:e500::/40", + "205.220.14.0/23", + "137.31.122.0/23", + "137.31.126.0/23", + "137.31.114.0/23", + "137.31.124.0/23", + "137.31.120.0/23", + "137.31.26.0/23", + "165.225.99.0/24", + "89.18.89.0/24", + "89.18.90.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing" + }, + { + "id": "ac981860008fae42", + "asn": "34702", + "ts": "2026-07-14T19:59:52.842Z", + "severity": "MEDIUM", + "unexpected": [ + "185.200.196.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing" + }, + { + "id": "e3fca5a660644a47", + "asn": "3491", + "ts": "2026-07-14T19:59:52.246Z", + "severity": "CRITICAL", + "unexpected": [ + "2400:8800:f8fa::/48", + "63.219.192.0/24", + "2400:8800:f8fb::/48", + "2400:8800:f8f0::/48", + "209.8.154.0/23" + ], + "missing": [ + "72.57.243.0/24", + "45.195.111.0/24", + "72.57.240.0/24", + "72.57.250.0/24", + "72.57.244.0/24", + "72.57.249.0/24", + "72.57.246.0/24", + "72.57.248.0/24", + "72.57.241.0/24", + "72.57.253.0/24", + "154.194.34.0/24", + "101.60.254.0/24", + "72.57.247.0/24", + "72.57.254.0/24", + "2400:8800:f827::/48", + "207.176.31.0/24", + "72.57.252.0/24", + "72.57.245.0/24", + "72.57.255.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing" + }, + { + "id": "9f602b507e053fe9", + "asn": "24940", + "ts": "2026-07-14T19:59:51.360Z", + "severity": "HIGH", + "unexpected": [ + "193.105.82.0/24", + "2.28.0.0/16", + "2.29.0.0/16" + ], + "missing": [ + "2a11:e980::/29" + ], + "resolved": false, + "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing" + }, + { + "id": "9a45eded7bbde30e", + "asn": "15290", + "ts": "2026-07-14T18:29:52.554Z", + "severity": "HIGH", + "unexpected": [], + "missing": [ + "2001:4c8:1005:1::/64", + "209.112.10.0/24", + "2001:4c8:107f:100::/56", + "2001:4c8:1097::/48", + "2001:4c8:1092::/48", + "192.135.87.0/24", + "2001:4c8:4:1::/64", + "216.123.66.0/24", + "2001:4c8:1089::/48", + "216.123.80.0/24", + "203.186.117.0/24", + "206.191.100.0/24", + "2001:4c8:107f:8000::/56", + "216.129.75.0/24", + "203.186.119.0/24", + "74.216.180.0/24", + "209.82.49.0/24", + "2001:4c8:108e::/48", + "159.18.219.0/24", + "209.112.42.0/24", + "2001:4c8:100f:2::/64", + "216.13.190.0/24", + "2001:4c8:108a::/48", + "2001:4c8:1080::/48", + "2001:4c8:101f::/48", + "204.138.183.0/24", + "2001:4c8:e:10::/64", + "216.13.26.0/24", + "2001:4c8:1090::/48", + "216.13.36.0/24", + "216.13.115.0/24", + "74.216.227.0/24", + "2001:4c8:107f:8200::/56", + "2001:4c8:107f:200::/56", + "203.186.116.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing" + }, + { + "id": "8169c1fdd2bff25f", + "asn": "138699", + "ts": "2026-07-14T18:29:51.705Z", + "severity": "HIGH", + "unexpected": [ + "2404:9dc0:cd04::/48", + "101.45.198.0/24", + "101.45.200.0/23", + "2404:9dc0:c002::/48" + ], + "missing": [ + "118.26.132.0/24", + "103.136.222.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing" + }, + { + "id": "8ea2453d73ab95a8", + "asn": "132372", + "ts": "2026-07-14T16:59:53.436Z", + "severity": "HIGH", + "unexpected": [ + "2a0d:9842::/48", + "103.91.194.0/24" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "7156c0cd3d01adeb", + "asn": "396986", + "ts": "2026-07-14T15:29:52.006Z", + "severity": "CRITICAL", + "unexpected": [ + "101.45.14.0/24", + "71.18.45.0/24", + "101.45.6.0/24", + "71.18.65.0/24", + "71.18.67.0/24", + "71.18.64.0/24", + "101.45.202.0/24", + "101.45.18.0/24", + "2605:340:f04a::/48", + "71.18.71.0/24", + "71.18.66.0/24", + "71.18.44.0/24", + "101.45.15.0/24", + "101.45.199.0/24", + "2605:340:f049::/48", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + } + ] + } + }, + "hijack-alerts": { + "status": 200, + "body": { + "asn": "", + "alerts": [ + { + "id": "7156c0cd3d01adeb", + "asn": "396986", + "ts": "2026-07-14T15:29:52.006Z", + "severity": "CRITICAL", + "unexpected": [ + "101.45.14.0/24", + "71.18.45.0/24", + "101.45.6.0/24", + "71.18.65.0/24", + "71.18.67.0/24", + "71.18.64.0/24", + "101.45.202.0/24", + "101.45.18.0/24", + "2605:340:f04a::/48", + "71.18.71.0/24", + "71.18.66.0/24", + "71.18.44.0/24", + "101.45.15.0/24", + "101.45.199.0/24", + "2605:340:f049::/48", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "8ea2453d73ab95a8", + "asn": "132372", + "ts": "2026-07-14T16:59:53.436Z", + "severity": "HIGH", + "unexpected": [ + "2a0d:9842::/48", + "103.91.194.0/24" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "8169c1fdd2bff25f", + "asn": "138699", + "ts": "2026-07-14T18:29:51.705Z", + "severity": "HIGH", + "unexpected": [ + "2404:9dc0:cd04::/48", + "101.45.198.0/24", + "101.45.200.0/23", + "2404:9dc0:c002::/48" + ], + "missing": [ + "118.26.132.0/24", + "103.136.222.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing" + }, + { + "id": "9a45eded7bbde30e", + "asn": "15290", + "ts": "2026-07-14T18:29:52.554Z", + "severity": "HIGH", + "unexpected": [], + "missing": [ + "2001:4c8:1005:1::/64", + "209.112.10.0/24", + "2001:4c8:107f:100::/56", + "2001:4c8:1097::/48", + "2001:4c8:1092::/48", + "192.135.87.0/24", + "2001:4c8:4:1::/64", + "216.123.66.0/24", + "2001:4c8:1089::/48", + "216.123.80.0/24", + "203.186.117.0/24", + "206.191.100.0/24", + "2001:4c8:107f:8000::/56", + "216.129.75.0/24", + "203.186.119.0/24", + "74.216.180.0/24", + "209.82.49.0/24", + "2001:4c8:108e::/48", + "159.18.219.0/24", + "209.112.42.0/24", + "2001:4c8:100f:2::/64", + "216.13.190.0/24", + "2001:4c8:108a::/48", + "2001:4c8:1080::/48", + "2001:4c8:101f::/48", + "204.138.183.0/24", + "2001:4c8:e:10::/64", + "216.13.26.0/24", + "2001:4c8:1090::/48", + "216.13.36.0/24", + "216.13.115.0/24", + "74.216.227.0/24", + "2001:4c8:107f:8200::/56", + "2001:4c8:107f:200::/56", + "203.186.116.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing" + }, + { + "id": "9f602b507e053fe9", + "asn": "24940", + "ts": "2026-07-14T19:59:51.360Z", + "severity": "HIGH", + "unexpected": [ + "193.105.82.0/24", + "2.28.0.0/16", + "2.29.0.0/16" + ], + "missing": [ + "2a11:e980::/29" + ], + "resolved": false, + "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing" + }, + { + "id": "e3fca5a660644a47", + "asn": "3491", + "ts": "2026-07-14T19:59:52.246Z", + "severity": "CRITICAL", + "unexpected": [ + "2400:8800:f8fa::/48", + "63.219.192.0/24", + "2400:8800:f8fb::/48", + "2400:8800:f8f0::/48", + "209.8.154.0/23" + ], + "missing": [ + "72.57.243.0/24", + "45.195.111.0/24", + "72.57.240.0/24", + "72.57.250.0/24", + "72.57.244.0/24", + "72.57.249.0/24", + "72.57.246.0/24", + "72.57.248.0/24", + "72.57.241.0/24", + "72.57.253.0/24", + "154.194.34.0/24", + "101.60.254.0/24", + "72.57.247.0/24", + "72.57.254.0/24", + "2400:8800:f827::/48", + "207.176.31.0/24", + "72.57.252.0/24", + "72.57.245.0/24", + "72.57.255.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing" + }, + { + "id": "ac981860008fae42", + "asn": "34702", + "ts": "2026-07-14T19:59:52.842Z", + "severity": "MEDIUM", + "unexpected": [ + "185.200.196.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing" + }, + { + "id": "3bbe047e16df0480", + "asn": "22616", + "ts": "2026-07-14T20:59:54.362Z", + "severity": "CRITICAL", + "unexpected": [ + "140.232.189.0/24", + "137.31.101.0/24", + "205.220.12.0/23", + "155.95.86.0/24", + "140.232.190.0/24", + "137.31.24.0/23", + "137.31.128.0/23", + "140.232.188.0/24", + "137.31.119.0/24", + "2605:4300:e500::/40", + "205.220.14.0/23", + "137.31.122.0/23", + "137.31.126.0/23", + "137.31.114.0/23", + "137.31.124.0/23", + "137.31.120.0/23", + "137.31.26.0/23", + "165.225.99.0/24", + "89.18.89.0/24", + "89.18.90.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing" + }, + { + "id": "acedee638859fec5", + "asn": "396986", + "ts": "2026-07-14T21:59:51.677Z", + "severity": "CRITICAL", + "unexpected": [ + "101.45.15.0/24", + "101.45.6.0/24", + "71.18.66.0/24", + "2605:340:f04a::/48", + "71.18.64.0/24", + "2605:340:f049::/48", + "71.18.67.0/24", + "101.45.199.0/24", + "101.45.14.0/24", + "101.45.18.0/24", + "101.45.202.0/24", + "71.18.44.0/24", + "71.18.45.0/24", + "71.18.65.0/24", + "71.18.71.0/24", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "9a4fa58e438b1a35", + "asn": "132372", + "ts": "2026-07-14T23:29:56.722Z", + "severity": "HIGH", + "unexpected": [ + "103.91.194.0/24", + "2a0d:9842::/48" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "0b0a20947ca1b4a1", + "asn": "138699", + "ts": "2026-07-15T00:59:51.661Z", + "severity": "HIGH", + "unexpected": [ + "2404:9dc0:cd04::/48", + "2404:9dc0:c002::/48", + "101.45.198.0/24", + "101.45.200.0/23" + ], + "missing": [ + "118.26.132.0/24", + "103.136.222.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing" + }, + { + "id": "268febdf4f8b8be3", + "asn": "15290", + "ts": "2026-07-15T00:59:52.476Z", + "severity": "HIGH", + "unexpected": [], + "missing": [ + "2001:4c8:1005:1::/64", + "209.112.10.0/24", + "2001:4c8:107f:100::/56", + "2001:4c8:1097::/48", + "2001:4c8:1092::/48", + "192.135.87.0/24", + "2001:4c8:4:1::/64", + "216.123.66.0/24", + "2001:4c8:1089::/48", + "216.123.80.0/24", + "203.186.117.0/24", + "206.191.100.0/24", + "2001:4c8:107f:8000::/56", + "216.129.75.0/24", + "203.186.119.0/24", + "74.216.180.0/24", + "209.82.49.0/24", + "2001:4c8:108e::/48", + "159.18.219.0/24", + "209.112.42.0/24", + "2001:4c8:100f:2::/64", + "216.13.190.0/24", + "2001:4c8:108a::/48", + "2001:4c8:1080::/48", + "2001:4c8:101f::/48", + "204.138.183.0/24", + "2001:4c8:e:10::/64", + "216.13.26.0/24", + "2001:4c8:1090::/48", + "216.13.36.0/24", + "216.13.115.0/24", + "74.216.227.0/24", + "2001:4c8:107f:8200::/56", + "2001:4c8:107f:200::/56", + "203.186.116.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing" + }, + { + "id": "366e6cbbbc89340c", + "asn": "24940", + "ts": "2026-07-15T01:59:51.361Z", + "severity": "HIGH", + "unexpected": [ + "193.105.82.0/24", + "2.28.0.0/16", + "2.29.0.0/16" + ], + "missing": [ + "2a11:e980::/29" + ], + "resolved": false, + "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing" + }, + { + "id": "7e19c9cf7db18df2", + "asn": "3491", + "ts": "2026-07-15T01:59:52.321Z", + "severity": "CRITICAL", + "unexpected": [ + "2400:8800:f8fa::/48", + "63.219.192.0/24", + "2400:8800:f8fb::/48", + "2400:8800:f8f0::/48", + "209.8.154.0/23" + ], + "missing": [ + "72.57.243.0/24", + "45.195.111.0/24", + "72.57.240.0/24", + "72.57.250.0/24", + "72.57.244.0/24", + "72.57.249.0/24", + "72.57.246.0/24", + "72.57.248.0/24", + "72.57.241.0/24", + "72.57.253.0/24", + "154.194.34.0/24", + "101.60.254.0/24", + "72.57.247.0/24", + "72.57.254.0/24", + "2400:8800:f827::/48", + "207.176.31.0/24", + "72.57.252.0/24", + "72.57.245.0/24", + "72.57.255.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing" + }, + { + "id": "a9658646462e1416", + "asn": "34702", + "ts": "2026-07-15T02:29:52.810Z", + "severity": "MEDIUM", + "unexpected": [ + "185.200.196.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing" + }, + { + "id": "44d63311918b4fd7", + "asn": "22616", + "ts": "2026-07-15T03:29:54.203Z", + "severity": "CRITICAL", + "unexpected": [ + "137.31.128.0/23", + "140.232.189.0/24", + "137.31.122.0/23", + "137.31.124.0/23", + "137.31.120.0/23", + "2605:4300:e500::/40", + "137.31.101.0/24", + "137.31.119.0/24", + "140.232.188.0/24", + "137.31.126.0/23", + "205.220.14.0/23", + "137.31.24.0/23", + "155.95.86.0/24", + "205.220.12.0/23", + "140.232.190.0/24", + "137.31.114.0/23", + "137.31.26.0/23", + "165.225.99.0/24", + "89.18.89.0/24", + "89.18.90.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing" + }, + { + "id": "aba848e0c0078785", + "asn": "396986", + "ts": "2026-07-15T03:59:51.702Z", + "severity": "CRITICAL", + "unexpected": [ + "71.18.67.0/24", + "101.45.14.0/24", + "2605:340:f04a::/48", + "101.45.202.0/24", + "71.18.44.0/24", + "101.45.15.0/24", + "71.18.45.0/24", + "71.18.66.0/24", + "71.18.64.0/24", + "71.18.71.0/24", + "101.45.6.0/24", + "101.45.199.0/24", + "2605:340:f049::/48", + "101.45.18.0/24", + "71.18.65.0/24", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "5e648fa861caabcb", + "asn": "132372", + "ts": "2026-07-15T05:59:52.969Z", + "severity": "HIGH", + "unexpected": [ + "103.91.194.0/24", + "2a0d:9842::/48" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "f50fd3d9b019f60f", + "asn": "138699", + "ts": "2026-07-15T06:59:51.698Z", + "severity": "HIGH", + "unexpected": [ + "101.45.200.0/23", + "2404:9dc0:cd04::/48", + "101.45.198.0/24", + "2404:9dc0:c002::/48" + ], + "missing": [ + "118.26.132.0/24", + "103.136.222.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing" + }, + { + "id": "23031dac4ec96573", + "asn": "15290", + "ts": "2026-07-15T06:59:52.674Z", + "severity": "HIGH", + "unexpected": [], + "missing": [ + "2001:4c8:1005:1::/64", + "209.112.10.0/24", + "2001:4c8:107f:100::/56", + "2001:4c8:1097::/48", + "2001:4c8:1092::/48", + "192.135.87.0/24", + "2001:4c8:4:1::/64", + "216.123.66.0/24", + "2001:4c8:1089::/48", + "216.123.80.0/24", + "203.186.117.0/24", + "206.191.100.0/24", + "2001:4c8:107f:8000::/56", + "216.129.75.0/24", + "203.186.119.0/24", + "74.216.180.0/24", + "209.82.49.0/24", + "2001:4c8:108e::/48", + "159.18.219.0/24", + "209.112.42.0/24", + "2001:4c8:100f:2::/64", + "216.13.190.0/24", + "2001:4c8:108a::/48", + "2001:4c8:1080::/48", + "2001:4c8:101f::/48", + "204.138.183.0/24", + "2001:4c8:e:10::/64", + "216.13.26.0/24", + "2001:4c8:1090::/48", + "216.13.36.0/24", + "216.13.115.0/24", + "74.216.227.0/24", + "2001:4c8:107f:8200::/56", + "2001:4c8:107f:200::/56", + "203.186.116.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing" + }, + { + "id": "a46b8ca977e0cfc5", + "asn": "24940", + "ts": "2026-07-15T07:59:51.363Z", + "severity": "HIGH", + "unexpected": [ + "193.105.82.0/24", + "2.29.0.0/16", + "2.28.0.0/16" + ], + "missing": [ + "2a11:e980::/29" + ], + "resolved": false, + "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing" + }, + { + "id": "67491773ba454e26", + "asn": "3491", + "ts": "2026-07-15T07:59:53.053Z", + "severity": "CRITICAL", + "unexpected": [ + "2400:8800:f8f0::/48", + "2400:8800:f8fb::/48", + "2400:8800:f8fa::/48", + "63.219.192.0/24", + "209.8.154.0/23" + ], + "missing": [ + "72.57.243.0/24", + "45.195.111.0/24", + "72.57.240.0/24", + "72.57.250.0/24", + "72.57.244.0/24", + "72.57.249.0/24", + "72.57.246.0/24", + "72.57.248.0/24", + "72.57.241.0/24", + "72.57.253.0/24", + "154.194.34.0/24", + "101.60.254.0/24", + "72.57.247.0/24", + "72.57.254.0/24", + "2400:8800:f827::/48", + "207.176.31.0/24", + "72.57.252.0/24", + "72.57.245.0/24", + "72.57.255.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing" + }, + { + "id": "9fde972e84297ad9", + "asn": "34702", + "ts": "2026-07-15T08:59:52.292Z", + "severity": "MEDIUM", + "unexpected": [ + "185.200.196.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing" + }, + { + "id": "32a6ba0b811278bb", + "asn": "22616", + "ts": "2026-07-15T09:59:53.724Z", + "severity": "CRITICAL", + "unexpected": [ + "137.31.128.0/23", + "140.232.189.0/24", + "137.31.122.0/23", + "137.31.124.0/23", + "137.31.120.0/23", + "2605:4300:e500::/40", + "137.31.101.0/24", + "137.31.119.0/24", + "140.232.188.0/24", + "137.31.126.0/23", + "205.220.14.0/23", + "137.31.24.0/23", + "155.95.86.0/24", + "205.220.12.0/23", + "140.232.190.0/24", + "137.31.114.0/23", + "137.31.26.0/23", + "165.225.99.0/24", + "89.18.89.0/24", + "89.18.90.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing" + }, + { + "id": "b72903d54d8d8705", + "asn": "396986", + "ts": "2026-07-15T10:29:51.682Z", + "severity": "CRITICAL", + "unexpected": [ + "71.18.67.0/24", + "101.45.14.0/24", + "2605:340:f04a::/48", + "101.45.202.0/24", + "71.18.44.0/24", + "101.45.15.0/24", + "71.18.45.0/24", + "71.18.66.0/24", + "71.18.64.0/24", + "71.18.71.0/24", + "101.45.6.0/24", + "101.45.199.0/24", + "2605:340:f049::/48", + "101.45.18.0/24", + "71.18.65.0/24", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "4a2318801159ada4", + "asn": "132372", + "ts": "2026-07-15T11:59:53.119Z", + "severity": "HIGH", + "unexpected": [ + "2a0d:9842::/48", + "103.91.194.0/24" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "8fdaa6a50cb01891", + "asn": "138699", + "ts": "2026-07-15T13:29:51.615Z", + "severity": "HIGH", + "unexpected": [ + "101.45.198.0/24", + "2404:9dc0:c002::/48", + "101.45.200.0/23", + "2404:9dc0:cd04::/48" + ], + "missing": [ + "118.26.132.0/24", + "103.136.222.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing" + }, + { + "id": "5943d1de345838ac", + "asn": "15290", + "ts": "2026-07-15T13:29:52.431Z", + "severity": "HIGH", + "unexpected": [], + "missing": [ + "2001:4c8:1005:1::/64", + "209.112.10.0/24", + "2001:4c8:107f:100::/56", + "2001:4c8:1097::/48", + "2001:4c8:1092::/48", + "192.135.87.0/24", + "2001:4c8:4:1::/64", + "216.123.66.0/24", + "2001:4c8:1089::/48", + "216.123.80.0/24", + "203.186.117.0/24", + "206.191.100.0/24", + "2001:4c8:107f:8000::/56", + "216.129.75.0/24", + "203.186.119.0/24", + "74.216.180.0/24", + "209.82.49.0/24", + "2001:4c8:108e::/48", + "159.18.219.0/24", + "209.112.42.0/24", + "2001:4c8:100f:2::/64", + "216.13.190.0/24", + "2001:4c8:108a::/48", + "2001:4c8:1080::/48", + "2001:4c8:101f::/48", + "204.138.183.0/24", + "2001:4c8:e:10::/64", + "216.13.26.0/24", + "2001:4c8:1090::/48", + "216.13.36.0/24", + "216.13.115.0/24", + "74.216.227.0/24", + "2001:4c8:107f:8200::/56", + "2001:4c8:107f:200::/56", + "203.186.116.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing" + }, + { + "id": "11d64812f7d25748", + "asn": "24940", + "ts": "2026-07-15T13:59:51.366Z", + "severity": "HIGH", + "unexpected": [ + "2.29.0.0/16", + "2.28.0.0/16", + "193.105.82.0/24" + ], + "missing": [ + "2a11:e980::/29" + ], + "resolved": false, + "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing" + }, + { + "id": "1275ffa07a209ece", + "asn": "3491", + "ts": "2026-07-15T14:29:51.794Z", + "severity": "CRITICAL", + "unexpected": [ + "2400:8800:f8f0::/48", + "2400:8800:f8fa::/48", + "63.219.192.0/24", + "2400:8800:f8fb::/48", + "209.8.154.0/23" + ], + "missing": [ + "72.57.243.0/24", + "45.195.111.0/24", + "72.57.240.0/24", + "72.57.250.0/24", + "72.57.244.0/24", + "72.57.249.0/24", + "72.57.246.0/24", + "72.57.248.0/24", + "72.57.241.0/24", + "72.57.253.0/24", + "154.194.34.0/24", + "101.60.254.0/24", + "72.57.247.0/24", + "72.57.254.0/24", + "2400:8800:f827::/48", + "207.176.31.0/24", + "72.57.252.0/24", + "72.57.245.0/24", + "72.57.255.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing" + }, + { + "id": "8df39b422cdb9693", + "asn": "34702", + "ts": "2026-07-15T14:59:52.749Z", + "severity": "MEDIUM", + "unexpected": [ + "185.200.196.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing" + }, + { + "id": "ef7fe7860371b6c7", + "asn": "396986", + "ts": "2026-07-15T16:29:51.704Z", + "severity": "CRITICAL", + "unexpected": [ + "101.45.6.0/24", + "71.18.71.0/24", + "71.18.65.0/24", + "71.18.64.0/24", + "101.45.18.0/24", + "2605:340:f049::/48", + "71.18.45.0/24", + "101.45.15.0/24", + "71.18.66.0/24", + "101.45.202.0/24", + "2605:340:f04a::/48", + "71.18.44.0/24", + "101.45.199.0/24", + "71.18.67.0/24", + "101.45.14.0/24", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "14d4c48ce7317e03", + "asn": "22616", + "ts": "2026-07-15T16:29:53.752Z", + "severity": "CRITICAL", + "unexpected": [ + "140.232.189.0/24", + "140.232.190.0/24", + "155.95.86.0/24", + "140.232.188.0/24", + "137.31.119.0/24", + "2605:4300:e500::/40", + "137.31.128.0/23", + "137.31.24.0/23", + "165.225.99.0/24", + "137.31.101.0/24", + "205.220.12.0/23", + "137.31.120.0/23", + "137.31.124.0/23", + "137.31.26.0/23", + "137.31.122.0/23", + "137.31.126.0/23", + "137.31.114.0/23", + "205.220.14.0/23", + "89.18.89.0/24", + "89.18.90.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing" + }, + { + "id": "3c915ae1bf8a755e", + "asn": "132372", + "ts": "2026-07-15T18:29:53.011Z", + "severity": "HIGH", + "unexpected": [ + "2a0d:9842::/48", + "103.91.194.0/24" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "fe4018f500c85f61", + "asn": "138699", + "ts": "2026-07-15T19:29:52.219Z", + "severity": "HIGH", + "unexpected": [ + "101.45.200.0/23", + "2404:9dc0:cd04::/48", + "2404:9dc0:c002::/48", + "101.45.198.0/24" + ], + "missing": [ + "118.26.132.0/24", + "103.136.222.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing" + }, + { + "id": "bc655a6f3f565c2b", + "asn": "15290", + "ts": "2026-07-15T19:29:53.248Z", + "severity": "HIGH", + "unexpected": [], + "missing": [ + "2001:4c8:1005:1::/64", + "209.112.10.0/24", + "2001:4c8:107f:100::/56", + "2001:4c8:1097::/48", + "2001:4c8:1092::/48", + "192.135.87.0/24", + "2001:4c8:4:1::/64", + "216.123.66.0/24", + "2001:4c8:1089::/48", + "216.123.80.0/24", + "203.186.117.0/24", + "206.191.100.0/24", + "2001:4c8:107f:8000::/56", + "216.129.75.0/24", + "203.186.119.0/24", + "74.216.180.0/24", + "209.82.49.0/24", + "2001:4c8:108e::/48", + "159.18.219.0/24", + "209.112.42.0/24", + "2001:4c8:100f:2::/64", + "216.13.190.0/24", + "2001:4c8:108a::/48", + "2001:4c8:1080::/48", + "2001:4c8:101f::/48", + "204.138.183.0/24", + "2001:4c8:e:10::/64", + "216.13.26.0/24", + "2001:4c8:1090::/48", + "216.13.36.0/24", + "216.13.115.0/24", + "74.216.227.0/24", + "2001:4c8:107f:8200::/56", + "2001:4c8:107f:200::/56", + "203.186.116.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing" + }, + { + "id": "341a4a5416d53fb4", + "asn": "24940", + "ts": "2026-07-15T19:59:51.372Z", + "severity": "HIGH", + "unexpected": [ + "2.29.0.0/16", + "193.105.82.0/24", + "2.28.0.0/16" + ], + "missing": [ + "2a11:e980::/29" + ], + "resolved": false, + "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing" + }, + { + "id": "0739880a09b2e60e", + "asn": "3491", + "ts": "2026-07-15T20:59:51.838Z", + "severity": "CRITICAL", + "unexpected": [ + "2400:8800:f8fb::/48", + "63.219.192.0/24", + "2400:8800:f8fa::/48", + "2400:8800:f8f0::/48", + "209.8.154.0/23" + ], + "missing": [ + "72.57.243.0/24", + "45.195.111.0/24", + "72.57.240.0/24", + "72.57.250.0/24", + "72.57.244.0/24", + "72.57.249.0/24", + "72.57.246.0/24", + "72.57.248.0/24", + "72.57.241.0/24", + "72.57.253.0/24", + "154.194.34.0/24", + "101.60.254.0/24", + "72.57.247.0/24", + "72.57.254.0/24", + "2400:8800:f827::/48", + "207.176.31.0/24", + "72.57.252.0/24", + "72.57.245.0/24", + "72.57.255.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing" + }, + { + "id": "176f637eb9f4b8a0", + "asn": "34702", + "ts": "2026-07-15T21:29:53.213Z", + "severity": "MEDIUM", + "unexpected": [ + "185.200.196.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing" + }, + { + "id": "3497eb91f0a1804b", + "asn": "396986", + "ts": "2026-07-15T22:29:51.711Z", + "severity": "CRITICAL", + "unexpected": [ + "2605:340:f04a::/48", + "101.45.199.0/24", + "71.18.65.0/24", + "71.18.66.0/24", + "71.18.71.0/24", + "71.18.67.0/24", + "71.18.44.0/24", + "101.45.15.0/24", + "101.45.6.0/24", + "101.45.202.0/24", + "2605:340:f049::/48", + "101.45.14.0/24", + "101.45.18.0/24", + "71.18.45.0/24", + "71.18.64.0/24", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "abf9968f49c729b7", + "asn": "22616", + "ts": "2026-07-15T22:29:53.837Z", + "severity": "CRITICAL", + "unexpected": [ + "140.232.189.0/24", + "137.31.114.0/23", + "155.95.86.0/24", + "165.225.99.0/24", + "137.31.124.0/23", + "2605:4300:e500::/40", + "137.31.101.0/24", + "205.220.12.0/23", + "137.31.119.0/24", + "205.220.14.0/23", + "137.31.128.0/23", + "137.31.26.0/23", + "137.31.120.0/23", + "137.31.24.0/23", + "140.232.190.0/24", + "140.232.188.0/24", + "137.31.122.0/23", + "137.31.126.0/23", + "89.18.89.0/24", + "89.18.90.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing" + }, + { + "id": "db1240cf335bd1fb", + "asn": "132372", + "ts": "2026-07-16T00:29:53.114Z", + "severity": "HIGH", + "unexpected": [ + "2a0d:9842::/48", + "103.91.194.0/24" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + }, + { + "id": "6daaace7b94cbf39", + "asn": "24940", + "ts": "2026-07-16T01:59:51.622Z", + "severity": "HIGH", + "unexpected": [ + "2.29.0.0/16", + "193.105.82.0/24", + "2.28.0.0/16" + ], + "missing": [ + "2a11:e980::/29" + ], + "resolved": false, + "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing" + }, + { + "id": "b91f3e602fd81ca1", + "asn": "138699", + "ts": "2026-07-16T01:59:52.148Z", + "severity": "HIGH", + "unexpected": [ + "101.45.200.0/23", + "2404:9dc0:cd04::/48", + "2404:9dc0:c002::/48", + "101.45.198.0/24" + ], + "missing": [ + "118.26.132.0/24", + "103.136.222.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing" + }, + { + "id": "8b02d291a1af8c3d", + "asn": "15290", + "ts": "2026-07-16T01:59:53.019Z", + "severity": "HIGH", + "unexpected": [], + "missing": [ + "2001:4c8:1005:1::/64", + "209.112.10.0/24", + "2001:4c8:107f:100::/56", + "2001:4c8:1097::/48", + "2001:4c8:1092::/48", + "192.135.87.0/24", + "2001:4c8:4:1::/64", + "216.123.66.0/24", + "2001:4c8:1089::/48", + "216.123.80.0/24", + "203.186.117.0/24", + "206.191.100.0/24", + "2001:4c8:107f:8000::/56", + "216.129.75.0/24", + "203.186.119.0/24", + "74.216.180.0/24", + "209.82.49.0/24", + "2001:4c8:108e::/48", + "159.18.219.0/24", + "209.112.42.0/24", + "2001:4c8:100f:2::/64", + "216.13.190.0/24", + "2001:4c8:108a::/48", + "2001:4c8:1080::/48", + "2001:4c8:101f::/48", + "204.138.183.0/24", + "2001:4c8:e:10::/64", + "216.13.26.0/24", + "2001:4c8:1090::/48", + "216.13.36.0/24", + "216.13.115.0/24", + "74.216.227.0/24", + "2001:4c8:107f:8200::/56", + "2001:4c8:107f:200::/56", + "203.186.116.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing" + }, + { + "id": "496424b0fc064555", + "asn": "3491", + "ts": "2026-07-16T02:59:52.248Z", + "severity": "CRITICAL", + "unexpected": [ + "63.219.192.0/24", + "2400:8800:f8fa::/48", + "2400:8800:f8fb::/48", + "2400:8800:f8f0::/48", + "209.8.154.0/23" + ], + "missing": [ + "72.57.243.0/24", + "45.195.111.0/24", + "72.57.240.0/24", + "72.57.250.0/24", + "72.57.244.0/24", + "72.57.249.0/24", + "72.57.246.0/24", + "72.57.248.0/24", + "72.57.241.0/24", + "72.57.253.0/24", + "154.194.34.0/24", + "101.60.254.0/24", + "72.57.247.0/24", + "72.57.254.0/24", + "2400:8800:f827::/48", + "207.176.31.0/24", + "72.57.252.0/24", + "72.57.245.0/24", + "72.57.255.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing" + }, + { + "id": "9088627d62eade29", + "asn": "34702", + "ts": "2026-07-16T03:59:52.690Z", + "severity": "MEDIUM", + "unexpected": [ + "185.200.196.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing" + }, + { + "id": "6dd656f19f6a1924", + "asn": "396986", + "ts": "2026-07-16T04:29:51.725Z", + "severity": "CRITICAL", + "unexpected": [ + "101.45.18.0/24", + "101.45.199.0/24", + "2605:340:f04a::/48", + "101.45.202.0/24", + "71.18.66.0/24", + "101.45.15.0/24", + "71.18.44.0/24", + "101.45.6.0/24", + "71.18.45.0/24", + "71.18.64.0/24", + "71.18.71.0/24", + "71.18.67.0/24", + "71.18.65.0/24", + "2605:340:f049::/48", + "101.45.14.0/24", + "101.45.23.0/24" + ], + "missing": [ + "71.18.138.0/23", + "2605:340:f082::/48", + "71.18.64.0/21", + "71.18.233.0/24", + "2605:340:f051::/48" + ], + "resolved": false, + "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing" + }, + { + "id": "5d4e90a8549f3771", + "asn": "22616", + "ts": "2026-07-16T04:59:53.845Z", + "severity": "CRITICAL", + "unexpected": [ + "137.31.24.0/23", + "137.31.101.0/24", + "205.220.14.0/23", + "137.31.120.0/23", + "137.31.114.0/23", + "137.31.119.0/24", + "2605:4300:e500::/40", + "137.31.26.0/23", + "137.31.126.0/23", + "137.31.122.0/23", + "140.232.189.0/24", + "137.31.128.0/23", + "140.232.190.0/24", + "137.31.124.0/23", + "155.95.86.0/24", + "165.225.99.0/24", + "205.220.12.0/23", + "140.232.188.0/24", + "89.18.89.0/24", + "89.18.90.0/24" + ], + "missing": [], + "resolved": false, + "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing" + }, + { + "id": "c37eb29c5c54c5a1", + "asn": "132372", + "ts": "2026-07-16T06:29:59.070Z", + "severity": "HIGH", + "unexpected": [ + "103.91.194.0/24", + "2a0d:9842::/48" + ], + "missing": [ + "172.94.65.0/24", + "172.94.34.0/24", + "172.94.97.0/24", + "172.94.29.0/24", + "172.94.99.0/24" + ], + "resolved": false, + "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing" + } + ], + "monitoring": false, + "prefix_count": 0 + } + }, + "aspa-adoption-page": { + "status": 200, + "body": { + "__non_json__": true, + "status": 200, + "bodyLength": 7059 + } + }, + "aspa-adoption-stats": { + "status": 200, + "body": { + "period": "30d", + "current": { + "date": "2026-07-17" + }, + "trend": [ + { + "date": "2026-06-17" + }, + { + "date": "2026-06-18" + }, + { + "date": "2026-06-19" + }, + { + "date": "2026-06-20" + }, + { + "date": "2026-06-21" + }, + { + "date": "2026-06-22" + }, + { + "date": "2026-06-23" + }, + { + "date": "2026-06-24" + }, + { + "date": "2026-06-25" + }, + { + "date": "2026-06-26" + }, + { + "date": "2026-06-27" + }, + { + "date": "2026-06-28" + }, + { + "date": "2026-06-29" + }, + { + "date": "2026-06-30" + }, + { + "date": "2026-07-01" + }, + { + "date": "2026-07-02" + }, + { + "date": "2026-07-03" + }, + { + "date": "2026-07-04" + }, + { + "date": "2026-07-05" + }, + { + "date": "2026-07-06" + }, + { + "date": "2026-07-07" + }, + { + "date": "2026-07-08" + }, + { + "date": "2026-07-09" + }, + { + "date": "2026-07-10" + }, + { + "date": "2026-07-11" + }, + { + "date": "2026-07-12" + }, + { + "date": "2026-07-13" + }, + { + "date": "2026-07-14" + }, + { + "date": "2026-07-15" + }, + { + "date": "2026-07-16" + }, + { + "date": "2026-07-17" + } + ], + "meta": { + "total_snapshots": 80, + "denominator": "atlas_visible_asns", + "source": "rpki_cloudflare_feed" + } + } + }, + "ipv6-adoption-stats": { + "status": 200, + "body": {} + }, + "atlas-coverage": { + "status": 200, + "body": { + "pages_fetched": 30 + } + }, + "submarine-cables": { + "status": 502, + "body": { + "error": "API server unavailable", + "detail": "connect ECONNREFUSED 127.0.0.1:39102" + } + }, + "communities": { + "status": 502, + "body": { + "error": "API server unavailable", + "detail": "connect ECONNREFUSED 127.0.0.1:39102" + } + } +} \ No newline at end of file diff --git a/server/__smoke__/compare-responses.js b/server/__smoke__/compare-responses.js new file mode 100644 index 0000000..1522b0d --- /dev/null +++ b/server/__smoke__/compare-responses.js @@ -0,0 +1,294 @@ +#!/usr/bin/env node +// Regression gate for the server.js module-extraction refactor (see +// /Users/renefichtmueller/.claude/plans/linked-riding-newell.md). Not shipped +// to prod -- local dev tool only. +// +// Usage: +// node server/__smoke__/compare-responses.js capture # save baseline (run once, before Phase A) +// node server/__smoke__/compare-responses.js diff # run after every extraction step + +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const { spawn, execFileSync } = require("child_process"); + +// server.js writes real snapshots into these git-tracked files at startup +// (ASPA adoption history) and at runtime (hijack alerts, webhook subs) -- +// snapshot + restore around every local boot so smoke-testing never leaves +// lasting mutations on data that's supposed to hold genuine production +// history, not local test noise. +const REPO_ROOT = path.join(__dirname, "..", ".."); +const MUTABLE_TRACKED_FILES = ["aspa-adoption-history.json", "hijack-alerts.json", "webhook-subs.json"]; + +function restoreTrackedFiles() { + try { + execFileSync("git", ["checkout", "--", ...MUTABLE_TRACKED_FILES], { cwd: REPO_ROOT, stdio: "ignore" }); + } catch { + // fine if a file has no uncommitted changes (git checkout on a clean path is a no-op anyway) + } +} + +const PORT = 3199; +const BASE = `http://127.0.0.1:${PORT}`; +const BASELINE_DIR = path.join(__dirname, "baseline"); +const ASN = 13335; // Cloudflare -- stable, public, always has real data +const ASN2 = 15169; // Google -- for compare/relationships + +// One representative, read-only (GET) request per route from the exploration +// map. POST/DELETE mutating routes (feedback POST, webhooks POST/DELETE, +// hijacks resolve) are intentionally excluded -- not idempotent-safe to +// re-run repeatedly across every extraction step. +const ENDPOINTS = [ + ["root", "/"], + ["search", `/api/search?q=cloudflare`], + ["visitors", "/api/visitors"], + ["health", "/api/health"], + ["aspa-verify", `/api/aspa/verify?asn=${ASN}`], + ["aspa-legacy", `/api/aspa?asn=${ASN}`], + ["bgp", `/api/bgp?asn=${ASN}`], + ["validate", `/api/validate?asn=${ASN}`], + ["lookup", `/api/lookup?asn=${ASN}`], + ["relationships", `/api/relationships?asn=${ASN}`], + ["compare", `/api/compare?asn1=${ASN}&asn2=${ASN2}`], + ["quick-ix", `/api/quick-ix?asn=${ASN}`], + ["peers-find", `/api/peers/find?asn=${ASN}`], + ["prefix-detail", "/api/prefix/detail?prefix=1.1.1.0/24"], + ["ix-detail", "/api/ix/detail?asn=${ASN}"], + ["topology", `/api/topology?asn=${ASN}`], + ["whois", `/api/whois?asn=${ASN}`], + ["enrich", `/api/enrich?asn=${ASN}`], + ["changelog", "/changelog"], + ["webhooks-list", "/api/webhooks"], + ["hijacks-list", "/api/hijacks"], + ["hijack-alerts", "/api/hijack-alerts"], + ["aspa-adoption-page", "/aspa-adoption"], + ["aspa-adoption-stats", "/api/aspa-adoption-stats?period=30d"], + ["ipv6-adoption-stats", "/api/ipv6-adoption-stats"], + ["atlas-coverage", "/api/atlas/coverage"], + // proxy stubs -- expected to fail identically (peercortex-api not running + // locally either); still worth diffing so the *error shape* stays stable + ["submarine-cables", "/api/submarine-cables"], + ["communities", "/api/communities?asn=" + ASN], +]; + +// Fields that legitimately change between runs -- excluded from the diff. +// Two sources of real (non-bug) drift observed empirically by diffing an +// unmodified server.js against itself: (1) process-local counters (uptime, +// memory), and (2) live third-party data that changes minute-to-minute +// (RIPE Atlas probe counts/IDs, Cloudflare RPKI feed size, health_score +// insofar as it's derived from live external checks like MANRS that can +// time out differently run to run). +const NONDETERMINISTIC_KEY_PATTERN = + /^(generated_at|timestamp|checked_at|fetched_at|query_time|cache_age|age_seconds|age_minutes|uptime_seconds|memory_mb|response_time_ms|duration_ms|last_updated|health_score|delta_last|history_samples|total_probes|total_connected|unique_asns_with_probes|atlas_asns_total|atlas_asns_with_aspa|coverage_pct_atlas|asns_with_probes|roa_count|aspa_objects)$/i; + +// Whole sub-trees excluded by dotted path (relative to the endpoint's body), +// found empirically by diffing an unmodified server.js against itself twice. +// Each of these depends on a live third-party call that can legitimately +// time out or return fresh-but-different data run to run (RIPE Stat routing +// status, RIS prefix history, PeeringDB unauthenticated rate limits, RIPE +// Atlas probe churn, source-fetch timing diagnostics) -- none of it reflects +// server.js's own logic, so a diff here is noise, not a regression signal. +const VOLATILE_PATHS = new Set([ + "lookup.source_timing", + "validate.validations.visibility", + // redundant summary of validations.* (already diffed in detail) that also + // embeds the visibility check's flaky pass/info status + "validate.score_breakdown", + "prefix-detail.origins", + "prefix-detail.first_seen", + "aspa-adoption-stats.forecast", + "quick-ix.ix_connections", + "quick-ix.name", + "atlas-coverage.by_country", + // live Cloudflare RPKI feed object count ticks between runs; date-rollover + // (midnight) shifts the adoption tracker's day-over-day delta too + "health.caches.aspa_map.entries", + "health.aspa_adoption.total_objects", + "aspa-adoption-stats.current.delta_from_previous", + // RIR delegation files (RIPE/ARIN/APNIC/AFRINIC/LACNIC) update near-real-time + "ipv6-adoption-stats.global_ipv6_pct", + "ipv6-adoption-stats.total_ipv4_records", + "ipv6-adoption-stats.total_ipv6_records", + "ipv6-adoption-stats.by_rir", +]); + +function stripNondeterministic(obj, pathPrefix) { + if (Array.isArray(obj)) return obj.map((v) => stripNondeterministic(v, pathPrefix)); + if (obj && typeof obj === "object") { + const out = {}; + for (const [k, v] of Object.entries(obj)) { + if (NONDETERMINISTIC_KEY_PATTERN.test(k)) continue; + const fullPath = pathPrefix ? `${pathPrefix}.${k}` : k; + if (VOLATILE_PATHS.has(fullPath)) continue; + out[k] = stripNondeterministic(v, fullPath); + } + return out; + } + return obj; +} + +function fetchOne(name, pathAndQuery) { + return new Promise((resolve) => { + const req = http.get(BASE + pathAndQuery, { timeout: 20000 }, (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => { + let parsed; + try { + parsed = JSON.parse(body); + } catch { + parsed = { __non_json__: true, status: res.statusCode, bodyLength: body.length }; + } + resolve({ status: res.statusCode, body: stripNondeterministic(parsed, name) }); + }); + }); + req.on("timeout", () => { + req.destroy(); + resolve({ status: 0, body: { __timeout__: true } }); + }); + req.on("error", (err) => resolve({ status: 0, body: { __error__: err.message } })); + }); +} + +function waitForServer(maxWaitMs = 100000) { + // Startup does real, sequential network fetches (Cloudflare RPKI/ROA feed + // ~972k entries, full paginated RIPE Atlas probe list, IPv6 RIR delegation + // stats) before calling server.listen() -- observed ~60s locally. + const start = Date.now(); + return new Promise((resolve, reject) => { + const tryOnce = () => { + const req = http.get(`${BASE}/api/health`, { timeout: 2000 }, (res) => { + res.resume(); + resolve(); + }); + req.on("error", () => { + if (Date.now() - start > maxWaitMs) return reject(new Error("server didn't come up in time")); + setTimeout(tryOnce, 500); + }); + req.on("timeout", () => { + req.destroy(); + if (Date.now() - start > maxWaitMs) return reject(new Error("server didn't come up in time")); + setTimeout(tryOnce, 500); + }); + }; + tryOnce(); + }); +} + +async function runAgainstLiveServer() { + const results = {}; + for (const [name, pathAndQuery] of ENDPOINTS) { + results[name] = await fetchOne(name, pathAndQuery); + } + return results; +} + +function leafDiffs(a, b, pathPrefix, out) { + if (JSON.stringify(a) === JSON.stringify(b)) return; + const bothPlainObjects = + a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b); + if (bothPlainObjects) { + for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { + leafDiffs(a[k], b[k], pathPrefix + "." + k, out); + } + } else { + out.push(`${pathPrefix}: ${JSON.stringify(a)} -> ${JSON.stringify(b)}`); + } +} + +function diffResults(baseline, current) { + const diffs = []; + for (const [name] of ENDPOINTS) { + const b = JSON.stringify(baseline[name]); + const c = JSON.stringify(current[name]); + if (b !== c) { + const leaves = []; + leafDiffs(baseline[name], current[name], name, leaves); + diffs.push({ name, leaves }); + } + } + return diffs; +} + +async function main() { + const mode = process.argv[2]; + if (mode !== "capture" && mode !== "diff") { + console.error("Usage: node compare-responses.js "); + process.exit(1); + } + + restoreTrackedFiles(); + + const serverEntry = path.join(__dirname, "..", "..", "server.js"); + const child = spawn(process.execPath, [serverEntry], { + cwd: path.join(__dirname, "..", ".."), + env: { + ...process.env, + PORT: String(PORT), + // Node 22's stricter header validation throws on Authorization: + // undefined, which server.js's fetchPdbOrgCountries() produces when + // PEERINGDB_API_KEY is unset (line ~6689). This is a real bug (tracked + // separately, fixed during the pdb-org-countries.js extraction in + // Phase B) but it blocks the server from even booting for local + // testing -- work around it here at the test-harness level only, so + // the baseline reflects real behavior rather than a crash. + PEERINGDB_API_KEY: process.env.PEERINGDB_API_KEY || "smoke-test-placeholder", + // Force the proxy-to-peercortex-api path to genuinely fail closed + // instead of cross-talking with whatever unrelated dev server happens + // to be listening on the real default port (3102) on this machine -- + // observed proxying straight into an unrelated Next.js app's 404 page. + API_SERVER_PORT: "39102", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stderrBuf = ""; + child.stderr.on("data", (c) => (stderrBuf += c)); + + const cleanup = () => { + try { + child.kill("SIGTERM"); + } catch {} + }; + process.on("exit", cleanup); + + try { + await waitForServer(); + console.log(`Server up on port ${PORT}, running ${ENDPOINTS.length} requests...`); + const results = await runAgainstLiveServer(); + + if (mode === "capture") { + fs.mkdirSync(BASELINE_DIR, { recursive: true }); + fs.writeFileSync(path.join(BASELINE_DIR, "baseline.json"), JSON.stringify(results, null, 2)); + console.log(`Baseline captured: ${ENDPOINTS.length} endpoints -> ${BASELINE_DIR}/baseline.json`); + } else { + const baselinePath = path.join(BASELINE_DIR, "baseline.json"); + if (!fs.existsSync(baselinePath)) { + console.error("No baseline found -- run `capture` first."); + process.exit(1); + } + const baseline = JSON.parse(fs.readFileSync(baselinePath, "utf8")); + const diffs = diffResults(baseline, results); + if (diffs.length === 0) { + console.log(`PASS -- all ${ENDPOINTS.length} endpoints match baseline.`); + } else { + console.log(`FAIL -- ${diffs.length}/${ENDPOINTS.length} endpoint(s) differ from baseline:`); + for (const d of diffs) { + console.log(`\n=== ${d.name} (${d.leaves.length} leaf change(s)) ===`); + for (const line of d.leaves.slice(0, 20)) console.log(" " + line); + if (d.leaves.length > 20) console.log(` ... and ${d.leaves.length - 20} more`); + } + process.exitCode = 1; + } + } + } catch (err) { + console.error("Smoke test failed to run:", err.message); + if (stderrBuf) console.error("--- server stderr ---\n" + stderrBuf.slice(0, 3000)); + process.exitCode = 1; + } finally { + cleanup(); + restoreTrackedFiles(); + } +} + +main(); diff --git a/server/aspa-adoption/tracker.js b/server/aspa-adoption/tracker.js new file mode 100644 index 0000000..29d6cc8 --- /dev/null +++ b/server/aspa-adoption/tracker.js @@ -0,0 +1,291 @@ +const fs = require("fs"); +const { DATA_DIR } = require("../hijack-monitoring/store"); +const { atlasState } = require("../atlas-probes"); + +const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json'; + +// Load persisted ASPA adoption history from disk. +// Returns array of snapshot objects (up to 365 entries). +function loadAspaAdoptionHistory() { + try { + const raw = JSON.parse(fs.readFileSync(ASPA_ADOPTION_FILE, 'utf8')); + return Array.isArray(raw) ? raw : []; + } catch(_) { return []; } +} + +// Save ASPA adoption history to disk (keep last 365 entries). +function saveAspaAdoptionHistory(history) { + try { + const trimmed = history.slice(-365); + fs.writeFileSync(ASPA_ADOPTION_FILE, JSON.stringify(trimmed, null, 2), 'utf8'); + } catch(e) { + console.error('[ASPA-ADOPT] Save failed:', e.message); + } +} + +// In-memory adoption history (persisted on each new snapshot). Mutated +// in place (push / index-assign), never reassigned -- safe to export as a +// live array reference, same pattern as roaStore/pdbSourceCache. +let aspaAdoptionDailyHistory = loadAspaAdoptionHistory(); + +// Take a new adoption snapshot and persist it. Called whenever the RPKI feed +// is refreshed. +// @param {number} aspaCount — total ASPA objects in rpkiAspaMap +// @param {number} roaCount — total ROAs in roaStore +// @param {Map} rpkiAspaMap — customer_asid -> Set, owned by +// server/services/rpki.js; passed in explicitly (rather than required here) +// to avoid a circular require between this module and rpki.js. +function recordAspaAdoptionSnapshot(aspaCount, roaCount, rpkiAspaMap) { + const now = new Date(); + const date = now.toISOString().slice(0, 10); // "YYYY-MM-DD" + + // Compute how many Atlas-visible ASNs have ASPA + let atlasTotal = 0; + let atlasWithAspa = 0; + if (atlasState.cache?.asns_with_probes) { + atlasTotal = atlasState.cache.asns_with_probes.length; + // rpkiAspaMap is keyed by integer ASN + atlasWithAspa = atlasState.cache.asns_with_probes.filter(a => rpkiAspaMap.has(Number(a))).length; + } + + const coveragePct = atlasTotal > 0 + ? Math.round((atlasWithAspa / atlasTotal) * 10000) / 100 // 2 decimal places + : 0; + + // Compute delta from previous snapshot + const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1]; + const delta = prev ? aspaCount - prev.aspa_objects : 0; + + const snapshot = { + date, + ts: now.getTime(), + aspa_objects: aspaCount, + roa_count: roaCount, + atlas_asns_total: atlasTotal, + atlas_asns_with_aspa: atlasWithAspa, + coverage_pct_atlas: coveragePct, + aspa_delta: delta, + method: 'rpki_cloudflare_feed', + }; + + // Only store one snapshot per date (overwrite if same day) + const existingIdx = aspaAdoptionDailyHistory.findIndex(s => s.date === date); + if (existingIdx >= 0) { + aspaAdoptionDailyHistory[existingIdx] = snapshot; + } else { + aspaAdoptionDailyHistory.push(snapshot); + } + saveAspaAdoptionHistory(aspaAdoptionDailyHistory); + console.log(`[ASPA-ADOPT] Snapshot ${date}: ${aspaCount} objects, ${coveragePct}% Atlas coverage (${atlasWithAspa}/${atlasTotal})`); + return snapshot; +} + +// Get adoption trend for a given period string ('7d', '30d', '90d', '1y'). +function getAdoptionTrend(period) { + const days = period === '7d' ? 7 : period === '30d' ? 30 : period === '90d' ? 90 : 365; + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + return aspaAdoptionDailyHistory.filter(s => s.ts >= cutoff); +} + +// Build the ASPA adoption dashboard HTML. +function buildAspaAdoptionDashboard() { + const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1]; + const trend30 = getAdoptionTrend('30d'); + const trend7 = getAdoptionTrend('7d'); + + // Forecast: simple linear regression over last 30 days + function linearForecast(data, daysAhead) { + if (data.length < 2) return null; + const n = data.length; + const xs = data.map((_, i) => i); + const ys = data.map(d => d.coverage_pct_atlas); + const xMean = xs.reduce((a, b) => a + b, 0) / n; + const yMean = ys.reduce((a, b) => a + b, 0) / n; + const num = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i] - yMean), 0); + const den = xs.reduce((s, x) => s + (x - xMean) ** 2, 0); + if (den === 0) return yMean; + const slope = num / den; + return Math.max(0, Math.min(100, yMean + slope * (n - 1 + daysAhead))); + } + + const forecast90d = linearForecast(trend30, 90); + const change7d = trend7.length >= 2 + ? (trend7[trend7.length-1].aspa_objects - trend7[0].aspa_objects) + : 0; + const trendLabels = trend30.map(d => d.date); + const trendValues = trend30.map(d => d.coverage_pct_atlas); + const asnValues = trend30.map(d => d.aspa_objects); + + const coverageNow = latest?.coverage_pct_atlas ?? 0; + const aspaNow = latest?.aspa_objects ?? 0; + const atlasTotal = latest?.atlas_asns_total ?? 0; + + return ` + + + + + ASPA Adoption Tracker · PeerCortex + + + + + +
    +

    ASPA Adoption Tracker

    +

    Global ASPA deployment trends · Powered by RPKI Cloudflare feed · Updated every ~4 hours

    + +
    +
    +
    Atlas Coverage
    +
    ${coverageNow.toFixed(1)}%
    +
    of Atlas-visible ASNs
    +
    +
    +
    ASPA Objects
    +
    ${aspaNow.toLocaleString()}
    +
    total in RPKI
    +
    +
    +
    Atlas ASNs
    +
    ${atlasTotal.toLocaleString()}
    +
    sampled denominator
    +
    +
    +
    7-Day Delta
    +
    ${change7d > 0 ? '+' : ''}${change7d}
    +
    new ASPA objects
    +
    +
    +
    Data Points
    +
    ${aspaAdoptionDailyHistory.length}
    +
    history snapshots
    +
    +
    + + + +
    +
    +

    Atlas Coverage % (last 30 days)

    + +
    +
    +
    90-Day Forecast
    +
    ${forecast90d !== null ? forecast90d.toFixed(1) + '%' : '—'}
    +
    linear projection from 30-day trend
    +
    + Based on ${trend30.length} data points.
    + Current: ${coverageNow.toFixed(1)}% +
    +
    +
    + +
    +

    Total ASPA Objects in RPKI (last 30 days)

    + +
    + +
    + Last snapshot: ${latest?.date ?? 'none'} · Denominator: ${atlasTotal.toLocaleString()} Atlas ASNs · + JSON API +
    +
    + + + +`; +} + +module.exports = { + aspaAdoptionDailyHistory, + loadAspaAdoptionHistory, + saveAspaAdoptionHistory, + recordAspaAdoptionSnapshot, + getAdoptionTrend, + buildAspaAdoptionDashboard, +}; diff --git a/server/aspa-verification/engine.js b/server/aspa-verification/engine.js new file mode 100644 index 0000000..b4767da --- /dev/null +++ b/server/aspa-verification/engine.js @@ -0,0 +1,224 @@ +// RFC-Compliant ASPA Verification Engine +// Pure functions, no I/O -- see draft-ietf-sidrops-aspa-verification. + +// 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) +// hopCheck(asI, asJ): is asJ an attested provider of CUSTOMER asI? Caller must pass +// the customer first, provider second -- see draft-ietf-sidrops-aspa-verification +// section 5.3's authorized(A(I), A(I+1)), where A(I) is always the customer side. +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++) { + // collapsed[i] (closer to origin) is the customer; collapsed[i - 1] (closer to + // the validator) is the provider it's declaring -- customer goes first in hopCheck. + const check = hopCheck(collapsed[i], collapsed[i - 1], 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 -- draft-ietf-sidrops-aspa-verification Section 5.5. +// Unlike upstream verification, a downstream path may legitimately contain both +// an up-ramp (customer->provider hops, near the origin) AND a down-ramp +// (provider->customer hops, near the validator), transitioning at most once -- +// e.g. origin -> its providers -> a peering exchange -> validator's providers -> +// validator. Section 5.3 defines four ramp lengths computed from two independent +// scans, verified 2026-07-16 against a worked example fetched from the draft +// (N=4 path with a single unattested/no-attestation "kink" correctly resolves +// to Valid, and a path with definite failures on both ends correctly resolves +// to Invalid, matching this implementation). +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], collapsed[i - 1], aspaStore), + }); + } + + // Up-ramp scan: walk from the origin (collapsed[N-1]) toward the validator + // (collapsed[0]), i.e. authorized(A(I), A(I+1)) for I = 1..N-1 ascending. + // maxUpRamp stops at the first DEFINITE violation ("NotProviderPlus"); + // minUpRamp stops at the first hop that isn't confirmed ProviderPlus + // (including unattested "NoAttestation" hops, which don't disprove a leak + // but also don't prove the path is clean). + let maxUpRamp = N; + let minUpRamp = N; + for (let k = N - 1; k >= 1; k--) { + const check = hopCheck(collapsed[k], collapsed[k - 1], aspaStore); + const I = N - k; + if (check === "NotProviderPlus" && maxUpRamp === N) maxUpRamp = I; + if (check !== "ProviderPlus" && minUpRamp === N) minUpRamp = I; + if (maxUpRamp !== N && minUpRamp !== N) break; + } + + // Down-ramp scan: the mirror image, walking from the validator (collapsed[0]) + // toward the origin, i.e. authorized(A(J), A(J-1)) for J = N..2 descending. + let maxDownRamp = N; + let minDownRamp = N; + for (let k = 0; k <= N - 2; k++) { + const check = hopCheck(collapsed[k], collapsed[k + 1], aspaStore); + const rampLen = k + 1; // = N - J + 1 for the corresponding J + if (check === "NotProviderPlus" && maxDownRamp === N) maxDownRamp = rampLen; + if (check !== "ProviderPlus" && minDownRamp === N) minDownRamp = rampLen; + if (maxDownRamp !== N && minDownRamp !== N) break; + } + + if (maxUpRamp + maxDownRamp < N) { + return { + result: "Invalid", + reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") < " + N + ": no valid up/down split covers the path", + hops, + }; + } + if (minUpRamp + minDownRamp < N) { + return { + result: "Unknown", + reason: "min_up_ramp(" + minUpRamp + ") + min_down_ramp(" + minDownRamp + ") < " + N + ": unattested hops prevent full confirmation", + hops, + }; + } + return { + result: "Valid", + reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") >= " + N + " and min_up_ramp + min_down_ramp >= " + N, + 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 }, + }, + }; +} + +module.exports = { + hasAsSet, + hopCheck, + collapsePrepends, + verifyUpstream, + verifyDownstream, + detectValleys, + buildAspaStore, + calculateAspaReadinessScore, +}; diff --git a/server/atlas-probes.js b/server/atlas-probes.js new file mode 100644 index 0000000..1576906 --- /dev/null +++ b/server/atlas-probes.js @@ -0,0 +1,77 @@ +const { fetchJSON } = require("./services/http-helpers"); + +// Atlas Probe Cache (for Lia's Atlas Paradise). Exposed as a mutable state +// object (not a bare reassigned export) so every requirer always sees the +// current value after fetchAllAtlasProbes() resolves -- a plain +// `module.exports = { atlasProbeCache }` would only capture the value (null) +// at require time, since CommonJS destructuring doesn't track reassignment. +const atlasState = { + cache: null, + fetching: false, +}; + +function fetchAllAtlasProbes() { + if (atlasState.fetching) return Promise.resolve(); + atlasState.fetching = 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 }; + }); + + atlasState.cache = { + 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)"); + atlasState.fetching = false; + resolve(); + } + + fetchPage("https://atlas.ripe.net/api/v2/probes/?page_size=500&status=1&page=1&format=json"); + }); +} + +module.exports = { atlasState, fetchAllAtlasProbes }; diff --git a/server/bgp-he-net.js b/server/bgp-he-net.js new file mode 100644 index 0000000..709c5de --- /dev/null +++ b/server/bgp-he-net.js @@ -0,0 +1,53 @@ +const { fetchHTML } = require("./services/http-helpers"); + +// Feature 24: bgp.he.net Integration. +// NOTE: currently dead code in practice -- /api/lookup's only call site +// (`timedFetch("bgp.he.net", ...)`) is hardcoded to `Promise.resolve(null)` +// rather than actually invoking this. Moved as-is (not removed) since that's +// a "disabled pending re-enable" state, not a provably-unreachable one -- +// removing a whole scraping feature is a bigger decision than this +// behavior-preserving extraction pass should make unilaterally. +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; + } +} + +module.exports = { fetchBgpHeNet }; diff --git a/server/caches/pdb-source-cache.js b/server/caches/pdb-source-cache.js new file mode 100644 index 0000000..23881d4 --- /dev/null +++ b/server/caches/pdb-source-cache.js @@ -0,0 +1,85 @@ +const fs = require("fs"); + +// 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; + } + }, +}; + +module.exports = { pdbSourceCache }; diff --git a/server/caches/response-caches.js b/server/caches/response-caches.js new file mode 100644 index 0000000..7565c59 --- /dev/null +++ b/server/caches/response-caches.js @@ -0,0 +1,113 @@ +// Task 6: In-memory cache with TTL + Rate Limiting +const responseCache = new Map(); + +function cacheGet(key) { + const entry = responseCache.get(key); + if (!entry) return null; + if (Date.now() > entry.expires) { + responseCache.delete(key); + return null; + } + return entry.data; +} + +function cacheSet(key, data, ttlMs) { + responseCache.set(key, { data, expires: Date.now() + ttlMs }); + // Evict old entries periodically (keep cache under 500 entries) + if (responseCache.size > 500) { + const now = Date.now(); + for (const [k, v] of responseCache) { + if (now > v.expires) responseCache.delete(k); + } + } +} + +const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes +const CACHE_TTL_LOOKUP_DEGRADED = 90 * 1000; // matches the existing 90s guard for suspicious /api/validate zero-counts +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, ttl } +const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000; +// A "not found" result after all 5 RIR sources failed is far more often a shared +// network blip (DNS, outbound connectivity, simultaneous rate-limit) than a real +// ASN existing nowhere across RIPE+APNIC+ARIN+LACNIC+AFRINIC -- fetchJSON can't +// tell timeout/network-error apart from a genuine 404, so cache that specific +// conclusion much more briefly than a confirmed, parsed WHOIS record. +const WHOIS_NOT_FOUND_CACHE_TTL = 10 * 60 * 1000; +function whoisCacheGet(asn) { + const e = whoisCache.get(String(asn)); + if (e && (Date.now() - e.ts) < (e.ttl || WHOIS_CACHE_TTL)) return e.data; + return undefined; +} +function whoisCacheSet(asn, data, ttl) { + if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value); + whoisCache.set(String(asn), { data, ts: Date.now(), ttl: ttl || WHOIS_CACHE_TTL }); +} + +// 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 + ASPA result caches — 15min TTL, prevent re-hitting slow APIs +const bgproutesResultCache = new Map(); +const aspaResultCache = new Map(); +const validateResultCache = new Map(); +const RESULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes +function resultCacheGet(map, key) { + const e = map.get(String(key)); + const ttl = e && e.ttl ? e.ttl : RESULT_CACHE_TTL; + if (e && (Date.now() - e.ts) < ttl) return e.data; + return undefined; +} +function resultCacheSet(map, key, data, ttlMs) { + if (map.size > 2000) map.delete(map.keys().next().value); + map.set(String(key), { data, ts: Date.now(), ttl: ttlMs || RESULT_CACHE_TTL }); +} + +module.exports = { + responseCache, + cacheGet, + cacheSet, + RESULT_CACHE_TTL, + CACHE_TTL_LOOKUP, + CACHE_TTL_LOOKUP_DEGRADED, + CACHE_TTL_DEFAULT, + rdapCacheGet, + rdapCacheSet, + whoisCacheGet, + whoisCacheSet, + WHOIS_NOT_FOUND_CACHE_TTL, + quickIxCacheGet, + quickIxCacheSet, + bgproutesResultCache, + aspaResultCache, + validateResultCache, + resultCacheGet, + resultCacheSet, +}; diff --git a/server/caches/roa-store.js b/server/caches/roa-store.js new file mode 100644 index 0000000..c2a03a8 --- /dev/null +++ b/server/caches/roa-store.js @@ -0,0 +1,225 @@ +const fs = require("fs"); + +// 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; + } + }, +}; + +module.exports = { roaStore }; diff --git a/server/data/city-coords.js b/server/data/city-coords.js new file mode 100644 index 0000000..89686ca --- /dev/null +++ b/server/data/city-coords.js @@ -0,0 +1,26 @@ +// 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], +}; + +module.exports = { CITY_COORDS }; diff --git a/server/data/constants.js b/server/data/constants.js new file mode 100644 index 0000000..a2e7a7b --- /dev/null +++ b/server/data/constants.js @@ -0,0 +1,3 @@ +const UA = "PeerCortex/0.5.0 (+https://peercortex.org)"; + +module.exports = { UA }; diff --git a/server/data/ix-city-map.js b/server/data/ix-city-map.js new file mode 100644 index 0000000..74856ac --- /dev/null +++ b/server/data/ix-city-map.js @@ -0,0 +1,4 @@ +// Small ASN/IX-id → city fallback map used by the /api/lookup facility geocoder +const 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" }; + +module.exports = { IX_CITY_MAP }; diff --git a/server/data/rir-country-codes.js b/server/data/rir-country-codes.js new file mode 100644 index 0000000..28a825a --- /dev/null +++ b/server/data/rir-country-codes.js @@ -0,0 +1,6 @@ +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"]); + +module.exports = { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC }; diff --git a/server/data/rir-delegation-urls.js b/server/data/rir-delegation-urls.js new file mode 100644 index 0000000..44160db --- /dev/null +++ b/server/data/rir-delegation-urls.js @@ -0,0 +1,9 @@ +const RIR_DELEGATION_URLS = { + RIPE: 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest', + ARIN: 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest', + APNIC: 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest', + AFRINIC: 'https://ftp.afrinic.net/stats/afrinic/delegated-afrinic-extended-latest', + LACNIC: 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest', +}; + +module.exports = { RIR_DELEGATION_URLS }; diff --git a/server/data/tier1-asns.js b/server/data/tier1-asns.js new file mode 100644 index 0000000..21f1fac --- /dev/null +++ b/server/data/tier1-asns.js @@ -0,0 +1,26 @@ +// 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 +]); + +module.exports = { TIER1_ASNS }; diff --git a/server/hijack-monitoring/monitor.js b/server/hijack-monitoring/monitor.js new file mode 100644 index 0000000..0b60456 --- /dev/null +++ b/server/hijack-monitoring/monitor.js @@ -0,0 +1,72 @@ +const crypto = require("crypto"); +const localDb = require("../../local-db-client"); +const { loadHijackSubs, loadHijackAlerts, saveHijackAlerts, saveHijackSubs } = require("./store"); +const { notifyWebhooks } = require("./webhooks"); + +// Classify hijack severity +function classifyHijackSeverity(unexpected, missing, rpkiInvalid) { + if (rpkiInvalid > 0 || unexpected.length >= 5) return 'CRITICAL'; + if (unexpected.length >= 2 || missing.length >= 3) return 'HIGH'; + if (unexpected.length >= 1 || missing.length >= 1) return 'MEDIUM'; + return 'LOW'; +} + +// Returns null (not []) when the prefix lookup itself failed -- distinct from a +// genuine zero-prefix result. runHijackCheck must not treat these the same way: +// setting an empty baseline from a transient failure would permanently disable +// hijack detection for that subscription (baseline.size stays 0 forever after, +// so "unexpected" never fires again -- see the baseline.size > 0 guard below). +async function checkHijacksForAsn(asn) { + try { + const data = await localDb.getRipeStatAnnouncedPrefixes(asn); + if (data && data.status === 'error') return null; + const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix); + return prefixes; + } catch (_) { return null; } +} + +async function runHijackCheck() { + const subs = loadHijackSubs(); + if (!subs.length) return; + const alerts = loadHijackAlerts(); + let changed = false; + for (const sub of subs) { + const current = await checkHijacksForAsn(sub.asn); + if (current === null) { + // Lookup failed this cycle -- skip both anomaly detection and baseline + // initialization; retry on the next 30-minute run rather than locking in + // an empty baseline that would silently disable detection forever. + continue; + } + const baseline = new Set(sub.prefixes || []); + const unexpected = current.filter(p => baseline.size > 0 && !baseline.has(p)); + const missing = [...baseline].filter(p => !current.includes(p)); + if (unexpected.length || missing.length) { + // Dedup: only alert if no alert in last 6 hours for this ASN + const sixHoursAgo = Date.now() - 6 * 60 * 60 * 1000; + const recentAlert = alerts.find(a => a.asn === sub.asn && new Date(a.ts).getTime() > sixHoursAgo); + if (!recentAlert) { + const severity = classifyHijackSeverity(unexpected, missing, 0); + const alert = { + id: crypto.randomBytes(8).toString('hex'), + asn: sub.asn, ts: new Date().toISOString(), + severity, unexpected, missing, resolved: false, + msg: `BGP anomaly for AS${sub.asn}: ${unexpected.length} unexpected prefix(es), ${missing.length} missing` + }; + alerts.push(alert); + changed = true; + notifyWebhooks(sub.asn, alert); + } + } + // Set baseline on first monitoring + if (!sub.prefixes || !sub.prefixes.length) { + sub.prefixes = current; + changed = true; + } + } + if (changed) { saveHijackAlerts(alerts); saveHijackSubs(subs); } +} +// Run hijack check every 30 minutes +setInterval(runHijackCheck, 30 * 60 * 1000); + +module.exports = { classifyHijackSeverity, checkHijacksForAsn, runHijackCheck }; diff --git a/server/hijack-monitoring/store.js b/server/hijack-monitoring/store.js new file mode 100644 index 0000000..cc84257 --- /dev/null +++ b/server/hijack-monitoring/store.js @@ -0,0 +1,31 @@ +const fs = require("fs"); +const path = require("path"); + +// Repo root (where server.js itself lives) -- NOT this module's own directory. +// Preserves the original server.js `__dirname` fallback behavior (only ever +// exercised locally; Erik always has /opt/peercortex-app and takes that branch). +const REPO_ROOT = path.join(__dirname, "..", ".."); +const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : REPO_ROOT); +const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json'; +const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json'; +const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.json'; + +function loadHijackSubs() { try { return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE,'utf8')); } catch(_){ return []; } } +function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALERTS_FILE,'utf8')); } catch(_){ return []; } } +function loadWebhookSubs() { try { return JSON.parse(fs.readFileSync(WEBHOOK_SUBS_FILE,'utf8')); } catch(_){ return []; } } +function saveWebhookSubs(s) { try { fs.writeFileSync(WEBHOOK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} } +function saveHijackAlerts(a) { try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(a.slice(-1000), null, 2)); } catch(_){} } +function saveHijackSubs(s) { try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} } + +module.exports = { + DATA_DIR, + HIJACK_SUBS_FILE, + HIJACK_ALERTS_FILE, + WEBHOOK_SUBS_FILE, + loadHijackSubs, + loadHijackAlerts, + loadWebhookSubs, + saveWebhookSubs, + saveHijackAlerts, + saveHijackSubs, +}; diff --git a/server/hijack-monitoring/webhooks.js b/server/hijack-monitoring/webhooks.js new file mode 100644 index 0000000..938a4b9 --- /dev/null +++ b/server/hijack-monitoring/webhooks.js @@ -0,0 +1,82 @@ +const http = require("http"); +const https = require("https"); +const crypto = require("crypto"); +const { loadWebhookSubs } = require("./store"); + +// Generate HMAC-SHA256 signature for webhook payloads +function webhookSignature(secret, payload) { + return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex'); +} + +// Deliver webhook with exponential backoff (max 5 retries) +async function deliverWebhook(sub, payload, attempt) { + attempt = attempt || 1; + const payloadStr = JSON.stringify(payload); + const sig = webhookSignature(sub.secret, payloadStr); + return new Promise((resolve) => { + try { + const parsed = new URL(sub.endpoint_url); + const lib = parsed.protocol === 'https:' ? https : http; + const options = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payloadStr), + 'X-PeerCortex-Signature': sig, + 'X-PeerCortex-Event': 'hijack_detected', + 'User-Agent': 'PeerCortex-Webhook/1.0' + }, + timeout: 10000 + }; + const req = lib.request(options, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve({ ok: true, status: res.statusCode }); + } else if (attempt < 5) { + const delay = Math.pow(2, attempt) * 1000; + setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay); + } else { + resolve({ ok: false, status: res.statusCode, error: 'max_retries' }); + } + }); + }); + req.on('error', () => { + if (attempt < 5) { + const delay = Math.pow(2, attempt) * 1000; + setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay); + } else { + resolve({ ok: false, error: 'connection_failed' }); + } + }); + req.on('timeout', () => { req.destroy(); }); + req.write(payloadStr); + req.end(); + } catch(e) { + resolve({ ok: false, error: e.message }); + } + }); +} + +// Fire webhooks for all subscribers of an ASN when hijack detected +async function notifyWebhooks(asn, alert) { + const subs = loadWebhookSubs().filter(s => String(s.asn) === String(asn) && s.active !== false); + for (const sub of subs) { + const payload = { + event: 'hijack_detected', + asn: asn, + alert: alert, + timestamp: new Date().toISOString(), + source: 'PeerCortex BGP Monitor' + }; + deliverWebhook(sub, payload, 1).then(result => { + if (!result.ok) console.warn(`[WEBHOOK] Delivery failed for AS${asn} → ${sub.endpoint_url}: ${result.error || result.status}`); + }); + } +} + +module.exports = { webhookSignature, deliverWebhook, notifyWebhooks }; diff --git a/server/ipv6-adoption.js b/server/ipv6-adoption.js new file mode 100644 index 0000000..9c55dfb --- /dev/null +++ b/server/ipv6-adoption.js @@ -0,0 +1,80 @@ +const { RIR_DELEGATION_URLS } = require("./data/rir-delegation-urls"); + +// FEATURE 4: IPv6 Adoption per RIR + +// In-memory cache: { data, ts } +let ipv6StatsCache = null; +const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h + +// Fetch and parse one RIR delegation file. +// Returns { ipv4, ipv6, asns } count objects. +function fetchRirDelegation(rir, url) { + return new Promise((resolve) => { + const result = { rir, ipv4: 0, ipv6: 0, asns: 0, ipv4_countries: new Set(), ipv6_countries: new Set() }; + const mod = url.startsWith('https') ? require('https') : require('http'); + const req = mod.get(url, { headers: { 'User-Agent': 'PeerCortex/1.0 (+https://peercortex.org)' }, timeout: 20000 }, res => { + if (res.statusCode !== 200) { resolve(result); return; } + let buf = ''; + res.on('data', d => { buf += d; }); + res.on('end', () => { + for (const line of buf.split('\n')) { + if (line.startsWith('#') || line.startsWith(rir.toLowerCase() + '|*') || !line.includes('|')) continue; + const parts = line.split('|'); + if (parts.length < 7) continue; + const type = parts[2]; + const status = parts[6].trim(); + const cc = parts[1]; + if (status !== 'allocated' && status !== 'assigned') continue; + if (type === 'ipv4') { result.ipv4++; result.ipv4_countries.add(cc); } + else if (type === 'ipv6') { result.ipv6++; result.ipv6_countries.add(cc); } + else if (type === 'asn') { result.asns++; } + } + resolve(result); + }); + }); + req.on('error', () => resolve(result)); + req.on('timeout', () => { req.destroy(); resolve(result); }); + }); +} + +// Fetch all 5 RIR delegation files and aggregate IPv6 stats. +// Cached for 24 hours. +async function fetchIpv6AdoptionStats(force) { + if (!force && ipv6StatsCache && (Date.now() - ipv6StatsCache.ts) < IPV6_STATS_TTL) { + return ipv6StatsCache.data; + } + console.log('[IPv6] Fetching RIR delegation stats...'); + const results = await Promise.all( + Object.entries(RIR_DELEGATION_URLS).map(([rir, url]) => fetchRirDelegation(rir, url)) + ); + const rirs = results.map(r => ({ + rir: r.rir, + ipv4_records: r.ipv4, + ipv6_records: r.ipv6, + asn_records: r.asns, + ipv6_pct: (r.ipv4 + r.ipv6) > 0 + ? Math.round((r.ipv6 / (r.ipv4 + r.ipv6)) * 10000) / 100 + : 0, + countries_with_ipv6: r.ipv6_countries.size, + countries_with_ipv4: r.ipv4_countries.size, + })); + const total_ipv4 = rirs.reduce((s, r) => s + r.ipv4_records, 0); + const total_ipv6 = rirs.reduce((s, r) => s + r.ipv6_records, 0); + const data = { + fetched_at: new Date().toISOString(), + global_ipv6_pct: total_ipv4 + total_ipv6 > 0 + ? Math.round((total_ipv6 / (total_ipv4 + total_ipv6)) * 10000) / 100 + : 0, + total_ipv4_records: total_ipv4, + total_ipv6_records: total_ipv6, + by_rir: rirs, + }; + ipv6StatsCache = { data, ts: Date.now() }; + console.log(`[IPv6] Done. Global IPv6%: ${data.global_ipv6_pct}% across ${rirs.length} RIRs`); + return data; +} + +// Pre-fetch on startup (non-blocking) +fetchIpv6AdoptionStats().catch(() => {}); + +module.exports = { fetchRirDelegation, fetchIpv6AdoptionStats }; diff --git a/server/pdb-org-countries.js b/server/pdb-org-countries.js new file mode 100644 index 0000000..8e6e8c5 --- /dev/null +++ b/server/pdb-org-countries.js @@ -0,0 +1,92 @@ +const fs = require("fs"); +const https = require("https"); +const path = require("path"); +const { UA } = require("./data/constants"); +const { PEERINGDB_API_KEY } = require("./services/peeringdb"); + +// Repo root -- NOT this module's own directory. See hijack-monitoring/store.js +// for the same __dirname-fallback fix applied for the same reason. +const REPO_ROOT = path.join(__dirname, ".."); + +// PeeringDB Org → Country Cache (for Lia's Paradise). Exposed via a mutable +// state object (not a bare reassigned export) for the same reason as +// atlas-probes.js's atlasState -- the Map itself is reassigned wholesale on +// refresh, not just mutated in place. +const pdbOrgState = { map: new Map() }; // org_id → { country, name } + +function fetchPdbOrgCountries() { + var cacheFile = path.join(REPO_ROOT, ".pdb-org-cache.json"); + + // 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")); + pdbOrgState.map = new Map(Object.entries(cached)); + console.log("[PDB-ORG] Loaded " + pdbOrgState.map.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 = https.get("https://www.peeringdb.com/api/org?status=ok&depth=0", { + headers: { + "User-Agent": UA, + // Bug fix 2026-07-16: a bare `PEERINGDB_API_KEY ? ... : undefined` + // sets the Authorization header value to `undefined` when no key is + // configured. Node 22's stricter header validation throws + // synchronously on that (ERR_HTTP_INVALID_HEADER_VALUE) instead of + // omitting the header, crashing the whole process at startup via + // this function's unhandled rejection (found empirically while + // trying to boot server.js locally without a real PeeringDB key). + // Conditionally spread the key in instead so the header key itself + // is simply absent when unconfigured. + ...(PEERINGDB_API_KEY ? { "Authorization": "Api-Key " + PEERINGDB_API_KEY } : {}), + }, + 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) { + pdbOrgState.map = new Map(); + var cacheObj = {}; + data.data.forEach(function(o) { + if (o.id && o.country) { + pdbOrgState.map.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 " + pdbOrgState.map.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(); + }); + }); +} + +module.exports = { pdbOrgState, fetchPdbOrgCountries }; diff --git a/server/pdf-export/renderer.js b/server/pdf-export/renderer.js new file mode 100644 index 0000000..7b3d3d1 --- /dev/null +++ b/server/pdf-export/renderer.js @@ -0,0 +1,65 @@ +// PDF Export: lazy puppeteer loader + response cache + renderer + +// ── PDF Export: lazy puppeteer loader ──────────────────────────────────────── +let _puppeteerBrowser = null; +let _puppeteerLoading = false; +async function getPuppeteerBrowser() { + if (_puppeteerBrowser) return _puppeteerBrowser; + if (_puppeteerLoading) { + // Wait for in-flight launch + await new Promise(r => setTimeout(r, 2000)); + return _puppeteerBrowser; + } + _puppeteerLoading = true; + try { + const puppeteer = require("puppeteer"); + _puppeteerBrowser = await puppeteer.launch({ + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"] + }); + console.log("[PDF] Puppeteer browser launched"); + _puppeteerBrowser.on("disconnected", () => { + console.log("[PDF] Browser disconnected — will relaunch on next request"); + _puppeteerBrowser = null; + _puppeteerLoading = false; + }); + } catch (e) { + console.error("[PDF] Puppeteer unavailable:", e.message); + _puppeteerBrowser = null; + } + _puppeteerLoading = false; + return _puppeteerBrowser; +} + +// PDF response cache: key = "asn:format" or "compare:asn1:asn2" → {buf, ts} +const pdfCache = new Map(); +const PDF_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +function pdfCacheGet(key) { + const e = pdfCache.get(key); + if (e && (Date.now() - e.ts) < PDF_CACHE_TTL) return e.buf; + return null; +} +function pdfCacheSet(key, buf) { + if (pdfCache.size > 100) pdfCache.delete(pdfCache.keys().next().value); + pdfCache.set(key, { buf, ts: Date.now() }); +} + +async function renderHtmlToPdf(html) { + const browser = await getPuppeteerBrowser(); + if (!browser) throw new Error('PDF renderer unavailable (puppeteer not installed)'); + const page = await browser.newPage(); + try { + await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const buf = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' }, + }); + return buf; + } finally { + await page.close(); + } +} + +module.exports = { getPuppeteerBrowser, pdfCacheGet, pdfCacheSet, renderHtmlToPdf }; diff --git a/server/pdf-export/templates.js b/server/pdf-export/templates.js new file mode 100644 index 0000000..14159df --- /dev/null +++ b/server/pdf-export/templates.js @@ -0,0 +1,661 @@ +// FEATURE 2: PDF Export -- pure HTML/CSS template generators, no I/O + +// FEATURE 2: PDF Export — template generators + puppeteer renderer +// ============================================================ + +/** + * Generate a health score ring SVG (inline, no external deps) + * @param {number} score 0–100 + * @param {string} color CSS color + */ +function scoreRingSvg(score, color) { + const r = 54, cx = 64, cy = 64; + const circ = 2 * Math.PI * r; + const fill = circ * (1 - score / 100); + const clr = color || (score >= 80 ? '#22c55e' : score >= 60 ? '#f59e0b' : '#ef4444'); + return `<svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg"> + <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="12"/> + <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${clr}" stroke-width="12" + stroke-dasharray="${circ}" stroke-dashoffset="${fill}" + stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/> + <text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" + font-family="system-ui,sans-serif" font-size="26" font-weight="700" fill="${clr}">${score}</text> + <text x="${cx}" y="${cy + 20}" text-anchor="middle" + font-family="system-ui,sans-serif" font-size="10" fill="#6b7280">/100</text> + </svg>`; +} + +/** Shared CSS for all PDF templates */ +function pdfBaseCSS() { + return ` + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + body { font-family: -apple-system, 'Segoe UI', system-ui, Arial, sans-serif; color: #1e1b4b; background: #fff; font-size: 11pt; line-height: 1.5; } + @page { size: A4; margin: 18mm 16mm; } + .page { page-break-after: always; min-height: 247mm; padding: 0; } + .page:last-child { page-break-after: avoid; } + h1 { font-size: 22pt; font-weight: 700; color: #4f46e5; } + h2 { font-size: 14pt; font-weight: 600; color: #3730a3; margin-top: 1.4em; margin-bottom: 0.5em; border-bottom: 2px solid #e0e7ff; padding-bottom: 4px; } + h3 { font-size: 11pt; font-weight: 600; color: #4338ca; margin-top: 1em; margin-bottom: 0.3em; } + p { margin-bottom: 0.5em; } + table { width: 100%; border-collapse: collapse; margin: 0.6em 0; font-size: 9.5pt; } + th { background: #4f46e5; color: #fff; padding: 6px 10px; text-align: left; font-weight: 600; } + td { padding: 5px 10px; border-bottom: 1px solid #e0e7ff; vertical-align: top; } + tr:nth-child(even) td { background: #f5f3ff; } + .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8.5pt; font-weight: 600; } + .badge-green { background: #dcfce7; color: #166534; } + .badge-red { background: #fee2e2; color: #991b1b; } + .badge-yellow{ background: #fef9c3; color: #854d0e; } + .badge-gray { background: #f3f4f6; color: #374151; } + .badge-indigo{ background: #e0e7ff; color: #3730a3; } + .cover { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; padding: 40px 0 20px; min-height: 247mm; } + .cover-logo { font-size: 11pt; font-weight: 700; color: #4f46e5; letter-spacing: -0.5px; margin-bottom: 60px; display: flex; align-items: center; gap: 8px; } + .cover-logo span { color: #1e1b4b; } + .cover-asn { font-size: 11pt; font-weight: 500; color: #6b7280; margin-bottom: 6px; } + .cover-name { font-size: 28pt; font-weight: 700; color: #1e1b4b; margin-bottom: 8px; line-height: 1.2; } + .cover-sub { font-size: 13pt; color: #4f46e5; font-weight: 500; margin-bottom: 30px; } + .cover-meta { font-size: 9pt; color: #9ca3af; margin-top: auto; padding-top: 20px; border-top: 1px solid #e0e7ff; width: 100%; } + .cover-stripe { position: fixed; right: -8mm; top: 0; width: 28mm; height: 100vh; background: linear-gradient(180deg, #4f46e5 0%, #6366f1 50%, #818cf8 100%); opacity: 0.08; pointer-events: none; } + .metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 1em 0; } + .metric-card { background: #f5f3ff; border: 1px solid #e0e7ff; border-radius: 8px; padding: 12px 14px; } + .metric-card .label { font-size: 8pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; } + .metric-card .value { font-size: 18pt; font-weight: 700; color: #4f46e5; } + .metric-card .unit { font-size: 8pt; color: #9ca3af; } + .score-row { display: flex; align-items: center; gap: 20px; margin: 0.8em 0; } + .check-list { list-style: none; padding: 0; } + .check-list li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid #f3f4f6; font-size: 9.5pt; } + .check-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; } + .check-pass { color: #16a34a; } + .check-fail { color: #dc2626; } + .check-warn { color: #d97706; } + .section-intro { font-size: 9.5pt; color: #6b7280; margin-bottom: 1em; font-style: italic; } + footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 8pt; color: #9ca3af; padding: 6px 16mm; border-top: 1px solid #e0e7ff; display: flex; justify-content: space-between; } + `; +} + +/** + * Build the PDF HTML for a single ASN. + * @param {object} data Full /api/validate response + * @param {object} aspa /api/aspa response (may be null) + * @param {string} format 'report' | 'executive' | 'technical' + */ +function buildPdfHtml(data, aspa, format) { + const asn = data.asn || '?'; + const name = data.name || 'Unknown Network'; + const score = Math.round(data.health_score || 0); + const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + const checks = (data.score_breakdown || []); + const rel = data.relationships || {}; + const counts = rel.counts || {}; + // validations is an object keyed by check name; rpki details live in rpki_completeness + const validations = (data.validations && typeof data.validations === 'object' && !Array.isArray(data.validations)) ? data.validations : {}; + const rpkiDetails = (validations.rpki_completeness?.details || []); + const totalPfx = data.meta?.total_prefixes || 0; + + // Derived stats — use rpki_completeness for RPKI metrics + const rpki = validations.rpki_completeness || {}; + const rpkiCoverage = rpki.coverage_pct ?? 0; + const rpkiTotal = rpki.total_checked || rpkiDetails.length || 0; + const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length; + const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').length; + const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not_found').length; + + const passedChecks = checks.filter(c => c.status === 'pass').length; + const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length; + const scoreColor = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626'; + const aspaDirect = aspa?.direct?.has_aspa ?? null; + const aspaProviders= aspa?.direct?.provider_asns?.length || 0; + + // Check list HTML + function checkIcon(passed) { + if (passed) return '<svg class="check-icon check-pass" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>'; + return '<svg class="check-icon check-fail" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>'; + } + + const checksHtml = checks.map(c => { + const isPassed = c.status === 'pass'; + const isInfo = c.status === 'info'; + const icon = checkIcon(isPassed || isInfo); + const statusBadge = isPassed ? 'badge-green' : isInfo ? 'badge-indigo' : 'badge-red'; + const statusLabel = isPassed ? 'PASS' : isInfo ? 'INFO' : 'FAIL'; + return ` + <li> + ${icon} + <div> + <span style="font-weight:600">${escHtml(c.check || '')}</span> + <span class="badge ${statusBadge}" style="margin-left:6px">${statusLabel}</span> + ${c.earned !== undefined ? `<span class="badge badge-indigo" style="margin-left:4px">${c.earned}/${c.weight}pt</span>` : ''} + </div> + </li>`; + }).join(''); + + // Upstream providers table + const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => ` + <tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr> + `).join(''); + + // Prefix validations table (sample from rpki_completeness.details) + const pfxRows = rpkiDetails.slice(0, 20).map(v => { + const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray'; + return `<tr><td>${escHtml(v.prefix)}</td> + <td><span class="badge ${badge}">${v.status}</span></td> + <td>${v.validating_roas ?? '-'}</td> + <td>—</td></tr>`; + }).join(''); + + // Recommendations + const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info'); + const recHtml = failedItems.length === 0 + ? '<p style="color:#16a34a;font-weight:500">✓ No critical issues found. Network health is excellent.</p>' + : failedItems.map((c, i) => ` + <div style="margin:8px 0; padding:10px 14px; background:#fef2f2; border-left:3px solid #ef4444; border-radius:4px;"> + <div style="font-weight:600;color:#991b1b">${i+1}. ${escHtml(c.check || '')}</div> + ${c.detail ? `<div style="font-size:9pt;color:#6b7280;margin-top:3px">${escHtml(c.detail)}</div>` : ''} + </div>`).join(''); + + // ── Executive pages (always present) ─────────────────────────────────────── + const coverPage = ` + <div class="page cover"> + <div class="cover-stripe"></div> + <div class="cover-logo">⬡ PeerCortex <span>Network Intelligence</span></div> + <div class="cover-asn">AS${asn}</div> + <div class="cover-name">${escHtml(name)}</div> + <div class="cover-sub">${formatLabel(format)} Report</div> + <div style="margin:20px 0; display:flex; align-items:center; gap:16px;"> + ${scoreRingSvg(score)} + <div> + <div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Health Score</div> + <div style="font-size:26pt;font-weight:700;color:${scoreColor}">${score}/100</div> + <div style="font-size:9pt;color:#6b7280">${passedChecks} passed · ${failedChecks} failed</div> + </div> + </div> + <div class="cover-meta"> + Generated by PeerCortex · ${ts} · peercortex.org +   |   ${totalPfx} prefixes analysed +   |   RPKI coverage ${rpkiCoverage}% + </div> + </div>`; + + const summaryPage = ` + <div class="page"> + <h2>Executive Summary</h2> + <p class="section-intro">Key performance indicators for AS${asn} (${escHtml(name)}).</p> + <div class="metric-grid"> + <div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}</div><div class="unit">out of 100</div></div> + <div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid}/${rpkiTotal} prefixes</div></div> + <div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">providers</div></div> + <div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div> + <div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP sessions</div></div> + <div class="metric-card"><div class="label">ASPA Status</div> + <div class="value" style="font-size:12pt;margin-top:4px"> + <span class="badge ${aspaDirect === true ? 'badge-green' : aspaDirect === false ? 'badge-red' : 'badge-gray'}"> + ${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'} + </span> + </div> + <div class="unit">${aspaProviders} providers</div> + </div> + </div> + <h3>Status Overview</h3> + <ul class="check-list">${checksHtml}</ul> + </div>`; + + // ── Recommendations page ──────────────────────────────────────────────────── + const recsPage = ` + <div class="page"> + <h2>Recommendations</h2> + <p class="section-intro">Action items to improve network health and security posture.</p> + ${recHtml} + ${failedItems.length > 0 ? ` + <h3 style="margin-top:1.5em">Remediation Priority</h3> + <table> + <tr><th>#</th><th>Check</th><th>Impact</th><th>Effort</th></tr> + ${failedItems.map((c, i) => ` + <tr> + <td>${i+1}</td> + <td>${escHtml(c.check || '')}</td> + <td><span class="badge badge-red">High</span></td> + <td><span class="badge badge-yellow">Medium</span></td> + </tr>`).join('')} + </table>` : ''} + </div>`; + + // ── Detail pages (included for 'report' and 'technical') ─────────────────── + const rpkiPage = ` + <div class="page"> + <h2>RPKI Compliance</h2> + <p class="section-intro">Route Origin Authorization (ROA) validation results for announced prefixes.</p> + <div class="metric-grid"> + <div class="metric-card"><div class="label">Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid ROAs</div></div> + <div class="metric-card"><div class="label">Invalid</div><div class="value" style="color:#dc2626">${rpkiInvalid}</div><div class="unit">prefix${rpkiInvalid !== 1 ? 'es' : ''}</div></div> + <div class="metric-card"><div class="label">Not Found</div><div class="value" style="color:#d97706">${rpkiNotFound}</div><div class="unit">no ROA</div></div> + </div> + <h3>ASPA (Autonomous System Provider Authorization)</h3> + <table> + <tr><th>Attribute</th><th>Value</th></tr> + <tr><td>ASPA Object Present</td><td><span class="badge ${aspaDirect ? 'badge-green' : 'badge-red'}">${aspaDirect ? 'Yes' : 'No'}</span></td></tr> + <tr><td>Provider Count</td><td>${aspaProviders}</td></tr> + <tr><td>Providers</td><td>${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || '—'}</td></tr> + </table> + ${pfxRows ? ` + <h3>Prefix Validation Sample (first 20)</h3> + <table> + <tr><th>Prefix</th><th>Status</th><th>Max Length</th><th>ROA Origin</th></tr> + ${pfxRows} + </table>` : ''} + </div>`; + + const bgpPage = ` + <div class="page"> + <h2>BGP Topology</h2> + <p class="section-intro">AS relationship overview and upstream connectivity analysis.</p> + <div class="metric-grid"> + <div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">transit providers</div></div> + <div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div> + <div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP peers</div></div> + </div> + ${upstreamRows ? ` + <h3>Top Upstream Providers</h3> + <table> + <tr><th>ASN</th><th>Name</th><th>Power Score</th></tr> + ${upstreamRows} + </table>` : '<p style="color:#9ca3af">No upstream data available.</p>'} + </div>`; + + const footer = ` + <footer> + <span>PeerCortex · AS${asn} · ${escHtml(name)}</span> + <span>Generated ${ts} · peercortex.org</span> + </footer>`; + + // ── Extra pages for technical / report formats ───────────────────────────── + + // Technical: full prefix table (no row cap) + all upstream rows + check scores + const allPfxRows = rpkiDetails.map(v => { + const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray'; + return `<tr><td>${escHtml(v.prefix)}</td> + <td><span class="badge ${badge}">${v.status}</span></td> + <td>${v.validating_roas ?? '-'}</td> + <td>${v.max_length ?? '-'}</td></tr>`; + }).join(''); + + const allUpstreamRows = (rel.upstreams || []).map(u => + `<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>` + ).join(''); + + const checkScoreRows = checks.map(c => + `<tr> + <td>${escHtml(c.check || '')}</td> + <td><span class="badge ${c.status === 'pass' ? 'badge-green' : c.status === 'info' ? 'badge-indigo' : 'badge-red'}">${c.status?.toUpperCase()}</span></td> + <td>${c.earned ?? '-'}</td><td>${c.weight ?? '-'}</td> + <td style="font-size:8pt;color:#6b7280">${escHtml(c.detail || '')}</td> + </tr>` + ).join(''); + + const technicalHeaderPage = ` + <div class="page" style="padding-top:2cm"> + <div style="border-left:4px solid #6366f1;padding-left:16px;margin-bottom:24px"> + <div style="font-size:8pt;text-transform:uppercase;letter-spacing:1px;color:#6b7280">PeerCortex · Technical Analysis Report</div> + <div style="font-size:22pt;font-weight:700;margin:4px 0">AS${asn} — ${escHtml(name)}</div> + <div style="font-size:9pt;color:#6b7280">Generated ${ts} · peercortex.org · ${totalPfx} prefixes · RPKI ${rpkiCoverage}%</div> + </div> + <div class="metric-grid"> + <div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}/100</div><div class="unit">${passedChecks} pass · ${failedChecks} fail</div></div> + <div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid · ${rpkiInvalid} invalid · ${rpkiNotFound} missing</div></div> + <div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">transit providers</div></div> + <div class="metric-card"><div class="label">Peers / Downstreams</div><div class="value">${counts.peers || 0} / ${counts.downstreams || 0}</div><div class="unit">BGP relationships</div></div> + <div class="metric-card"><div class="label">ASPA</div> + <div class="value" style="font-size:11pt;margin-top:6px"><span class="badge ${aspaDirect === true ? 'badge-green' : 'badge-red'}">${aspaDirect === true ? 'Deployed' : 'Not deployed'}</span></div> + <div class="unit">${aspaProviders} providers registered</div> + </div> + <div class="metric-card"><div class="label">Prefixes Analysed</div><div class="value">${totalPfx}</div><div class="unit">${rpkiDetails.length} with RPKI data</div></div> + </div> + </div>`; + + const rpkiFullPage = ` + <div class="page"> + <h2>RPKI Compliance — Full Prefix Table</h2> + <p class="section-intro">${rpkiDetails.length} prefixes with RPKI validation data. Coverage: ${rpkiCoverage}% · Valid: ${rpkiValid} · Invalid: ${rpkiInvalid} · Not Found: ${rpkiNotFound}</p> + <h3>ASPA Object</h3> + <table> + <tr><th>Attribute</th><th>Value</th></tr> + <tr><td>ASPA Present</td><td><span class="badge ${aspaDirect ? 'badge-green' : 'badge-red'}">${aspaDirect ? 'Yes' : 'No'}</span></td></tr> + <tr><td>Provider Count</td><td>${aspaProviders}</td></tr> + <tr><td>Registered Providers</td><td style="font-size:8pt">${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || '—'}</td></tr> + </table> + ${allPfxRows ? ` + <h3 style="margin-top:1.5em">All Prefixes</h3> + <table> + <tr><th>Prefix</th><th>RPKI Status</th><th>Validating ROAs</th><th>Max Length</th></tr> + ${allPfxRows} + </table>` : '<p style="color:#9ca3af">No RPKI prefix data available.</p>'} + </div>`; + + const bgpFullPage = ` + <div class="page"> + <h2>BGP Topology — Full Relationship Table</h2> + <p class="section-intro">Complete AS relationship data. Upstreams: ${counts.upstreams || 0} · Peers: ${counts.peers || 0} · Downstreams: ${counts.downstreams || 0}</p> + ${allUpstreamRows ? ` + <h3>All Upstream Providers</h3> + <table> + <tr><th>ASN</th><th>Name</th><th>Power Score</th></tr> + ${allUpstreamRows} + </table>` : '<p style="color:#9ca3af">No upstream data available.</p>'} + ${(rel.peers || []).length > 0 ? ` + <h3 style="margin-top:1.5em">Peer Sample (top 20)</h3> + <table> + <tr><th>ASN</th><th>Name</th></tr> + ${(rel.peers || []).slice(0, 20).map(p => `<tr><td>AS${p.asn}</td><td>${escHtml(p.name || '')}</td></tr>`).join('')} + </table>` : ''} + </div>`; + + const checkDetailPage = ` + <div class="page"> + <h2>Check Score Detail</h2> + <p class="section-intro">All ${checks.length} health checks with individual scores and diagnostic notes.</p> + <table> + <tr><th>Check</th><th>Status</th><th>Earned</th><th>Weight</th><th>Detail</th></tr> + ${checkScoreRows} + </table> + <div style="margin-top:1.5em;padding:12px;background:#f9fafb;border:1px solid #e5e7eb;font-size:8pt;color:#6b7280"> + <strong>Scoring:</strong> Total = sum of earned points across all checks. Maximum possible: ${checks.reduce((s, c) => s + (c.weight || 0), 0)} points. + Current score: ${checks.reduce((s, c) => s + (c.earned || 0), 0)} / ${checks.reduce((s, c) => s + (c.weight || 0), 0)} = ${score}/100 + </div> + </div>`; + + // Report: add data provenance and ASPA deep-dive + const aspaPage = ` + <div class="page"> + <h2>ASPA — Provider Authorization Analysis</h2> + <p class="section-intro">Autonomous System Provider Authorization (ASPA) is a BGP security mechanism that cryptographically links an ASN to its upstream providers in the RPKI.</p> + <div class="metric-grid"> + <div class="metric-card"><div class="label">ASPA Deployed</div> + <div class="value" style="font-size:12pt;margin-top:6px"><span class="badge ${aspaDirect === true ? 'badge-green' : 'badge-red'}">${aspaDirect === true ? 'Yes' : 'No'}</span></div> + </div> + <div class="metric-card"><div class="label">Providers Listed</div><div class="value">${aspaProviders}</div><div class="unit">in ASPA object</div></div> + <div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">of announced prefixes</div></div> + </div> + <h3>What ASPA Protects Against</h3> + <table> + <tr><th>Threat</th><th>ASPA Mitigates?</th><th>Notes</th></tr> + <tr><td>Route leaks (valley-free violations)</td><td><span class="badge badge-green">Yes</span></td><td>Providers verify upstream path</td></tr> + <tr><td>BGP hijacks (origin AS spoofing)</td><td><span class="badge badge-indigo">Partial</span></td><td>Combined with ROV</td></tr> + <tr><td>AS-path prepending attacks</td><td><span class="badge badge-green">Yes</span></td><td>Invalid paths rejected</td></tr> + <tr><td>Forged-origin hijacks</td><td><span class="badge badge-red">No</span></td><td>ROA + ROV required</td></tr> + </table> + ${aspaDirect === false ? ` + <div style="margin-top:1.5em;padding:14px;background:#fef2f2;border-left:3px solid #ef4444;border-radius:4px"> + <div style="font-weight:600;color:#991b1b;margin-bottom:6px">Recommendation: Deploy ASPA</div> + <div style="font-size:9pt;color:#6b7280"> + 1. Identify all upstream transit providers (${counts.upstreams || 0} detected)<br> + 2. Create an ASPA object in your RIR portal listing their ASNs<br> + 3. Sign with your RPKI key — takes effect within hours<br> + 4. Verify with: <code>rpki-client -v</code> or RIPE RPKI Validator + </div> + </div>` : ` + <div style="margin-top:1.5em;padding:14px;background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px"> + <div style="font-weight:600;color:#15803d">ASPA is deployed — listed providers:</div> + <div style="font-size:9pt;color:#374151;margin-top:6px">${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(' · ') || '—'}</div> + </div>`} + </div>`; + + const provenancePage = ` + <div class="page"> + <h2>Data Provenance</h2> + <p class="section-intro">All data sources, cache ages, and methodology used to generate this report.</p> + <table> + <tr><th>Data Source</th><th>What it provides</th><th>Update Frequency</th></tr> + <tr><td>Cloudflare RPKI Feed</td><td>ROA + ASPA objects</td><td>Every 4 hours</td></tr> + <tr><td>PeeringDB</td><td>IXP presence, peering policy, facilities</td><td>Daily (03:00 UTC)</td></tr> + <tr><td>RIPE Stat</td><td>AS relationships, prefix visibility, BGP data</td><td>Real-time / cached 15 min</td></tr> + <tr><td>RIPE Atlas</td><td>Active measurement probes by ASN</td><td>Cached 12 hours</td></tr> + <tr><td>bgproutes.io</td><td>BGP route visibility fallback</td><td>Real-time</td></tr> + <tr><td>bgp.he.net</td><td>AS relationship graph</td><td>Cached 24 hours</td></tr> + </table> + <h3 style="margin-top:1.5em">Report Metadata</h3> + <table> + <tr><th>Field</th><th>Value</th></tr> + <tr><td>ASN</td><td>AS${asn}</td></tr> + <tr><td>Network Name</td><td>${escHtml(name)}</td></tr> + <tr><td>Report Format</td><td>${formatLabel(format)}</td></tr> + <tr><td>Generated</td><td>${ts}</td></tr> + <tr><td>Platform</td><td>PeerCortex · peercortex.org</td></tr> + <tr><td>Prefixes Analysed</td><td>${totalPfx}</td></tr> + <tr><td>RPKI Objects Checked</td><td>${rpkiDetails.length}</td></tr> + <tr><td>Health Checks Run</td><td>${checks.length}</td></tr> + </table> + <div style="margin-top:1.5em;font-size:8pt;color:#9ca3af;border-top:1px solid #e5e7eb;padding-top:12px"> + This report is generated automatically based on publicly available BGP and RPKI data. + Data may be delayed by cache TTLs. Not a substitute for real-time monitoring. + MIT License · github.com/renefichtmueller/PeerCortex + </div> + </div>`; + + // ── Compose pages by format ──────────────────────────────────────────────── + let bodyContent; + if (format === 'executive') { + // 3 pages: clean stakeholder summary — no deep technical data + bodyContent = coverPage + summaryPage + recsPage; + } else if (format === 'technical') { + // ~8 pages: engineer-focused, dense, no decorative cover, full data tables + bodyContent = technicalHeaderPage + rpkiFullPage + bgpFullPage + checkDetailPage + recsPage; + } else { + // 'report' — full stakeholder + technical, ~10 pages + bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + aspaPage + recsPage + provenancePage; + } + + return `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"/> + <title>PeerCortex — AS${asn} ${formatLabel(format)} + + + + ${bodyContent} + ${footer} + +`; +} + +/** + * Build comparison PDF HTML for two ASNs. + */ +function buildComparisonPdfHtml(d1, aspa1, l1, d2, aspa2, l2) { + const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + + function col(d, aspa) { + const asn = d.asn || '?'; + const name = d.name || 'Unknown'; + const score = Math.round(d.health_score || 0); + const rel = d.relationships || {}; + const counts= rel.counts || {}; + const vals = (d.validations && !Array.isArray(d.validations)) ? d.validations : {}; + const rpki = vals.rpki_completeness || {}; + const rpkiV = rpki.with_roa || 0; + const rpkiT = rpki.total_checked || 0; + const rpkiC = rpki.coverage_pct ?? (rpkiT > 0 ? Math.round((rpkiV / rpkiT) * 100) : 0); + const sc = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626'; + const aspaDep = aspa?.direct?.has_aspa ?? null; + const checks= (d.score_breakdown || []); + const passC = checks.filter(c => c.status === 'pass').length; + const failC = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length; + + return ` + +
    + ${scoreRingSvg(score, sc)} +
    AS${asn}
    +
    ${escHtml(name)}
    +
    + + + + + + + + + + + +
    Key Metrics
    Health Score${score}/100
    RPKI Coverage${rpkiC}% (${rpkiV}/${rpkiT})
    Upstreams${counts.upstreams || 0}
    Downstreams${counts.downstreams || 0}
    Peers${counts.peers || 0}
    ASPA${aspaDep ? 'Deployed' : 'Not Deployed'}
    Checks Passed${passC} / ${checks.length}
    Checks Failed${failC}
    Total Prefixes${d.meta?.total_prefixes || 0}
    + `; + } + + const colHtml = col(d1, aspa1) + col(d2, aspa2); + + // ── Peering Intelligence: common IXPs + common facilities ───────────────── + const ixList1 = (l1?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps })); + const ixList2 = (l2?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps })); + const ixIds1 = new Set(ixList1.map(x => x.id)); + const ixIds2 = new Set(ixList2.map(x => x.id)); + const commonIxIds = [...ixIds1].filter(id => ixIds2.has(id)); + const commonIxps = commonIxIds.slice(0, 20).map(id => { + const x1 = ixList1.find(x => x.id === id); + const x2 = ixList2.find(x => x.id === id); + return { id, name: x1?.name || 'Unknown', city: x1?.city || '', speed1: x1?.speed, speed2: x2?.speed }; + }); + + const facList1 = (l1?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' })); + const facList2 = (l2?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' })); + const facIds1 = new Set(facList1.map(f => f.id)); + const facIds2 = new Set(facList2.map(f => f.id)); + const commonFacIds = [...facIds1].filter(id => facIds2.has(id)); + const commonFacs = commonFacIds.slice(0, 15).map(id => facList1.find(f => f.id === id)); + + // IXPs of ASN1 where ASN2 is NOT present (opportunity IXPs for ASN2) + const oppsFor2 = ixList1.filter(x => !ixIds2.has(x.id)).slice(0, 8); + const oppsFor1 = ixList2.filter(x => !ixIds1.has(x.id)).slice(0, 8); + + const commonIxHtml = commonIxps.length > 0 + ? ` + + ${commonIxps.map(x => ` + + + + + + `).join('')} +
    IXP NameCityAS${d1.asn} SpeedAS${d2.asn} Speed
    ${escHtml(x.name)}${escHtml(x.city)}${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}
    ` + : '

    No common IXPs found — these networks do not share any Internet Exchange Points.

    '; + + const commonFacHtml = commonFacs.length > 0 + ? ` + + ${commonFacs.map(f => ` + + + + + `).join('')} +
    Datacenter / Colocation FacilityCityCountry
    ${escHtml(f?.name||'')}${escHtml(f?.city||'')}${escHtml(f?.country||'')}
    ` + : '

    No shared colocation facilities found.

    '; + + const oppsHtml = (asns, opps) => opps.length === 0 + ? `

    Already maximally peered on these exchanges — or no data available.

    ` + : opps.map(x => `
    + ${escHtml(x.name)} + ${x.city ? ` · ${escHtml(x.city)}` : ''} + ${x.speed ? `${(x.speed/1000).toLocaleString()}G` : ''} +
    `).join(''); + + const peeringPage = (commonIxps.length + commonFacs.length > 0 || oppsFor1.length + oppsFor2.length > 0) ? ` +
    +

    Peering Intelligence

    +

    Common Internet Exchange Points and colocation facilities — potential peering locations between AS${d1.asn} and AS${d2.asn}.

    + +
    +
    +
    ${commonIxps.length}
    +
    Common IXPs
    +
    +
    +
    ${commonFacs.length}
    +
    Shared Facilities
    +
    +
    + +

    Common Internet Exchange Points (direct peering possible)

    + ${commonIxHtml} + +

    Shared Colocation Facilities

    + ${commonFacHtml} + + ${oppsFor1.length + oppsFor2.length > 0 ? ` +

    Expansion Opportunities

    +
    +
    +
    AS${d2.asn} could join (AS${d1.asn} is there)
    + ${oppsHtml(d2.asn, oppsFor2)} +
    +
    +
    AS${d1.asn} could join (AS${d2.asn} is there)
    + ${oppsHtml(d1.asn, oppsFor1)} +
    +
    ` : ''} +
    ` : ''; + + // Side-by-side check comparison + const allChecks = [...new Set([ + ...(d1.score_breakdown || []).map(c => c.check), + ...(d2.score_breakdown || []).map(c => c.check), + ])]; + const checkRows = allChecks.map(checkName => { + const c1 = (d1.score_breakdown || []).find(c => c.check === checkName); + const c2 = (d2.score_breakdown || []).find(c => c.check === checkName); + const isP1 = c1?.status === 'pass'; + const isP2 = c2?.status === 'pass'; + const icon = p => p ? '✓' : '✗'; + const st = p => p ? 'color:#16a34a;font-weight:700' : 'color:#dc2626;font-weight:700'; + return ` + ${escHtml(checkName)} + ${c1 ? icon(isP1) : '—'} + ${c2 ? icon(isP2) : '—'} + `; + }).join(''); + + return ` + + + + PeerCortex — AS${d1.asn} vs AS${d2.asn} Comparison + + + +
    +
    + +
    +
    ASN Comparison Report
    +
    AS${d1.asn} vs AS${d2.asn}
    +
    ${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}
    +
    + ${colHtml}
    +
    Generated by PeerCortex · ${ts} · peercortex.org
    +
    +
    +

    Head-to-Head Health Checks

    + + + ${checkRows} +
    CheckAS${d1.asn}AS${d2.asn}
    +
    + ${peeringPage} +
    + PeerCortex · AS${d1.asn} vs AS${d2.asn} + Generated ${ts} · peercortex.org +
    + +`; +} + +function formatLabel(fmt) { + return fmt === 'executive' ? 'Executive Summary' : fmt === 'technical' ? 'Technical Analysis' : 'Full Report'; +} + +function escHtml(s) { + if (!s) return ''; + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +module.exports = { scoreRingSvg, pdfBaseCSS, buildPdfHtml, buildComparisonPdfHtml, formatLabel, escHtml }; diff --git a/server/resilience-score.js b/server/resilience-score.js new file mode 100644 index 0000000..a31ae35 --- /dev/null +++ b/server/resilience-score.js @@ -0,0 +1,75 @@ +// 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", + }, + }; +} + +module.exports = { computeResilienceScore }; diff --git a/server/resolve-as-names.js b/server/resolve-as-names.js new file mode 100644 index 0000000..226a692 --- /dev/null +++ b/server/resolve-as-names.js @@ -0,0 +1,23 @@ +const { fetchRipeStatCached } = require("./services/ripe-stat"); + +// Batch resolve AS names via RIPE Stat AS overview API +async function resolveASNames(providers) { + 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; +} + +module.exports = { resolveASNames }; diff --git a/server/route-leak.js b/server/route-leak.js new file mode 100644 index 0000000..862d771 --- /dev/null +++ b/server/route-leak.js @@ -0,0 +1,54 @@ +const { TIER1_ASNS } = require("./data/tier1-asns"); + +// 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.", + }, + }; +} + +module.exports = { computeRouteLeakDetection }; diff --git a/server/routes/aspa-adoption.js b/server/routes/aspa-adoption.js new file mode 100644 index 0000000..5d4522d --- /dev/null +++ b/server/routes/aspa-adoption.js @@ -0,0 +1,114 @@ +const { rpkiAspaMap } = require("../services/rpki"); +const { fetchIpv6AdoptionStats } = require("../ipv6-adoption"); +const { + aspaAdoptionDailyHistory, getAdoptionTrend, buildAspaAdoptionDashboard, +} = require("../aspa-adoption/tracker"); + +// GET /aspa-adoption → dashboard HTML +function handleDashboard(req, res) { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.writeHead(200); + return res.end(buildAspaAdoptionDashboard()); +} + +// Linear regression forecast -- duplicated near-identically in +// buildAspaAdoptionDashboard (server/aspa-adoption/tracker.js) since both +// use the same formula over possibly-different trend windows; kept as two +// call sites rather than merged to avoid changing either's behavior here. +function forecast(data, daysAhead) { + if (data.length < 2) return null; + const n = data.length; + const xs = data.map((_, i) => i); + const ys = data.map(d => d.coverage_pct_atlas); + const xMean = xs.reduce((a,b)=>a+b,0)/n; + const yMean = ys.reduce((a,b)=>a+b,0)/n; + const num = xs.reduce((s,x,i)=>s+(x-xMean)*(ys[i]-yMean),0); + const den = xs.reduce((s,x)=>s+(x-xMean)**2,0); + if (den===0) return yMean; + const slope = num/den; + return Math.max(0, Math.min(100, Math.round((yMean + slope*(n-1+daysAhead))*100)/100)); +} + +// GET /api/aspa-adoption-stats?period=7d|30d|1y +function handleStats(req, res, url) { + const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period')) + ? url.searchParams.get('period') : '30d'; + const trend = getAdoptionTrend(period); + const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1]; + const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2]; + + const trend30 = getAdoptionTrend('30d'); + const result = { + period, + current: { + date: latest?.date, + aspa_objects: latest?.aspa_objects ?? rpkiAspaMap.size, + roa_count: latest?.roa_count, + atlas_asns_total: latest?.atlas_asns_total ?? 0, + atlas_asns_with_aspa: latest?.atlas_asns_with_aspa ?? 0, + coverage_pct_atlas: latest?.coverage_pct_atlas ?? 0, + delta_from_previous: prev ? (latest?.aspa_objects ?? 0) - prev.aspa_objects : 0, + }, + trend: trend.map(s => ({ + date: s.date, + aspa_objects: s.aspa_objects, + coverage_pct_atlas: s.coverage_pct_atlas, + atlas_asns_total: s.atlas_asns_total, + })), + forecast: { + predicted_90d: forecast(trend30, 90), + confidence: trend30.length >= 10 ? 0.75 : 0.5, + method: 'linear_regression', + based_on_samples: trend30.length, + }, + meta: { + total_snapshots: aspaAdoptionDailyHistory.length, + last_updated: latest?.date ?? null, + denominator: 'atlas_visible_asns', + source: 'rpki_cloudflare_feed', + } + }; + res.setHeader('Content-Type', 'application/json'); + res.writeHead(200); + return res.end(JSON.stringify(result, null, 2)); +} + +// GET /api/aspa-adoption-stats/export?format=csv|json&period=30d +function handleStatsExport(req, res, url) { + const fmt = url.searchParams.get('format') === 'csv' ? 'csv' : 'json'; + const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period')) + ? url.searchParams.get('period') : '30d'; + const data = getAdoptionTrend(period); + + if (fmt === 'csv') { + const header = 'date,aspa_objects,roa_count,atlas_asns_total,atlas_asns_with_aspa,coverage_pct_atlas,aspa_delta\n'; + const rows = data.map(s => + `${s.date},${s.aspa_objects},${s.roa_count},${s.atlas_asns_total},${s.atlas_asns_with_aspa},${s.coverage_pct_atlas},${s.aspa_delta??0}` + ).join('\n'); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.csv"`); + res.writeHead(200); + return res.end(header + rows); + } + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.json"`); + res.writeHead(200); + return res.end(JSON.stringify({ period, count: data.length, data }, null, 2)); +} + +// GET /api/ipv6-adoption-stats?refresh=1 +async function handleIpv6Stats(req, res, url) { + const force = url.searchParams.get('refresh') === '1'; + try { + const data = await fetchIpv6AdoptionStats(force); + res.setHeader('Content-Type', 'application/json'); + res.writeHead(200); + return res.end(JSON.stringify(data, null, 2)); + } catch (err) { + res.writeHead(503); + return res.end(JSON.stringify({ error: 'IPv6 stats unavailable', message: err.message })); + } +} + +module.exports = { handleDashboard, handleStats, handleStatsExport, handleIpv6Stats }; diff --git a/server/routes/aspa-legacy.js b/server/routes/aspa-legacy.js new file mode 100644 index 0000000..8164596 --- /dev/null +++ b/server/routes/aspa-legacy.js @@ -0,0 +1,133 @@ +const { fetchRipeStatCached } = require("../services/ripe-stat"); +const { resolveASNames } = require("../resolve-as-names"); +const { ensureAspaCache, lookupAspaFromRpki } = require("../services/rpki"); +const { aspaResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches"); + +// ASPA Check endpoint: /api/aspa?asn=X (existing, kept for compat) +async function handleAspaLegacy(req, res, url) { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); + } + const cachedAspa = resultCacheGet(aspaResultCache, rawAsn); + if (cachedAspa !== undefined) { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(cachedAspa)); + } + const start = Date.now(); + let _aspaDone = false; + const _aspaTimer = setTimeout(() => { + if (!_aspaDone) { + _aspaDone = true; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) })); + } + }, 12000); + try { + const [lgData, neighbourData] = await Promise.all([ + fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 3000 }).catch(() => null), + fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 4000 }), + ]); + + const rrcs = lgData?.data?.rrcs || []; + const asPaths = []; + + rrcs.forEach((rrc) => { + const peers = rrc.peers || []; + peers.forEach((peer) => { + const path = peer.as_path || ""; + const pathArr = path.split(" ").map(Number).filter(Boolean); + if (pathArr.length > 1) { + asPaths.push({ rrc: rrc.rrc, path: pathArr, prefix: peer.prefix || "" }); + } + }); + }); + + // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams) + const neighbours = neighbourData?.data?.neighbours || []; + const leftNeighbours = neighbours.filter((n) => n.type === "left"); + const upstreamSet = new Set(); + leftNeighbours.forEach((n) => upstreamSet.add(n.asn)); + + // Classify left neighbours: high-power = likely upstream, low-power = likely peer + const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1); + const detectedProviders = [...upstreamSet].map((asn) => { + const nb = leftNeighbours.find((n) => n.asn === asn); + const power = nb ? (nb.power || 0) : 0; + const powerPct = Math.round((power / maxPower) * 100); + const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer"; + return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification }; + }); + + await resolveASNames(detectedProviders); + + // Check Cloudflare RPKI feed for ASPA object + await ensureAspaCache(); + const aspaLookup = lookupAspaFromRpki(rawAsn); + const aspaObjectExists = aspaLookup.exists; + const aspaDeclaredProviders = aspaLookup.providers; + + const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", "); + let recommendedAspa = + "aut-num: AS" + rawAsn + "\n" + + "# Recommended ASPA object:\n" + + "# customer: AS" + rawAsn + "\n" + + "# provider-set: " + providerList + "\n" + + "# AFI: ipv4, ipv6\n" + + "#\n" + + "# Detected providers from BGP path analysis:\n" + + detectedProviders.map((p) => "# AS" + p.asn + (p.name ? " (" + p.name + ")" : "")).join("\n"); + + // If ASPA object exists, show RPKI-declared providers + if (aspaObjectExists && aspaDeclaredProviders.length > 0) { + recommendedAspa += "\n#\n# RPKI-declared providers (from Cloudflare RPKI feed):\n" + + aspaDeclaredProviders.map((a) => "# AS" + a).join("\n"); + } + + const samplePaths = asPaths.slice(0, 10).map((p) => { + const pathStr = p.path.map((a) => "AS" + a).join(" -> "); + const idx = p.path.indexOf(parseInt(rawAsn)); + const provider = idx > 0 ? p.path[idx - 1] : null; + return { + rrc: p.rrc, + prefix: p.prefix, + path: pathStr, + detected_provider: provider ? "AS" + provider : null, + provider_in_set: provider ? upstreamSet.has(provider) : false, + }; + }); + + if (_aspaDone) return; // hard timeout already responded + _aspaDone = true; + clearTimeout(_aspaTimer); + const duration = Date.now() - start; + const aspaResult = { + meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() }, + asn: parseInt(rawAsn), + detected_providers: detectedProviders, + provider_count: detectedProviders.length, + aspa_object_exists: aspaObjectExists, + aspa_feed_healthy: aspaLookup.feedLoaded, + aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })), + aspa_declared_count: aspaDeclaredProviders.length, + recommended_aspa: recommendedAspa, + path_analysis: { + total_paths_seen: asPaths.length, + sample_paths: samplePaths, + }, + }; + resultCacheSet(aspaResultCache, rawAsn, aspaResult); + return res.end(JSON.stringify(aspaResult, null, 2)); + } catch (err) { + if (!_aspaDone) { + _aspaDone = true; + clearTimeout(_aspaTimer); + res.writeHead(500); + return res.end(JSON.stringify({ error: "ASPA check failed", message: err.message })); + } + } + +} + +module.exports = { handleAspaLegacy }; diff --git a/server/routes/aspa-verify.js b/server/routes/aspa-verify.js new file mode 100644 index 0000000..9102c17 --- /dev/null +++ b/server/routes/aspa-verify.js @@ -0,0 +1,271 @@ +const { fetchRipeStatCached } = require("../services/ripe-stat"); +const { hasAsSet, verifyUpstream, verifyDownstream, detectValleys, collapsePrepends, calculateAspaReadinessScore } = require("../aspa-verification/engine"); +const { resolveASNames } = require("../resolve-as-names"); +const { ensureAspaCache, lookupAspaFromRpki, rpkiAspaMap, validateRPKIWithCache } = require("../services/rpki"); +const { aspaResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches"); + +// ASPA Deep Verification endpoint: /api/aspa/verify?asn=X +async function handleAspaVerify(req, res, url) { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); + } + const cachedVerify = resultCacheGet(aspaResultCache, "verify:" + rawAsn); + if (cachedVerify !== undefined) { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(cachedVerify)); + } + const targetAsn = parseInt(rawAsn); + const start = Date.now(); + + try { + // Fetch neighbour and prefix data first + const [neighbourData, prefixData] = await Promise.all([ + fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }), + fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }), + ]); + + // Use looking-glass with actual prefixes to get BGP paths + const announcedPrefixes = prefixData?.data?.prefixes || []; + const samplePrefixes = announcedPrefixes.slice(0, 3).map((p) => p.prefix); // reduced 5→3 + + // Fetch looking-glass data for up to 3 prefixes in parallel (3s timeout each) + const lgResults = await Promise.all( + samplePrefixes.map((pfx) => + fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=" + encodeURIComponent(pfx), { timeout: 3000 }).catch(() => null) + ) + ); + + // Extract AS paths from looking glass results + const allPaths = []; + const pathNeighbourCount = new Map(); // Count how often each AS appears next to target in paths + + lgResults.forEach((lgData) => { + const rrcs = lgData?.data?.rrcs || []; + rrcs.forEach((rrc) => { + const peers = rrc.peers || []; + peers.forEach((peer) => { + const rawPath = peer.as_path || ""; + const pathArr = rawPath.split(" ").map(Number).filter(Boolean); + if (pathArr.length > 1) { + allPaths.push({ + rrc: rrc.rrc, + path: pathArr, + rawPath: rawPath, + prefix: peer.prefix || "", + hasAsSet: hasAsSet(rawPath), + }); + const idx = pathArr.indexOf(targetAsn); + if (idx > 0) { + const neighbour = pathArr[idx - 1]; + pathNeighbourCount.set(neighbour, (pathNeighbourCount.get(neighbour) || 0) + 1); + } + } + }); + }); + }); + + // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams) + // AS-path analysis is used for frequency/confirmation, NOT as standalone provider source + const neighbours = neighbourData?.data?.neighbours || []; + const leftNeighbours = neighbours.filter((n) => n.type === "left"); + const upstreamSet = new Set(); + leftNeighbours.forEach((n) => upstreamSet.add(n.asn)); + + // Classify left neighbours: high-power = likely upstream, low-power = likely peer + const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1); + const detectedProviders = [...upstreamSet].map((asn) => { + const nb = leftNeighbours.find((n) => n.asn === asn); + const power = nb ? (nb.power || 0) : 0; + const powerPct = Math.round((power / maxPower) * 100); + const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer"; + return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification }; + }); + + await resolveASNames(detectedProviders); + + // Count how often each provider appears in paths + const providerFrequency = new Map(); + allPaths.forEach((p) => { + const idx = p.path.indexOf(targetAsn); + if (idx > 0) { + const prov = p.path[idx - 1]; + providerFrequency.set(prov, (providerFrequency.get(prov) || 0) + 1); + } + }); + + // Check Cloudflare RPKI feed for ASPA object + await ensureAspaCache(); + const aspaLookup = lookupAspaFromRpki(targetAsn); + const aspaObjectExists = aspaLookup.exists; + const aspaDeclaredProviders = aspaLookup.providers; + + // Build ASPA store from RPKI feed data (real ASPA objects only). If the + // target has no real ASPA object, its hops must show "NoAttestation" -- + // NOT a fabricated declaration built from heuristically-detected BGP + // neighbours, which would contradict the aspa_object_exists:false this + // same response reports and falsely present unattested hops as + // "ProviderPlus"/Valid. hopCheck() already returns "NoAttestation" for + // any ASN with no entry in aspaStore, so simply not setting one here is + // correct and sufficient -- no fallback needed. + const aspaStore = new Map(); + if (aspaObjectExists) { + aspaStore.set(targetAsn, new Set(aspaDeclaredProviders)); + } + // Also populate store with all known ASPA objects from the RPKI feed + // for providers that have their own ASPA objects (enables full path verification) + for (const [cas, provSet] of rpkiAspaMap) { + if (!aspaStore.has(cas)) { + aspaStore.set(cas, provSet); + } + } + // Deliberately NOT adding empty Set() entries for detected-but-unattested + // providers here: aspaStore.get() returning undefined for them makes + // hopCheck() correctly report "NoAttestation". An empty (but present) Set + // would make providers.has(x) return false, misreporting "NotProviderPlus" + // (implying a real ASPA object that denies this hop) instead of "we simply + // don't know" -- see the 2026-07-16 audit for how this previously caused + // clean, unattested paths to be misclassified as route leaks. + + // Sample paths for verification (up to 50) + const samplePaths = allPaths.slice(0, 50); + const pathResults = samplePaths.map((p) => { + const upstream = verifyUpstream(p.path, aspaStore, p.rawPath); + const downstream = verifyDownstream(p.path, aspaStore, p.rawPath); + const valleys = detectValleys(p.path, aspaStore); + + return { + rrc: p.rrc, + prefix: p.prefix, + path: p.path.map((a) => "AS" + a).join(" "), + collapsed_path: collapsePrepends(p.path).map((a) => "AS" + a).join(" "), + has_as_set: p.hasAsSet, + upstream_verification: upstream, + downstream_verification: downstream, + valleys: valleys, + overall: p.hasAsSet + ? "Invalid" + : upstream.result === "Valid" && downstream.result === "Valid" + ? "Valid" + : upstream.result === "Invalid" || downstream.result === "Invalid" + ? "Invalid" + : "Unknown", + }; + }); + + // Calculate statistics + const validPaths = pathResults.filter((p) => p.overall === "Valid").length; + const invalidPaths = pathResults.filter((p) => p.overall === "Invalid").length; + const unknownPaths = pathResults.filter((p) => p.overall === "Unknown").length; + const asSetPaths = pathResults.filter((p) => p.has_as_set).length; + const valleyPaths = pathResults.filter((p) => p.valleys.length > 0).length; + + // For readiness scoring: Valid = full credit, Unknown = partial (no ASPA data is normal), + // only Invalid actually indicates problems + const pathNotInvalidPct = pathResults.length > 0 + ? Math.round(((validPaths + unknownPaths) / pathResults.length) * 100) + : 0; + const pathValidPct = pathResults.length > 0 ? Math.round((validPaths / pathResults.length) * 100) : 0; + + // Provider audit: compare detected vs declared + const detectedSet = new Set(detectedProviders.map((p) => p.asn)); + const declaredSet = new Set(aspaDeclaredProviders); + + const missingFromAspa = detectedProviders + .filter((p) => !declaredSet.has(p.asn)) + .map((p) => ({ + asn: p.asn, + name: p.name, + frequency: providerFrequency.get(p.asn) || 0, + frequency_pct: allPaths.length > 0 + ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100) + : 0, + })) + .sort((a, b) => b.frequency - a.frequency); + + const extraInAspa = aspaDeclaredProviders + .filter((asn) => !detectedSet.has(asn)) + .map((asn) => ({ + asn, + name: "", + seen_in_paths: false, + })); + + const providerCompleteness = detectedProviders.length > 0 + ? Math.round( + (detectedProviders.filter((p) => declaredSet.has(p.asn)).length / + detectedProviders.length) * + 100 + ) + : aspaObjectExists ? 100 : 0; + + // Get RPKI coverage for readiness score + // Validate ALL prefixes using local RPKI data (Cloudflare feed - all 5 RIRs) + await ensureAspaCache(); + const rpkiBatch = announcedPrefixes.map((p) => p.prefix); + const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx))); + // Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup + // must not lower a network's displayed ASPA readiness score. + const rpkiChecked = rpkiResults.filter((r) => r.status !== "unavailable"); + const rpkiValid = rpkiChecked.filter((r) => r.status === "valid").length; + const rpkiCoverage = rpkiChecked.length > 0 ? Math.round((rpkiValid / rpkiChecked.length) * 100) : 0; + + // Calculate readiness score + const readinessScore = calculateAspaReadinessScore({ + rpkiCoverage, + aspaObjectExists, + providerCompleteness, + pathValidationPct: pathNotInvalidPct, + }); + + const duration = Date.now() - start; + const verifyResult = { + meta: { + query: "AS" + rawAsn, + duration_ms: duration, + timestamp: new Date().toISOString(), + paths_analyzed: pathResults.length, + total_paths_seen: allPaths.length, + }, + asn: targetAsn, + readiness_score: readinessScore, + aspa_object_exists: aspaObjectExists, + aspa_feed_healthy: aspaLookup.feedLoaded, + detected_providers: detectedProviders.map((p) => ({ + ...p, + frequency: providerFrequency.get(p.asn) || 0, + frequency_pct: allPaths.length > 0 + ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100) + : 0, + })), + provider_audit: { + declared_count: aspaDeclaredProviders.length, + detected_count: detectedProviders.length, + completeness_pct: providerCompleteness, + missing_from_aspa: missingFromAspa, + extra_in_aspa: extraInAspa, + }, + path_verification: { + total: pathResults.length, + valid: validPaths, + invalid: invalidPaths, + unknown: unknownPaths, + as_set_flagged: asSetPaths, + valley_detected: valleyPaths, + valid_pct: pathValidPct, + not_invalid_pct: pathNotInvalidPct, + results: pathResults, + }, + rpki_coverage: rpkiCoverage, + }; + resultCacheSet(aspaResultCache, "verify:" + rawAsn, verifyResult); + return res.end(JSON.stringify(verifyResult, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "ASPA verification failed", message: err.message })); + } + +} + +module.exports = { handleAspaVerify }; diff --git a/server/routes/bgp.js b/server/routes/bgp.js new file mode 100644 index 0000000..17a0dd8 --- /dev/null +++ b/server/routes/bgp.js @@ -0,0 +1,108 @@ +const localDb = require("../../local-db-client"); +const { bgproutesResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches"); + +// BGP endpoint (LOCAL DB): /api/bgp?asn=X (or prefix=X) +// Queries local PostgreSQL bgp_routes table — zero external API calls +async function handleBgp(req, res, url) { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + const prefix = url.searchParams.get("prefix") || ""; + if (!rawAsn && !prefix) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Need asn or prefix parameter" })); + } + const cacheKey = rawAsn || prefix; + const cached = resultCacheGet(bgproutesResultCache, cacheKey); + if (cached !== undefined) { + res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); + return res.end(JSON.stringify(cached)); + } + const start = Date.now(); + try { + const result = { + meta: { timestamp: new Date().toISOString(), source: "local_bgp_db" }, + bgp_status: null, + threat_intel: null, + }; + + // ---- BGP Status (local DB lookup) ---- + if (prefix) { + // Prefix lookup: Get BGP status for this prefix + const bgpStatus = await localDb.getBgpStatus(prefix); + if (bgpStatus) { + result.bgp_status = { + prefix, + announced: bgpStatus.announced, + origin_asns: bgpStatus.origin_asns, + visibility_percent: bgpStatus.visibility_percent, + last_seen: bgpStatus.last_seen, + source: "local_bgp", + }; + // Check for hijack (multiple origin ASNs). null means the check itself + // failed (DB error) -- distinct from a genuine single/no-origin result. + const hijackAsns = await localDb.checkBgpHijack(prefix); + if (hijackAsns === null) { + result.bgp_status.hijack_check_unavailable = true; + } else if (hijackAsns.length > 1) { + result.bgp_status.hijack_warning = { + detected: true, + origin_asns: hijackAsns, + message: `Multiple origin ASNs detected for ${prefix}`, + }; + } + } + } else if (rawAsn) { + // ASN lookup: Get all announced prefixes for this ASN + const prefixes = await localDb.getAnnouncedPrefixes(rawAsn); + if (prefixes && prefixes.length > 0) { + result.bgp_status = { + asn: rawAsn, + announced_count: prefixes.length, + prefixes: prefixes.slice(0, 50).map((p) => ({ + prefix: p.prefix, + origin_asn: p.origin_asn, + visibility_percent: p.visibility_percent, + last_seen: p.last_seen, + })), + source: "local_bgp", + }; + } else { + result.bgp_status = { + asn: rawAsn, + announced: false, + announced_count: 0, + message: "No prefixes found for this ASN in local BGP table", + source: "local_bgp", + }; + } + } + + // ---- Threat Intelligence (local cache lookup) ---- + // If we have an IP context, look up threat intel + if (prefix && prefix.includes(".")) { + // Extract IP from prefix (e.g., "1.1.1.0/24" → "1.1.1.0") + const ipAddr = prefix.split("/")[0]; + const threat = await localDb.getThreatIntel(ipAddr); + if (threat) { + result.threat_intel = { + ip_address: threat.ip_address, + threat_level: threat.threat_level, + confidence_score: threat.confidence_score, + source: threat.source, + cached_at: threat.cached_at, + }; + } + } + + result.meta.duration_ms = Date.now() - start; + resultCacheSet(bgproutesResultCache, cacheKey, result); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(result, null, 2)); + } catch (err) { + console.error("[/api/bgp] Error:", err.message); + res.writeHead(500); + return res.end(JSON.stringify({ error: "BGP query failed", message: err.message })); + } + +} + +module.exports = { handleBgp }; diff --git a/server/routes/changelog.js b/server/routes/changelog.js new file mode 100644 index 0000000..4cc5401 --- /dev/null +++ b/server/routes/changelog.js @@ -0,0 +1,46 @@ +const fs = require("fs"); + +// Changelog page: renders CHANGELOG.md as HTML +function handleChangelog(req, res) { + 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 += `

    ${line.slice(3)}

    `; + } else if (line.startsWith('### ')) { + html += `

    ${line.slice(4)}

    `; + } else if (line.startsWith('- **')) { + const m = line.replace(/^- \*\*(.+?)\*\*(.*)$/, '$1$2'); + html += `

    · ${m}

    `; + } else if (line.startsWith('- ')) { + html += `

    · ${line.slice(2)}

    `; + } else if (line.startsWith('# ')) { + html += `

    ${line.slice(2)}

    `; + } + } + const page = `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'); + } +} + +module.exports = { handleChangelog }; diff --git a/server/routes/compare.js b/server/routes/compare.js new file mode 100644 index 0000000..fe2f13e --- /dev/null +++ b/server/routes/compare.js @@ -0,0 +1,175 @@ +const localDb = require("../../local-db-client"); +const { fetchRipeStatCached } = require("../services/ripe-stat"); +const { fetchPeeringDB } = require("../services/peeringdb"); +const { validateRPKIWithCache } = require("../services/rpki"); +const { cacheGet, cacheSet, CACHE_TTL_DEFAULT } = require("../caches/response-caches"); + +// Compare endpoint: /api/compare?asn1=X&asn2=Y +async function handleCompare(req, res, url) { + 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) => validateRPKIWithCache(asn1, p))), + Promise.all(pfx2.map((p) => validateRPKIWithCache(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; + +} + +module.exports = { handleCompare }; diff --git a/server/routes/enrich.js b/server/routes/enrich.js new file mode 100644 index 0000000..e6f80c7 --- /dev/null +++ b/server/routes/enrich.js @@ -0,0 +1,125 @@ +const http = require("http"); +const https = require("https"); +const { fetchJSON } = require("../services/http-helpers"); + +// Feature 28b: Company enrichment via Wikipedia + website meta scraping +async function handleEnrich(req, res, url) { + 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 = [ + /]+name=["']description["'][^>]+content=["']([^"']{20,500})["']/i, + /]+content=["']([^"']{20,500})["'][^>]+name=["']description["']/i, + /]+property=["']og:description["'][^>]+content=["']([^"']{20,500})["']/i, + /]+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 })); + +} + +module.exports = { handleEnrich }; diff --git a/server/routes/feedback.js b/server/routes/feedback.js new file mode 100644 index 0000000..630df32 --- /dev/null +++ b/server/routes/feedback.js @@ -0,0 +1,70 @@ +const fs = require("fs"); +const { sendFeedbackMail } = require("../../src/backend/services/smtp"); + +const FEEDBACK_TOKEN = process.env.FEEDBACK_TOKEN || "changeme-set-in-env"; +const FEEDBACK_FILE = "/opt/peercortex-app/feedback.json"; + +function handleOptions(req, res) { + 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 +function handlePost(req, res) { + 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 +function handleGet(req, res, url) { + 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 })); + } +} + +module.exports = { handleOptions, handlePost, handleGet }; diff --git a/server/routes/health.js b/server/routes/health.js new file mode 100644 index 0000000..f4d0319 --- /dev/null +++ b/server/routes/health.js @@ -0,0 +1,104 @@ +const localDb = require("../../local-db-client"); +const { getRpkiAspaLastFetch, rpkiAspaMap } = require("../services/rpki"); +const { pdbSourceCache } = require("../caches/pdb-source-cache"); +const { roaStore } = require("../caches/roa-store"); +const { ripeStatCache } = require("../services/ripe-stat"); +const { responseCache } = require("../caches/response-caches"); +const { aspaAdoptionDailyHistory } = require("../aspa-adoption/tracker"); + +const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || ""; + +function aspaAdoptionDelta() { + return aspaAdoptionDailyHistory.length >= 2 + ? aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1].aspa_objects - aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2].aspa_objects + : 0; +} + +// Health endpoint — extended with cache status, ASPA metrics, and local DB stats +function handleHealth(req, res) { + const mem = process.memoryUsage(); + const rpkiAspaLastFetch = getRpkiAspaLastFetch(); + const aspaAge = rpkiAspaLastFetch ? Math.floor((Date.now() - rpkiAspaLastFetch) / 60000) : -1; + // Bug fix 2026-07-17: previously only declared inside the .then() callback + // below, so the .catch() branch's reference to it threw ReferenceError + // (never caught -- the response just hung until client timeout). + const roaAge = roaStore.lastBuild ? Math.floor((Date.now() - roaStore.lastBuild) / 60000) : -1; + const pdbTotal = pdbSourceCache.hits + pdbSourceCache.misses; + + // Query local DB stats (async, but return partial if needed) + localDb.getLocalDbStats().then(function(dbStats) { + // Determine health status based on local DB data availability + const hasLocalBgp = dbStats && dbStats.bgp_routes > 100000; // should have >2M rows normally + const hasLocalRpki = dbStats && dbStats.rpki_roas > 100000; // should have >500k rows normally + const status = (hasLocalBgp && hasLocalRpki && aspaAge < 300) ? "ok" : "degraded"; + + const healthResponse = { + status, + service: "PeerCortex", + version: "0.6.9", + timestamp: new Date().toISOString(), + uptime_seconds: Math.floor(process.uptime()), + memory_mb: Math.round(mem.heapUsed / 1024 / 1024), + bgproutes_configured: !!BGPROUTES_API_KEY, + caches: { + aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge }, + pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 }, + pdb_netixlan: { entries: pdbSourceCache.netixlan.size }, + pdb_netfac: { entries: pdbSourceCache.netfac.size }, + ripe_stat: { entries: ripeStatCache.size }, + response_cache: { entries: responseCache.size }, + }, + local_db: dbStats ? { + bgp_routes: dbStats.bgp_routes, + rpki_roas: dbStats.rpki_roas, + threat_intel: dbStats.threat_intel, + rdap_cache_entries: dbStats.rdap_cache_entries, + source: "PostgreSQL (local)", + healthy: hasLocalBgp && hasLocalRpki, + } : null, + aspa_adoption: { + total_objects: rpkiAspaMap.size, + roa_count: roaStore.count, + history_samples: aspaAdoptionDailyHistory.length, + delta_last: aspaAdoptionDelta(), + }, + }; + 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. Local DB stats failed + // to fetch, so "ok" can't be confirmed -- report degraded, matching the + // honest-degradation pattern used throughout this codebase. + const status = "degraded"; + return res.end( + JSON.stringify({ + status, + service: "PeerCortex", + version: "0.6.9", + timestamp: new Date().toISOString(), + uptime_seconds: Math.floor(process.uptime()), + memory_mb: Math.round(mem.heapUsed / 1024 / 1024), + bgproutes_configured: !!BGPROUTES_API_KEY, + caches: { + roa_store: { entries: roaStore.count, age_minutes: roaAge, ready: roaStore.ready }, + aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge }, + pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 }, + pdb_netixlan: { entries: pdbSourceCache.netixlan.size }, + pdb_netfac: { entries: pdbSourceCache.netfac.size }, + ripe_stat: { entries: ripeStatCache.size }, + response_cache: { entries: responseCache.size }, + }, + local_db: { error: "Could not fetch local DB stats", message: e.message }, + aspa_adoption: { + total_objects: rpkiAspaMap.size, + roa_count: roaStore.count, + history_samples: aspaAdoptionDailyHistory.length, + delta_last: aspaAdoptionDelta(), + }, + }, null, 2) + ); + }); + return; +} + +module.exports = { handleHealth }; diff --git a/server/routes/hijack-alerts.js b/server/routes/hijack-alerts.js new file mode 100644 index 0000000..a210ae0 --- /dev/null +++ b/server/routes/hijack-alerts.js @@ -0,0 +1,221 @@ +const crypto = require("crypto"); +const { + loadHijackSubs, loadHijackAlerts, loadWebhookSubs, + saveWebhookSubs, saveHijackAlerts, saveHijackSubs, +} = require("../hijack-monitoring/store"); +const { deliverWebhook } = require("../hijack-monitoring/webhooks"); +const { checkHijacksForAsn } = require("../hijack-monitoring/monitor"); + +function matchesHijackGroup(reqPath) { + return reqPath.startsWith('/api/webhooks') || reqPath.startsWith('/api/hijacks') || reqPath === '/api/hijack-subscribe'; +} + +// CORS preflight for all /api/webhooks + /api/hijacks +function handleOptions(req, res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.writeHead(204); return res.end(); +} + +// Webhook: Register. POST /api/webhooks?asn=X body: { endpoint_url, timeout_ms?, max_retries? } +function handleWebhookRegister(req, res) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : ''; + if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn query param required'})); } + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const { endpoint_url, timeout_ms, max_retries } = JSON.parse(body || '{}'); + if (!endpoint_url || !endpoint_url.startsWith('http')) { + res.writeHead(400); return res.end(JSON.stringify({error:'endpoint_url required (http/https)'})); + } + const subs = loadWebhookSubs(); + const exists = subs.find(s => s.asn === asn && s.endpoint_url === endpoint_url); + if (exists) { res.writeHead(200); return res.end(JSON.stringify({id: exists.id, already_exists: true, secret_key: exists.secret})); } + const webhookToken = crypto.randomBytes(32).toString('hex'); + const entry = { + id: crypto.randomBytes(8).toString('hex'), + asn, endpoint_url, + secret: webhookToken, + timeout_ms: timeout_ms || 10000, + max_retries: max_retries || 5, + active: true, + created_at: new Date().toISOString(), + last_triggered_at: null, + failure_count: 0 + }; + subs.push(entry); + saveWebhookSubs(subs); + // Also ensure this ASN is in hijack monitoring + const hijackSubs = loadHijackSubs(); + if (!hijackSubs.find(s => s.asn === asn)) { + checkHijacksForAsn(asn).then(prefixes => { + // null (lookup failed) becomes [] here so this record's shape stays + // consistent; runHijackCheck retries the real baseline next cycle. + hijackSubs.push({ asn, prefixes: prefixes || [], subscribed: new Date().toISOString() }); + saveHijackSubs(hijackSubs); + }); + } + res.writeHead(201); + // Bug fix 2026-07-17 (agreed pre-existing bug #1 of 3): responded with + // `secret_key: secret`, but `secret` was never declared in this scope + // (only `webhookToken` and `entry.secret`, the same value) -- every new + // webhook registration threw ReferenceError here, caught below, + // degrading to a 400 "secret is not defined". Use entry.secret, matching + // the working "already exists" branch above. + res.end(JSON.stringify({ id: entry.id, asn, endpoint_url, secret_key: entry.secret, created_at: entry.created_at, note: 'Store secret_key safely — it signs all webhook payloads' })); + } catch(e) { res.writeHead(400); res.end(JSON.stringify({error: e.message})); } + }); + return; +} + +// Webhook: List. GET /api/webhooks?asn=X +function handleWebhookList(req, res) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : ''; + const subs = loadWebhookSubs(); + const result = (asn ? subs.filter(s => s.asn === asn) : subs) + .map(s => ({ id: s.id, asn: s.asn, endpoint_url: s.endpoint_url, active: s.active, failure_count: s.failure_count, last_triggered_at: s.last_triggered_at, created_at: s.created_at })); + res.writeHead(200); + return res.end(JSON.stringify({ webhooks: result, total: result.length })); +} + +// Webhook: Delete. DELETE /api/webhooks/:id +function handleWebhookDelete(req, res, reqPath) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + const id = reqPath.split('/')[3]; + const subs = loadWebhookSubs(); + const idx = subs.findIndex(s => s.id === id); + if (idx === -1) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); } + subs.splice(idx, 1); + saveWebhookSubs(subs); + res.writeHead(200); + return res.end(JSON.stringify({ deleted: true, id })); +} + +// Webhook: Test. POST /api/webhooks/:id/test +function handleWebhookTest(req, res, reqPath) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + const id = reqPath.split('/')[3]; + const subs = loadWebhookSubs(); + const sub = subs.find(s => s.id === id); + if (!sub) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); } + const testPayload = { event: 'test', asn: sub.asn, message: 'PeerCortex webhook test — if you receive this, delivery works!', timestamp: new Date().toISOString() }; + const start = Date.now(); + deliverWebhook(sub, testPayload, 1).then(result => { + res.writeHead(result.ok ? 200 : 502); + res.end(JSON.stringify({ ok: result.ok, response_time_ms: Date.now() - start, status: result.status, error: result.error || null })); + }); + return; +} + +// Hijack Events: List. GET /api/hijacks?asn=X&limit=50&resolved=false +function handleHijacksList(req, res) { + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : ''; + const limit = Math.min(parseInt(params.get('limit') || '50', 10), 200); + const resolvedFilter = params.get('resolved'); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + let events = loadHijackAlerts(); + if (asn) events = events.filter(a => String(a.asn) === asn); + if (resolvedFilter === 'false') events = events.filter(a => !a.resolved); + if (resolvedFilter === 'true') events = events.filter(a => a.resolved); + const total = events.length; + events = events.slice(-limit).reverse(); + res.writeHead(200); + return res.end(JSON.stringify({ total, events })); +} + +// Hijack Events: Resolve. POST /api/hijacks/:id/resolve body: { resolution_notes? } +function handleHijackResolve(req, res, reqPath) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + const id = reqPath.split('/')[3]; + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + const alerts = loadHijackAlerts(); + const alert = alerts.find(a => a.id === id); + if (!alert) { res.writeHead(404); return res.end(JSON.stringify({error:'event not found'})); } + alert.resolved = true; + alert.resolved_at = new Date().toISOString(); + try { const { resolution_notes } = JSON.parse(body || '{}'); if (resolution_notes) alert.resolution_notes = resolution_notes; } catch(_){} + saveHijackAlerts(alerts); + res.writeHead(200); + res.end(JSON.stringify({ resolved: true, id, resolved_at: alert.resolved_at })); + }); + return; +} + +// Hijack Subscribe (legacy, kept for backwards compatibility) +function handleHijackSubscribe(req, res) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + let body = ''; + req.on('data', c => body += c); + req.on('end', async () => { + try { + const { asn } = JSON.parse(body || '{}'); + const asnNum = String(asn || '').replace(/[^0-9]/g,''); + if (!asnNum) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); } + const subs = loadHijackSubs(); + const exists = subs.find(s => s.asn === asnNum); + if (!exists) { + const prefixes = await checkHijacksForAsn(asnNum); + // prefixes may be null if the lookup failed just now -- store an empty + // array so this record shape stays consistent; runHijackCheck's own + // "!sub.prefixes.length" check will retry establishing the real + // baseline on its next cycle rather than treating this as permanent. + subs.push({ asn: asnNum, prefixes: prefixes || [], subscribed: new Date().toISOString() }); + saveHijackSubs(subs); + } + const sub = subs.find(s => s.asn === asnNum) || { prefixes: [] }; + res.writeHead(200); + res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: (sub.prefixes || []).length })); + } catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); } + }); + return; +} + +// Hijack Alerts (legacy endpoint, kept for backwards compatibility). +// src/routes/hijack-alerts.ts registers /api/webhooks + /api/hijacks under +// the same paths this file already serves above (file-backed, live). +// Deliberately NOT proxied here — that would shadow working data with an +// empty Postgres-backed implementation. Not a gap, just two feature +// branches that ended up naming their routes the same thing. +function handleHijackAlertsLegacy(req, res) { + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = (params.get('asn') || '').replace(/[^0-9]/g,''); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + const allAlerts = loadHijackAlerts(); + const alerts = asn ? allAlerts.filter(a => String(a.asn) === asn) : allAlerts; + const subs = loadHijackSubs(); + const sub = subs.find(s => s.asn === asn); + res.writeHead(200); + return res.end(JSON.stringify({ asn, alerts: alerts.slice(-50), monitoring: !!sub, prefix_count: sub ? sub.prefixes.length : 0 })); +} + +module.exports = { + matchesHijackGroup, + handleOptions, + handleWebhookRegister, + handleWebhookList, + handleWebhookDelete, + handleWebhookTest, + handleHijacksList, + handleHijackResolve, + handleHijackSubscribe, + handleHijackAlertsLegacy, +}; diff --git a/server/routes/ix-detail.js b/server/routes/ix-detail.js new file mode 100644 index 0000000..d55f8be --- /dev/null +++ b/server/routes/ix-detail.js @@ -0,0 +1,59 @@ +const { fetchPeeringDB } = require("../services/peeringdb"); + +// IX Detail endpoint: /api/ix/detail?ix_id=X +async function handleIxDetail(req, res, url) { + 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 })); + } + +} + +module.exports = { handleIxDetail }; diff --git a/server/routes/lia.js b/server/routes/lia.js new file mode 100644 index 0000000..45de159 --- /dev/null +++ b/server/routes/lia.js @@ -0,0 +1,98 @@ +const { atlasState } = require("../atlas-probes"); +const { pdbOrgState } = require("../pdb-org-countries"); +const { fetchPeeringDB } = require("../services/peeringdb"); +const { cacheGet, cacheSet } = require("../caches/response-caches"); + +// Lia's Atlas Paradise: Atlas probe coverage endpoint +function handleAtlasCoverage(req, res) { + res.setHeader("Content-Type", "application/json"); + if (!atlasState.cache) { + res.writeHead(503); + return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." })); + } + return res.end(JSON.stringify(atlasState.cache, null, 2)); +} + +// Lia's Paradise: File parsing endpoint (for binary uploads) +function handleParseFile(req, res) { + 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 +function handleLiaCoverage(req, res) { + res.setHeader("Content-Type", "application/json"); + if (!atlasState.cache) { + res.writeHead(503); + return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." })); + } + + // Cache this expensive response for 30 min + var liaCacheKey = "lia_coverage"; + var liaCached = cacheGet(liaCacheKey); + if (liaCached) return res.end(liaCached); + + // Fetch PeeringDB network list (all networks with status "ok") + // Use pre-cached org→country map (loaded at startup, 16MB response cached in memory) + fetchPeeringDB("/net?status=ok&depth=0").then(function(pdbData) { + if (!pdbData || !pdbData.data) { + return res.end(JSON.stringify({ error: "Could not fetch PeeringDB networks" })); + } + + var probeAsns = new Set(atlasState.cache.asns_with_probes || []); + + var enriched = pdbData.data.map(function(n) { + var org = pdbOrgState.map.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: pdbOrgState.map.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; +} + +module.exports = { handleAtlasCoverage, handleParseFile, handleLiaCoverage }; diff --git a/server/routes/lookup/cross-check.js b/server/routes/lookup/cross-check.js new file mode 100644 index 0000000..f9243a0 --- /dev/null +++ b/server/routes/lookup/cross-check.js @@ -0,0 +1,90 @@ +const { crossCheckRpki } = require("../../services/rpki"); + +// Multi-source cross-checks (RPKI vs RIPE Validator, prefix/neighbour counts +// vs bgp.he.net) plus the overall data-quality summary derived from them. +async function computeCrossChecks(asn, allPrefixes, rpkiStatuses, prefixes, neighbours, bgpHeData) { + let rpkiCrossCheck = { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: null, disagreements: [], sample_size: 0, comparable_size: 0 }; + let prefixCrossCheck = { ripe_stat: prefixes.length, bgp_he_net: null, agreement: null, note: "" }; + let neighbourCrossCheck = { ripe_stat_total: neighbours.length, bgp_he_net_total: null }; + + try { + // RPKI cross-check: sample up to 5 prefixes against RIPE Validator (with 8s total timeout) + const rpkiCrossPromise = crossCheckRpki(asn, allPrefixes, rpkiStatuses); + const rpkiCrossResult = await Promise.race([ + rpkiCrossPromise, + new Promise((resolve) => setTimeout(() => resolve(null), 8000)), + ]); + if (rpkiCrossResult) rpkiCrossCheck = rpkiCrossResult; + } catch (_e) { /* cross-check failed, keep defaults */ } + + // bgp.he.net fetching is deliberately disabled (see the hardcoded + // Promise.resolve(null) in index.js's timedFetch("bgp.he.net", ...)) -- + // bgpHeData is therefore always null and this whole branch, along with the + // prefix/neighbour cross-checks below, never actually runs. Left in place + // (not deleted) in case bgp.he.net fetching is re-enabled later, but + // overall_confidence below must not claim a 2-source agreement that never + // happened -- see the sources:1/2 handling further down. + // Prefix count cross-check: compare RIPE Stat vs bgp.he.net + if (bgpHeData) { + const heV4 = bgpHeData.prefixes_v4 || 0; + const heV6 = bgpHeData.prefixes_v6 || 0; + const heTotal = heV4 + heV6; + if (heTotal > 0) { + prefixCrossCheck.bgp_he_net = heTotal; + const ripeStat = prefixes.length; + if (ripeStat > 0 && heTotal > 0) { + const ratio = Math.min(ripeStat, heTotal) / Math.max(ripeStat, heTotal); + prefixCrossCheck.agreement = ratio >= 0.9; + const diff = Math.abs(ripeStat - heTotal); + prefixCrossCheck.note = diff === 0 + ? "Exact match" + : "Difference of " + diff + " prefixes (" + Math.round((1 - ratio) * 100) + "% divergence)"; + } + } else { + prefixCrossCheck.note = "bgp.he.net prefix count unavailable"; + } + + // Neighbour cross-check: compare RIPE Stat vs bgp.he.net peer_count + if (bgpHeData.peer_count != null) { + neighbourCrossCheck.bgp_he_net_total = bgpHeData.peer_count; + } + } else { + prefixCrossCheck.note = "bgp.he.net data unavailable"; + } + + // Compute overall data quality. Only push a score for a cross-check that + // actually ran (agreement_pct !== null) -- an unrun/inconclusive check must + // not silently count as a perfect 100 in the average, and confidence must + // reflect "nothing was actually cross-checked" rather than defaulting high. + const crossCheckScores = []; + if (rpkiCrossCheck.agreement_pct !== null) crossCheckScores.push(rpkiCrossCheck.agreement_pct); + // Prefix agreement: convert to percentage + if (prefixCrossCheck.bgp_he_net != null && prefixes.length > 0) { + const pfxRatio = Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net); + crossCheckScores.push(Math.round(pfxRatio * 100)); + } + // Neighbour agreement + if (neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0) { + const nbrRatio = Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total); + crossCheckScores.push(Math.round(nbrRatio * 100)); + } + const avgAgreement = crossCheckScores.length > 0 + ? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length) + : null; + const overallConfidence = avgAgreement === null ? "unknown" : avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low"; + + const dataQuality = { + sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"], + cross_checks: { + rpki: { sources: rpkiCrossCheck.agreement_pct !== null ? 2 : 1, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, comparable_size: rpkiCrossCheck.comparable_size, disagreements: rpkiCrossCheck.disagreements }, + prefixes: { sources: prefixCrossCheck.bgp_he_net != null ? 2 : 1, agreement_pct: prefixCrossCheck.bgp_he_net != null ? Math.round((Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net || 1)) * 100) : null, ripe_stat: prefixCrossCheck.ripe_stat, bgp_he_net: prefixCrossCheck.bgp_he_net, note: prefixCrossCheck.note }, + neighbours: { sources: neighbourCrossCheck.bgp_he_net_total != null ? 2 : 1, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total }, + }, + overall_confidence: overallConfidence, + overall_agreement_pct: avgAgreement, + }; + + return { rpkiCrossCheck, prefixCrossCheck, neighbourCrossCheck, dataQuality }; +} + +module.exports = { computeCrossChecks }; diff --git a/server/routes/lookup/enrich-neighbours.js b/server/routes/lookup/enrich-neighbours.js new file mode 100644 index 0000000..1d25066 --- /dev/null +++ b/server/routes/lookup/enrich-neighbours.js @@ -0,0 +1,73 @@ +const localDb = require("../../../local-db-client"); +const { fetchRipeStatCached } = require("../../services/ripe-stat"); + +// Resolve empty AS names — all in parallel, with 3s timeout. Mutates entries +// in place (matches original control flow). +async function resolveEmptyNames(neighbourLists) { + const emptyNameNeighbours = neighbourLists.flat().filter(n => !n.name); + if (emptyNameNeighbours.length === 0) return; + 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 the local threat_intel table. Batch lookups (cap at 50 +// to avoid overwhelming DB), 4s timeout. +async function buildThreatMap(neighbors) { + const threatMap = {}; + const toCheck = neighbors.slice(0, 50); + const threatPromises = toCheck.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, + cached_at: threat.cached_at, + }; + } + } catch (e) { + // Gracefully skip on error + console.error(`[Threat Lookup] Error checking ASN ${n.asn}:`, e.message); + } + }); + + await Promise.race([ + Promise.all(threatPromises), + new Promise(r => setTimeout(r, 4000)), + ]); + + return threatMap; +} + +function attachThreat(n, threatMap) { + return { + ...n, + threat_level: threatMap[n.asn]?.threat_level || null, + threat_confidence: threatMap[n.asn]?.confidence_score || null, + threat_source: threatMap[n.asn]?.source || null, + }; +} + +// Full enrichment pipeline: resolve empty names, then attach threat intel to +// upstreams/downstreams/peers. Returns new arrays (does not mutate the input +// arrays themselves, though resolveEmptyNames does mutate entry.name). +async function enrichNeighbours(upstreams, downstreams, peers) { + await resolveEmptyNames([upstreams, downstreams, peers]); + const threatMap = await buildThreatMap([...upstreams, ...downstreams, ...peers]); + return { + upstreams: upstreams.map(n => attachThreat(n, threatMap)), + downstreams: downstreams.map(n => attachThreat(n, threatMap)), + peers: peers.map(n => attachThreat(n, threatMap)), + }; +} + +module.exports = { enrichNeighbours }; diff --git a/server/routes/lookup/facilities.js b/server/routes/lookup/facilities.js new file mode 100644 index 0000000..c0a6082 --- /dev/null +++ b/server/routes/lookup/facilities.js @@ -0,0 +1,114 @@ +const { fetchPeeringDB } = require("../../services/peeringdb"); +const { CITY_COORDS } = require("../../data/city-coords"); +const { IX_CITY_MAP } = require("../../data/ix-city-map"); + +// Batch-fetch facility lat/lon by ID, 25 per request (PeeringDB /fac?id__in= limit), +// bounded by an overall race timeout. Returns {fac_id: {lat, lon}}. +async function fetchFacCoordsBatch(facIds, timeoutMs) { + const coordMap = {}; + if (facIds.length === 0) return coordMap; + 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([]), timeoutMs)), + ]); + (coordResults || []).forEach(res => { + (res?.data || []).forEach(f => { if (f.latitude && f.longitude) coordMap[f.id] = { lat: f.latitude, lon: f.longitude }; }); + }); + } catch (e) { /* graceful degradation */ } + return coordMap; +} + +// Resolve facility coordinates for the map, then IX locations via ixfac -> fac +// coordinates (max 20 IXs), then the name/CITY_COORDS/IX_CITY_MAP geocode +// fallbacks for IXs PeeringDB has no facility coordinates for. +async function resolveFacilitiesAndIxLocations(facilitiesRaw, ixConnections) { + // Batch-fetch facility coordinates for map (max 50 facilities) + const facIds = facilitiesRaw.map(f => f.fac_id).filter(Boolean).slice(0, 50); + let facCoordMap = await fetchFacCoordsBatch(facIds, 5000); + + 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 extraCoords = await fetchFacCoordsBatch(extraFacIds, 4000); + facCoordMap = { ...facCoordMap, ...extraCoords }; + } + // 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 */ } + } + + applyIxGeocodeFallback(ixLocations, ixConnections); + + return { facilities, ixLocations }; +} + +// === IX Location Geocode Fallback === +// Some IXPs have no facility coordinates in PeeringDB. +// Use ix_name city extraction + hard-coded IX→city map as fallback. +// Mutates ixLocations in place (matches original control flow). +function applyIxGeocodeFallback(ixLocations, ixConnections) { + 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 + 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" }); + } + }); +} + +module.exports = { resolveFacilitiesAndIxLocations }; diff --git a/server/routes/lookup/index.js b/server/routes/lookup/index.js new file mode 100644 index 0000000..25be038 --- /dev/null +++ b/server/routes/lookup/index.js @@ -0,0 +1,337 @@ +const localDb = require("../../../local-db-client"); +const { fetchJSON } = require("../../services/http-helpers"); +const { fetchPeeringDB, fetchPeeringDBWithRetry } = require("../../services/peeringdb"); +const { ensureAspaCache, validateRPKIWithCache } = require("../../services/rpki"); +const { computeResilienceScore } = require("../../resilience-score"); +const { computeRouteLeakDetection } = require("../../route-leak"); +const { pdbSourceCache } = require("../../caches/pdb-source-cache"); +const { + cacheGet, cacheSet, CACHE_TTL_LOOKUP, CACHE_TTL_LOOKUP_DEGRADED, + rdapCacheGet, rdapCacheSet, +} = require("../../caches/response-caches"); +const { resolveFacilitiesAndIxLocations } = require("./facilities"); +const { enrichNeighbours } = require("./enrich-neighbours"); +const { deriveRirAndCountry } = require("./rir-country"); +const { computeRoutingInfo } = require("./routing-info"); +const { computeCrossChecks } = require("./cross-check"); + +// Main lookup endpoint: /api/lookup?asn=X +async function handleLookup(req, res, url) { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); + } + const asn = rawAsn; + const cacheKey = "lookup:" + asn; + const cached = cacheGet(cacheKey); + if (cached) { + res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); + return res.end(JSON.stringify(cached)); + } + const start = Date.now(); + + try { + // Phase 0: Get PDB net first — check L2 cache, then API with retry + let pdbNet = pdbSourceCache.get("net", asn); + if (!pdbNet) { + pdbNet = await fetchPeeringDBWithRetry("/net?asn=" + asn); + if (pdbNet) pdbSourceCache.set("net", asn, pdbNet); + } + const net = pdbNet?.data?.[0] || {}; + const netId = net.id; + + // Phase 1: ALL calls in parallel — RIPE Stat (cached+throttled) + PDB IX/Fac (cached) + Atlas + bgp.he.net + const ixQuery = netId + ? "/netixlan?net_id=" + netId + "&limit=1000" + : "/netixlan?asn=" + asn + "&limit=1000"; + const ixCacheKey = netId ? String(netId) : "asn:" + asn; + + // Check PDB source cache for IX/Fac data + let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey); + let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null; + + // Per-source timing tracking — 9s hard cap per source to prevent long-tail blocking + const sourceTiming = {}; + function timedFetch(name, promise) { + const ts = Date.now(); + return Promise.race([ + Promise.resolve(promise), + new Promise(function(r) { setTimeout(function() { r(null); }, 9000); }), + ]) + .then(function(r) { sourceTiming[name] = Date.now() - ts; return r; }) + .catch(function() { sourceTiming[name] = null; return null; }); + } + + const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null; + + // RDAP: check module-level cache first, only hit RIR endpoints on cache miss + const rdapCached = rdapCacheGet(asn); + const rdapPromise = rdapCached !== undefined + ? Promise.resolve(rdapCached) + : Promise.race([ + ...["https://rdap.db.ripe.net/autnum/"+asn, "https://rdap.arin.net/registry/autnum/"+asn, + "https://rdap.apnic.net/autnum/"+asn, "https://rdap.lacnic.net/rdap/autnum/"+asn, + "https://rdap.afrinic.net/rdap/autnum/"+asn].map(url => + fetchJSON(url, { timeout: 4000 }) + .then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {})) + .catch(() => new Promise(() => {})) + ), + new Promise(resolve => setTimeout(() => resolve(null), 5000)), + ]).then(d => { rdapCacheSet(asn, d); return d; }); + + const promises = [ + timedFetch("RIPE Stat Prefixes", localDb ? localDb.getRipeStatAnnouncedPrefixes(asn) : Promise.resolve(null)), + timedFetch("RIPE Stat Neighbours", localDb ? localDb.getRipeStatAsnNeighbours(asn) : Promise.resolve(null)), + timedFetch("RIPE Stat Overview", localDb ? localDb.getRipeStatAsOverview(asn) : Promise.resolve(null)), + timedFetch("RIPE Stat RIR", Promise.resolve(null)), + timedFetch("RIPE Atlas", Promise.resolve(null)), + timedFetch("bgp.he.net", Promise.resolve(null)), + timedFetch("RIPE Stat Visibility", localDb ? localDb.getRipeStatVisibility(asn) : Promise.resolve(null)), + timedFetch("RIPE Stat PrefixSize", localDb ? localDb.getRipeStatPrefixSizeDistribution(asn) : Promise.resolve(null)), + timedFetch("PeeringDB IXLan", cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery)), + timedFetch("PeeringDB Facilities", cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))), + timedFetch("PeeringDB Contacts", pocQuery ? fetchPeeringDB(pocQuery).catch(() => null) : Promise.resolve(null)), + timedFetch("RDAP Registration", rdapPromise), + ]; + const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData, pocData, rdapData] = await Promise.all(promises); + + // Store PDB results in L2 source cache for future lookups + if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData); + if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData); + + // local-db-client's getRipeStat* functions return status:"error" (not "ok") when + // the local Postgres DB query itself failed -- track which sources actually + // failed so we don't cache a DB hiccup's fake-empty result as if it were a + // genuine "this ASN has 0 prefixes" answer for the full 5-minute TTL. + const degradedSources = [ + ["RIPE Stat Prefixes", prefixData], ["RIPE Stat Neighbours", neighbourData], + ["RIPE Stat Overview", overviewData], ["RIPE Stat Visibility", visibilityData], + ["RIPE Stat PrefixSize", prefixSizeData], + ].filter(([, v]) => v && v.status === "error").map(([name]) => name); + + const prefixes = prefixData?.data?.prefixes || []; + const neighbours = neighbourData?.data?.neighbours || []; + const overview = overviewData?.data || {}; + const rirEntries = rirData?.data?.located_resources || rirData?.data?.rir_stats || []; + + // Bug 6 fix: Atlas probe status uses status.name (object), not status_name (flat) + const atlasProbes = atlasProbeData?.results || []; + const atlasConnected = atlasProbes.filter(p => { + const sName = (p.status_name || (p.status && p.status.name) || "").toLowerCase(); + return sName === "connected"; + }); + const atlasAnchors = atlasProbes.filter(p => p.is_anchor === true); + + // RPKI: validate ALL prefixes using local Cloudflare RPKI data (all 5 RIRs, instant) + await ensureAspaCache(); + const allPrefixes = prefixes.map((p) => p.prefix); + const rpkiAllResults = await Promise.all(allPrefixes.map((pfx) => validateRPKIWithCache(asn, pfx))); + + const ixConnections = (ixlanData?.data || []) + .map((ix) => ({ + ix_name: ix.name || "", + ix_id: ix.ix_id, + speed_mbps: ix.speed || 0, + ipv4: ix.ipaddr4 || null, + ipv6: ix.ipaddr6 || null, + city: ix.city || "", + is_rs_peer: ix.is_rs_peer === true, + })) + .sort((a, b) => b.speed_mbps - a.speed_mbps); + + const facilitiesRaw = (facData?.data || []).map((f) => ({ + fac_id: f.fac_id, + name: f.name || "", + city: f.city || "", + country: f.country || "", + })); + + const { facilities, ixLocations } = await resolveFacilitiesAndIxLocations(facilitiesRaw, ixConnections); + + const rpkiStatuses = rpkiAllResults; + const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length; + const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length; + const rpkiUnavailable = rpkiStatuses.filter((r) => r.status === "unavailable").length; + const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid" && r.status !== "unavailable").length; + // Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup + // must not lower a network's displayed RPKI coverage percentage. + const rpkiTotal = rpkiStatuses.length - rpkiUnavailable; + const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0; + + let upstreams = neighbours + .filter((n) => n.type === "left") + .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 })) + .sort((a, b) => b.power - a.power); + let downstreams = neighbours + .filter((n) => n.type === "right") + .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 })) + .sort((a, b) => b.power - a.power); + let peers = neighbours + .filter((n) => n.type === "uncertain" || n.type === "peer") + .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 })) + .sort((a, b) => b.power - a.power); + + const enriched = await enrichNeighbours(upstreams, downstreams, peers); + upstreams = enriched.upstreams; + downstreams = enriched.downstreams; + peers = enriched.peers; + + const { rir, country } = deriveRirAndCountry(rirEntries, rirData, rdapData, bgpHeData); + + const duration = Date.now() - start; + + const routingInfo = await computeRoutingInfo(prefixes, visibilityData, prefixSizeData); + + const { rpkiCrossCheck, prefixCrossCheck, neighbourCrossCheck, dataQuality } = + await computeCrossChecks(asn, allPrefixes, rpkiStatuses, prefixes, neighbours, bgpHeData); + + const result = { + meta: { + service: "PeerCortex", + version: "0.6.9", + query: "AS" + asn, + duration_ms: duration, + sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"], + timestamp: new Date().toISOString(), + rpki_prefixes_checked: rpkiTotal, + total_prefixes: prefixes.length, + degraded_sources: degradedSources, + }, + network: { + asn: parseInt(asn), + name: net.name || overview?.holder || (bgpHeData && bgpHeData.name_from_title) || "Unknown", + aka: net.aka || "", + org_name: (net.org && net.org.name) ? net.org.name : "", + website: net.website || "", + type: net.info_type || "", + policy: net.policy_general || "", + traffic: net.info_traffic || "", + ratio: net.info_ratio || "", + scope: net.info_scope || "", + notes: net.notes ? net.notes.substring(0, 500) : "", + peeringdb_id: netId || null, + rir: rir, + country: country, + city: net.city || "", + latitude: (net.latitude != null) ? net.latitude : null, + longitude: (net.longitude != null) ? net.longitude : null, + looking_glass: net.looking_glass || "", + route_server: net.route_server || "", + info_prefixes4: net.info_prefixes4 || 0, + info_prefixes6: net.info_prefixes6 || 0, + status: net.status || "", + peeringdb_created: net.created ? net.created.slice(0, 10) : "", + peeringdb_updated: net.updated ? net.updated.slice(0, 10) : "", + }, + prefixes: { + total: prefixes.length, + ipv4: prefixes.filter((p) => !p.prefix.includes(":")).length, + ipv6: prefixes.filter((p) => p.prefix.includes(":")).length, + list: prefixes.map((p) => p.prefix), + cross_check: prefixCrossCheck, + }, + rpki: { + coverage_percent: rpkiCoverage, + valid: rpkiValid, + invalid: rpkiInvalid, + not_found: rpkiNotFound, + unavailable: rpkiUnavailable, + checked: rpkiTotal, + details: rpkiStatuses, + cross_check: rpkiCrossCheck, + }, + neighbours: { + total: neighbours.length, + upstream_count: upstreams.length, + downstream_count: downstreams.length, + peer_count: peers.length, + upstreams: upstreams.slice(0, 20), + downstreams: downstreams.slice(0, 20), + peers: peers.slice(0, 20), + cross_check: neighbourCrossCheck, + }, + ix_presence: { + total_connections: ixConnections.length, + unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length, + connections: ixConnections, + }, + ix_locations: ixLocations, + facilities: { + total: facilities.length, + list: facilities, + }, + routing: routingInfo, + resilience_score: computeResilienceScore(upstreams, peers, ixConnections, prefixes), + route_leak: computeRouteLeakDetection(upstreams, downstreams, peers), + bgp_he_net: bgpHeData || null, + atlas: { + total_probes: atlasProbes.length, + connected: atlasConnected.length, + disconnected: atlasProbes.length - atlasConnected.length, + anchors: atlasAnchors.length, + probes: atlasProbes.slice(0, 100).map(p => ({ + id: p.id, + status: p.status_name || p.status || "Unknown", + is_anchor: p.is_anchor || false, + country: p.country_code || "", + prefix_v4: p.prefix_v4 || "", + prefix_v6: p.prefix_v6 || "", + description: p.description || "", + })), + }, + data_quality: dataQuality, + source_timing: sourceTiming, + contacts: (() => { + const pocs = (pocData && pocData.data) ? pocData.data : []; + return pocs.slice(0, 20).map(p => ({ + role: p.role || "", + name: p.name || "", + email: p.email || "", + url: p.url || "", + visible: p.visible || "", + })); + })(), + registration: (() => { + const events = (rdapData && rdapData.events) ? rdapData.events : []; + const created = (events.find(e => e.eventAction === "registration") || {}).eventDate || ""; + const lastChg = (events.find(e => e.eventAction === "last changed") || {}).eventDate || ""; + return { + created: created ? created.slice(0, 10) : "", + last_modified: lastChg ? lastChg.slice(0, 10) : "", + rir: rir || "", + handle: (rdapData && rdapData.handle) ? rdapData.handle : ("AS" + asn), + rdap_source: (rdapData && rdapData.port43) ? rdapData.port43 : "", + }; + })(), + _provenance: { + prefixes: { source: "RIPE Stat announced-prefixes", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net prefix count daily" }, + neighbours: { source: "RIPE Stat asn-neighbours", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net peer count daily" }, + rpki: { source: "Cloudflare RPKI + RIPE Validator", validation: "cross-validated", confidence: "high", note: "Two independent RPKI sources compared" }, + ix_presence: { source: "PeeringDB netixlan (local mirror)", validation: "cross-validated", confidence: "high", note: "PeeringDB mirror refreshed daily" }, + facilities: { source: "PeeringDB netfac (local mirror)", validation: "single-source", confidence: "medium" }, + bgp_he_net: { source: "bgp.he.net HTML scrape", validation: "single-source", confidence: "medium", note: "HTML scrape, no official API — may have parsing drift" }, + atlas: { source: "RIPE Atlas API", validation: "single-source", confidence: "medium", note: "Probe availability varies by region" }, + resilience_score: { source: "Computed from RIPE Stat + PeeringDB", validation: "computed", confidence: "high", note: "All inputs cross-validated daily" }, + route_leak: { source: "RIPE Stat asn-neighbours heuristic", validation: "heuristic", confidence: "medium", note: "Pattern-based, not real-time — false positives possible" }, + registration: { source: "RDAP (RIR registry)", validation: "single-source", confidence: "high" }, + contacts: { source: "PeeringDB POC API", validation: "single-source", confidence: "medium", note: "Subject to PeeringDB rate limiting" }, + }, + }; + + // Update duration to include cross-check time + result.meta.duration_ms = Date.now() - start; + + // A DB hiccup shouldn't get cached as this ASN's answer for the full 5min -- + // retry soon instead of repeating a degraded result to every visitor. + cacheSet(cacheKey, result, degradedSources.length > 0 ? CACHE_TTL_LOOKUP_DEGRADED : CACHE_TTL_LOOKUP); + res.end(JSON.stringify(result, null, 2)); + } catch (err) { + const duration = Date.now() - start; + res.writeHead(500); + res.end(JSON.stringify({ error: "Lookup failed", message: err.message, duration_ms: duration })); + } + return; +} + +module.exports = { handleLookup }; diff --git a/server/routes/lookup/rir-country.js b/server/routes/lookup/rir-country.js new file mode 100644 index 0000000..fc56f57 --- /dev/null +++ b/server/routes/lookup/rir-country.js @@ -0,0 +1,51 @@ +const { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC } = require("../../data/rir-country-codes"); + +// Derive RIR and country through a cascade of fallback sources: RIPE Stat +// rir-stats-country, RDAP port43, RDAP self-link, bgp.he.net country_code, +// then a country-code-to-RIR table as a last resort. +function deriveRirAndCountry(rirEntries, rirData, rdapData, bgpHeData) { + 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) { + 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 + } + return { rir, country }; +} + +module.exports = { deriveRirAndCountry }; diff --git a/server/routes/lookup/routing-info.js b/server/routes/lookup/routing-info.js new file mode 100644 index 0000000..bf20c1c --- /dev/null +++ b/server/routes/lookup/routing-info.js @@ -0,0 +1,71 @@ +const { fetchBgproutesVisibility } = require("../../services/http-helpers"); + +// Compute routing visibility and prefix size distribution. +async function computeRoutingInfo(prefixes, visibilityData, prefixSizeData) { + 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, + }; +} + +module.exports = { computeRoutingInfo }; diff --git a/server/routes/pdf-export.js b/server/routes/pdf-export.js new file mode 100644 index 0000000..2f2c690 --- /dev/null +++ b/server/routes/pdf-export.js @@ -0,0 +1,112 @@ +const http = require("http"); +const { buildPdfHtml, buildComparisonPdfHtml } = require("../pdf-export/templates"); +const { renderHtmlToPdf, pdfCacheGet, pdfCacheSet } = require("../pdf-export/renderer"); + +const PORT = process.env.PORT || 3101; + +// Fetch JSON from an internal endpoint on this same process (loopback) -- +// re-triggers the full HTTP/caching/rate-limit path for /api/validate, +// /api/aspa, /api/lookup rather than calling their handler logic directly. +async function fetchInternal(path) { + return new Promise((resolve, reject) => { + const opts = { hostname: '127.0.0.1', port: PORT, path, method: 'GET' }; + const req2 = http.request(opts, res2 => { + let body = ''; + res2.on('data', c => body += c); + res2.on('end', () => { + try { resolve(JSON.parse(body)); } + catch(e) { reject(new Error('JSON parse failed: ' + body.slice(0, 200))); } + }); + }); + req2.on('error', reject); + req2.setTimeout(30000, () => { req2.destroy(); reject(new Error('Internal request timeout')); }); + req2.end(); + }); +} + +// GET /api/export/pdf?asn=X&format=report|executive|technical +async function handleExportPdf(req, res, url) { + const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, ''); + const format = ['executive', 'technical', 'report'].includes(url.searchParams.get('format')) + ? url.searchParams.get('format') : 'report'; + if (!rawAsn) { + res.writeHead(400, {'Content-Type':'application/json'}); + return res.end(JSON.stringify({ error: 'Missing asn parameter' })); + } + const cacheKey = `pdf:${rawAsn}:${format}`; + const cached = pdfCacheGet(cacheKey); + if (cached) { + res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length }); + return res.end(cached); + } + try { + const [validateData, aspaData] = await Promise.all([ + fetchInternal(`/api/validate?asn=${rawAsn}`), + fetchInternal(`/api/aspa?asn=${rawAsn}`).catch(() => null), + ]); + if (validateData.error) { + res.writeHead(400, {'Content-Type':'application/json'}); + return res.end(JSON.stringify({ error: validateData.error })); + } + const html = buildPdfHtml(validateData, aspaData, format); + const pdfBuf = await renderHtmlToPdf(html); + pdfCacheSet(cacheKey, pdfBuf); + res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'Content-Length': pdfBuf.length }); + return res.end(pdfBuf); + } catch (err) { + console.error('[PDF] Error:', err.message); + res.writeHead(503, {'Content-Type':'application/json'}); + return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message })); + } +} + +// GET /api/export/pdf/compare?asn1=X&asn2=Y +async function handleExportPdfCompare(req, res, url) { + const asn1 = (url.searchParams.get('asn1') || '').replace(/[^0-9]/g, ''); + const asn2 = (url.searchParams.get('asn2') || '').replace(/[^0-9]/g, ''); + if (!asn1 || !asn2) { + res.writeHead(400, {'Content-Type':'application/json'}); + return res.end(JSON.stringify({ error: 'Missing asn1 or asn2 parameter' })); + } + if (asn1 === asn2) { + res.writeHead(400, {'Content-Type':'application/json'}); + return res.end(JSON.stringify({ error: 'asn1 and asn2 must be different' })); + } + const cacheKey = `pdf:compare:${Math.min(+asn1,+asn2)}:${Math.max(+asn1,+asn2)}`; + const cached = pdfCacheGet(cacheKey); + if (cached) { + res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length }); + return res.end(cached); + } + try { + const [v1, a1, l1, v2, a2, l2] = await Promise.all([ + fetchInternal(`/api/validate?asn=${asn1}`), + fetchInternal(`/api/aspa?asn=${asn1}`).catch(() => null), + fetchInternal(`/api/lookup?asn=${asn1}`).catch(() => null), + fetchInternal(`/api/validate?asn=${asn2}`), + fetchInternal(`/api/aspa?asn=${asn2}`).catch(() => null), + fetchInternal(`/api/lookup?asn=${asn2}`).catch(() => null), + ]); + if (v1.error || v2.error) { + res.writeHead(400, {'Content-Type':'application/json'}); + return res.end(JSON.stringify({ error: v1.error || v2.error })); + } + const html = buildComparisonPdfHtml(v1, a1, l1, v2, a2, l2); + const pdfBuf = await renderHtmlToPdf(html); + pdfCacheSet(cacheKey, pdfBuf); + res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'Content-Length': pdfBuf.length }); + return res.end(pdfBuf); + } catch (err) { + console.error('[PDF] Error:', err.message); + res.writeHead(503, {'Content-Type':'application/json'}); + return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message })); + } +} + +// CORS preflight for PDF export (already handled globally above, belt-and-suspenders) +function handleExportOptions(req, res) { + res.writeHead(204); + return res.end(); +} + +module.exports = { handleExportPdf, handleExportPdfCompare, handleExportOptions }; diff --git a/server/routes/peers-find.js b/server/routes/peers-find.js new file mode 100644 index 0000000..d9a6bef --- /dev/null +++ b/server/routes/peers-find.js @@ -0,0 +1,106 @@ +const { fetchPeeringDB } = require("../services/peeringdb"); + +// Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000 +async function handlePeersFind(req, res, url) { + 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 })); + } + +} + +module.exports = { handlePeersFind }; diff --git a/server/routes/prefix-detail.js b/server/routes/prefix-detail.js new file mode 100644 index 0000000..74d1011 --- /dev/null +++ b/server/routes/prefix-detail.js @@ -0,0 +1,77 @@ +const { fetchRipeStatCached } = require("../services/ripe-stat"); +const { fetchBgproutesVisibility } = require("../services/http-helpers"); +const { validateRPKIWithCache } = require("../services/rpki"); + +const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || ""; + +// Prefix Detail endpoint: /api/prefix/detail?prefix=X +async function handlePrefixDetail(req, res, url) { + 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 })); + } + +} + +module.exports = { handlePrefixDetail }; diff --git a/server/routes/proxy-stubs.js b/server/routes/proxy-stubs.js new file mode 100644 index 0000000..98bb879 --- /dev/null +++ b/server/routes/proxy-stubs.js @@ -0,0 +1,30 @@ +const { proxyToApiServer } = require("../services/api-proxy"); + +// Routes already migrated to the separate Fastify API server (src/api/ + +// src/features/*/routes.ts) -- forwarded through rather than reimplemented +// inline a second time. All mechanically identical (proxyToApiServer(req, +// res, req.url)); handled as one data-driven dispatch instead of 12 +// near-duplicate one-line route handlers. +const EXACT_PATHS = new Set([ + "/api/submarine-cables", + "/api/global-infra", + "/api/communities", + "/api/irr-audit", + "/api/asset-expand", + "/api/rpki-history", + "/api/aspath", + "/api/looking-glass", + "/api/ix-matrix", + "/changelog-data", + "/api/prefix-changes", +]); + +function isProxyStubPath(reqPath) { + return EXACT_PATHS.has(reqPath) || reqPath.startsWith("/api/rib/"); +} + +function handleProxyStub(req, res) { + return proxyToApiServer(req, res, req.url); +} + +module.exports = { isProxyStubPath, handleProxyStub }; diff --git a/server/routes/quick-ix.js b/server/routes/quick-ix.js new file mode 100644 index 0000000..5647a0a --- /dev/null +++ b/server/routes/quick-ix.js @@ -0,0 +1,48 @@ +const { fetchJSON } = require("../services/http-helpers"); +const { fetchRipeStatCached } = require("../services/ripe-stat"); +const { quickIxCacheGet, quickIxCacheSet } = require("../caches/response-caches"); + +// 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 +async function handleQuickIx(req, res, url) { + 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 })); + } + +} + +module.exports = { handleQuickIx }; diff --git a/server/routes/relationships.js b/server/routes/relationships.js new file mode 100644 index 0000000..869e025 --- /dev/null +++ b/server/routes/relationships.js @@ -0,0 +1,110 @@ +const localDb = require("../../local-db-client"); +const { fetchRipeStatCached } = require("../services/ripe-stat"); +const { cacheGet, cacheSet } = require("../caches/response-caches"); + +// AS Relationships endpoint: /api/relationships?asn=X +// Returns upstream providers, downstream customers, and peers with resolved +// names. Based on RIPE Stat asn-neighbours. +async function handleRelationships(req, res, url) { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); + } + const cacheKey = "relationships:" + rawAsn; + const cached = cacheGet(cacheKey); + if (cached) { + res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); + return res.end(JSON.stringify(cached)); + } + const start = Date.now(); + try { + const neighbourData = await fetchRipeStatCached( + "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn + "&lod=1", + { timeout: 8000 } + ); + const neighbours = (neighbourData && neighbourData.data && neighbourData.data.neighbours) || []; + const counts = (neighbourData && neighbourData.data && neighbourData.data.neighbour_counts) || {}; + + const upstreams = neighbours.filter(n => n.type === "left").sort((a,b) => (b.power||0)-(a.power||0)); + const downstreams = neighbours.filter(n => n.type === "right").sort((a,b) => (b.power||0)-(a.power||0)); + const peers = neighbours.filter(n => n.type === "uncertain").sort((a,b) => (b.power||0)-(a.power||0)); + + // Resolve AS names for top entries (upstreams + downstreams all, top 20 peers) + const toResolve = [...upstreams, ...downstreams, ...peers.slice(0, 20)]; + const resolvedNames = {}; + await Promise.race([ + Promise.all(toResolve.map(n => + fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 }) + .then(r => { if (r && r.data && r.data.holder) resolvedNames[n.asn] = r.data.holder; }) + .catch(() => {}) + )), + new Promise(r => setTimeout(r, 5000)), + ]); + + // ---- Threat Intelligence Enrichment ---- + // Enrich neighbors with threat status from local threat_intel table + const threatMap = {}; + const allNeighborsForThreat = [...upstreams, ...downstreams, ...peers]; + const threatPromises = allNeighborsForThreat.slice(0, 100).map(async (n) => { + try { + const asNum = String(n.asn); + const threat = await localDb.getThreatIntel(asNum); + if (threat) { + threatMap[n.asn] = { + threat_level: threat.threat_level, + confidence_score: threat.confidence_score, + source: threat.source, + }; + } + } catch (e) { + console.error(`[Relationships Threat Lookup] Error checking ASN ${n.asn}:`, e.message); + } + }); + await Promise.race([ + Promise.all(threatPromises), + new Promise(r => setTimeout(r, 3000)), + ]); + + const fmt = n => ({ + asn: n.asn, + name: resolvedNames[n.asn] || "", + power: n.power || 0, + v4_peers: n.v4_peers || 0, + v6_peers: n.v6_peers || 0, + threat_level: threatMap[n.asn]?.threat_level || null, + threat_confidence: threatMap[n.asn]?.confidence_score || null, + threat_source: threatMap[n.asn]?.source || null, + }); + + const result = { + asn: parseInt(rawAsn), + query_time: new Date().toISOString(), + duration_ms: Date.now() - start, + counts: { + upstreams: counts.left || upstreams.length, + downstreams: counts.right || downstreams.length, + peers_total: counts.unique || peers.length, + uncertain: counts.uncertain || peers.length, + }, + upstreams: upstreams.map(fmt), + downstreams: downstreams.map(fmt), + peers: peers.slice(0, 50).map(fmt), + methodology: "RIPE Stat asn-neighbours API. left=upstream providers (carry your traffic), right=downstream customers (you carry their traffic), uncertain=lateral peers. Sorted by power score (number of prefixes seen via this relationship).", + source_url: "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, + }; + + // A neighbourData fetch failure (not a genuine zero-neighbour ASN) would + // otherwise get cached as "0 upstreams/downstreams/peers" for the full 10min -- + // match the existing /api/validate precedent (90s TTL for suspicious zero counts). + cacheSet(cacheKey, result, neighbourData ? 10 * 60 * 1000 : 90 * 1000); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(result, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "Relationships lookup failed", message: err.message })); + } + +} + +module.exports = { handleRelationships }; diff --git a/server/routes/search.js b/server/routes/search.js new file mode 100644 index 0000000..4372ead --- /dev/null +++ b/server/routes/search.js @@ -0,0 +1,83 @@ +const { fetchJSONWithRetry } = require("../services/http-helpers"); +const { PEERINGDB_API_KEY } = require("../services/peeringdb"); +const { trackVisitor } = require("../visitors"); + +// Name Search (RIPE Stat + PeeringDB combined) +async function handleSearch(req, res) { + 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 +function handleVisitors(req, res) { + 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 })); +} + +module.exports = { handleSearch, handleVisitors }; diff --git a/server/routes/static.js b/server/routes/static.js new file mode 100644 index 0000000..7364a5e --- /dev/null +++ b/server/routes/static.js @@ -0,0 +1,61 @@ +const fs = require("fs"); +const path = require("path"); + +const REPO_ROOT = path.join(__dirname, "..", ".."); + +function handleRoot(req, res, host, reqPath) { + // 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"); + } +} + +function handleIndexEditorial(req, res) { + 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"); + } +} + +function handleFavicon(req, res) { + res.writeHead(204); + return res.end(); +} + +function handleLia(req, res) { + try { + const liaHtml = fs.readFileSync(path.join(REPO_ROOT, "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"); + } +} + +module.exports = { handleRoot, handleIndexEditorial, handleFavicon, handleLia }; diff --git a/server/routes/topology.js b/server/routes/topology.js new file mode 100644 index 0000000..7f661a8 --- /dev/null +++ b/server/routes/topology.js @@ -0,0 +1,59 @@ +const localDb = require("../../local-db-client"); +const { fetchTopology } = require("../topology"); + +// Feature 25: Topology endpoint +async function handleTopology(req, res, url) { + 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 })); + } + +} + +module.exports = { handleTopology }; diff --git a/server/routes/validate/bogon.js b/server/routes/validate/bogon.js new file mode 100644 index 0000000..0c25cb9 --- /dev/null +++ b/server/routes/validate/bogon.js @@ -0,0 +1,62 @@ +// 11. Bogon Detection (local check, no external API calls) +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; +} + +// "pass" must mean prefixes/neighbours were actually fetched and checked clean -- +// not that the upstream source failed and left nothing to check. A source is +// unavailable if it's falsy (fetchRipeStatCached network failure) OR if it's a +// truthy object with status:"error" (local-db-client.js's DB-error signal -- +// see the 2026-07-16 audit; that object is NOT null, so a plain `!x` check +// alone misses this case and would show "pass" on 0 checked prefixes). +function isSourceUnavailable(x) { return !x || x.status === "error"; } + +function computeBogonResult(allPrefixes, neighbours, prefixData, neighbourData) { + 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 prefixesUnavailable = isSourceUnavailable(prefixData); + var neighboursUnavailable = isSourceUnavailable(neighbourData); + var bogonDataUnavailable = prefixesUnavailable && neighboursUnavailable; + return { + status: bogonDataUnavailable ? "info" : (bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail"), + bogon_prefixes: bogonPrefixes, + bogon_asns_in_paths: bogonAsns, + total_prefixes_checked: allPrefixes.length, + prefixes_source_unavailable: prefixesUnavailable, + neighbours_source_unavailable: neighboursUnavailable, + }; +} + +module.exports = { computeBogonResult }; diff --git a/server/routes/validate/checks.js b/server/routes/validate/checks.js new file mode 100644 index 0000000..7f9aaf3 --- /dev/null +++ b/server/routes/validate/checks.js @@ -0,0 +1,337 @@ +const { fetchJSON, fetchBgproutesVisibility } = require("../../services/http-helpers"); +const { fetchRipeStatCached } = require("../../services/ripe-stat"); +const { fetchPeeringDB } = require("../../services/peeringdb"); +const { validateRPKIWithCache } = require("../../services/rpki"); +const { ensureManrsCache, checkManrsMembership } = require("../../services/manrs"); + +// 12. IRR Validation +// fetchJSON resolves(null) on timeout/network-error/parse-failure rather than +// rejecting, so the .catch() below never sees the common failure mode -- a +// dead upstream would otherwise silently read as "0 entries, no mismatches, pass". +function checkIRR(rawAsn) { + return fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) { + if (irrData === null) { + return { status: "info", message: "IRR Explorer unavailable (fetch failed)", total_entries: 0, mismatches: [], mismatch_count: 0 }; + } + var entries = Array.isArray(irrData) ? irrData : []; + var mismatches = entries.filter(function(e) { + if (!e.bgpOrigins && !e.bgp_origins) return false; + if (!e.irrRoutes && !e.irr_origins) return false; + var bgpArr = e.bgpOrigins || e.bgp_origins || []; + var irrArr = e.irrRoutes || e.irr_origins || []; + var bgpSet = {}; + bgpArr.forEach(function(a) { bgpSet[String(typeof a === "object" ? a.asn : a)] = true; }); + var match = false; + irrArr.forEach(function(a) { if (bgpSet[String(typeof a === "object" ? a.asn : a)]) match = true; }); + return Object.keys(bgpSet).length > 0 && irrArr.length > 0 && !match; + }); + return { + status: mismatches.length === 0 ? "pass" : "warning", + total_entries: entries.length, + mismatches: mismatches.slice(0, 10).map(function(e) { return { prefix: e.prefix, bgp_origins: e.bgpOrigins || e.bgp_origins, irr_origins: e.irrRoutes || e.irr_origins }; }), + mismatch_count: mismatches.length, + }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 13. RPKI ROA Completeness (local validation against Cloudflare RPKI feed - all RIRs) +// Caller must await ensureAspaCache() before calling this. +function checkRpkiCompleteness(rawAsn, allPrefixes) { + return Promise.all( + allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); }) + ).then(function(rpkiResults) { + var checkedResults = rpkiResults.filter(function(r) { return r.status !== "unavailable"; }); + var unavailableCount = rpkiResults.length - checkedResults.length; + var withRoa = checkedResults.filter(function(r) { return r.status === "valid"; }); + var coverage = checkedResults.length > 0 ? Math.round((withRoa.length / checkedResults.length) * 100) : 0; + var overSpecific = checkedResults.filter(function(r) { + var mask = parseInt((r.prefix || "").split("/")[1] || "0"); + return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid"; + }); + // If the local RPKI DB was unreachable for every prefix, we haven't actually + // checked anything -- say so instead of reporting a misleading pass/fail. + var allUnavailable = rpkiResults.length > 0 && checkedResults.length === 0; + return { + status: allUnavailable ? "info" : coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail", + coverage_pct: coverage, + total_checked: checkedResults.length, + db_unavailable_count: unavailableCount, + with_roa: withRoa.length, + over_specific: overSpecific.map(function(r) { return r.prefix; }), + details: rpkiResults, + }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 14. Abuse Contact Validation +function checkAbuseContact(rawAsn) { + return fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) { + if (data === null) return { status: "info", message: "abuse-contact-finder unavailable (fetch failed)", contacts: [], has_valid_email: false }; + var contacts = data && data.data && data.data.abuse_contacts ? data.data.abuse_contacts : []; + var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); }); + return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 15. Spamhaus DROP/Blocklist +function checkBlocklist(samplePrefixes) { + return Promise.all( + samplePrefixes.slice(0, 5).map(function(pfx) { + return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) { + // fetchRipeStatCached resolves(null) on timeout/network-error rather than + // throwing, so the .catch() below never sees that case -- distinguish it + // explicitly instead of letting a failed lookup read as "not listed". + if (data === null) return { prefix: pfx, listed: false, checked: false }; + var sources = data && data.data && data.data.sources ? data.data.sources : []; + var listed = sources.filter(function(s) { return s.prefix_count > 0 || (s.entries && s.entries.length > 0); }); + return { prefix: pfx, listed: listed.length > 0, checked: true, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) }; + }).catch(function() { return { prefix: pfx, listed: false, checked: false }; }); + }) + ).then(function(results) { + var checkedResults = results.filter(function(r) { return r.checked; }); + var listedPrefixes = checkedResults.filter(function(r) { return r.listed; }); + var allUnchecked = results.length > 0 && checkedResults.length === 0; + return { + status: allUnchecked ? "info" : (listedPrefixes.length === 0 ? "pass" : "fail"), + checked: checkedResults.length, + unavailable: results.length - checkedResults.length, + listed_prefixes: listedPrefixes, + }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 16. MANRS Compliance — scraped from public participants list (24h cache) +function checkManrs(rawAsn) { + return 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) +function checkRdns(samplePrefixes) { + var rdnsSampleSize = Math.min(3, samplePrefixes.length); + return Promise.all( + samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) { + return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) { + if (data === null) return { prefix: pfx, has_rdns: false, details: [], error: true }; + var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {}; + var hasDelegation = false; + var details = []; + // API returns { ipv4: { "prefix": { complete, domains } }, ipv6: { ... } } + ["ipv4", "ipv6"].forEach(function(af) { + var afData = pfxData[af] || {}; + Object.keys(afData).forEach(function(p) { + var entry = afData[p]; + if (entry && entry.complete) hasDelegation = true; + if (entry && entry.domains) { + entry.domains.forEach(function(d) { + if (d.found) hasDelegation = true; + details.push({ domain: d.domain, found: !!d.found }); + }); + } + }); + }); + // Fallback: old array format + if (Array.isArray(pfxData)) { + pfxData.forEach(function(p) { + if (p.ipv4 || p.ipv6 || (p.delegations && p.delegations.length > 0)) hasDelegation = true; + }); + } + return { prefix: pfx, has_rdns: hasDelegation, details: details }; + }).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; }); + }) + ).then(function(results) { + var checkedResults = results.filter(function(r) { return !r.error; }); + var withRdns = checkedResults.filter(function(r) { return r.has_rdns; }); + var coverage = checkedResults.length > 0 ? Math.round((withRdns.length / checkedResults.length) * 100) : 0; + var failedPrefixes = checkedResults.filter(function(r) { return !r.has_rdns; }).map(function(r) { return r.prefix; }); + var allUnavailable = results.length > 0 && checkedResults.length === 0; + return { + status: allUnavailable ? "info" : (coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail"), + coverage_pct: coverage, + checked: checkedResults.length, + unavailable: results.length - checkedResults.length, + results: results, + failed_prefixes: failedPrefixes, + }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 18. BGP Visibility (uses routing-status API which is more reliable than visibility API) +function checkVisibility(rawAsn, samplePrefixes) { + return fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 8000 }).then(function(rsData) { + var vis = rsData && rsData.data && rsData.data.visibility ? rsData.data.visibility : {}; + var v4 = vis.v4 || {}; + var v6 = vis.v6 || {}; + var totalPeers = (v4.total_ris_peers || 0) + (v6.total_ris_peers || 0); + var seeingPeers = (v4.ris_peers_seeing || 0) + (v6.ris_peers_seeing || 0); + var score = totalPeers > 0 ? Math.round((seeingPeers / totalPeers) * 100) : 0; + var observedNeighbours = rsData && rsData.data ? (rsData.data.observed_neighbours || 0) : 0; + // If routing-status returned no data, try bgproutes.io + if (totalPeers === 0 && samplePrefixes[0]) { + return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) { + if (bgprFb && bgprFb.vps_seeing > 0) { + seeingPeers = bgprFb.vps_seeing; + totalPeers = Math.max(bgprFb.vps_seeing, 300); + score = Math.round((seeingPeers / totalPeers) * 100); + } + return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "bgproutes.io_fallback" }; + }).catch(function() { + // Neither RIPE routing-status nor the bgproutes.io fallback returned data -- + // this network hasn't actually been measured, not confirmed at 0% visibility. + return { status: "info", message: "visibility could not be measured (both data sources unavailable)", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" }; + }); + } + return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "ripe_routing_status" }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 19. BGP Communities Analysis +function checkCommunities(samplePrefixes) { + return (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 +function checkGeolocation(samplePrefixes) { + return (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) +function checkRpsl(rawAsn) { + // 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) +function checkIxRouteServer(rawAsn) { + var ixRsQueryUrl = "/netixlan?asn=" + rawAsn; + return 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) +function checkResourceCert(rawAsn, allPrefixes) { + return Promise.all( + allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); }) + ).then(function(results) { + var checked = results.filter(function(r) { return r.status !== "unavailable"; }); + var hasRoa = checked.some(function(r) { return r.status === "valid" || r.validating_roas > 0; }); + var allUnavailable = results.length > 0 && checked.length === 0; + return { status: allUnavailable ? "info" : hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: checked.length, roa_count: checked.filter(function(r) { return r.status === "valid"; }).length }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// Geolocation cross-ref with PeeringDB facilities +function fetchFacCountries(netId) { + return 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([]); +} + +module.exports = { + checkIRR, + checkRpkiCompleteness, + checkAbuseContact, + checkBlocklist, + checkManrs, + checkRdns, + checkVisibility, + checkCommunities, + checkGeolocation, + checkRpsl, + checkIxRouteServer, + checkResourceCert, + fetchFacCountries, +}; diff --git a/server/routes/validate/index.js b/server/routes/validate/index.js new file mode 100644 index 0000000..c56039d --- /dev/null +++ b/server/routes/validate/index.js @@ -0,0 +1,208 @@ +const { fetchRipeStatCached } = require("../../services/ripe-stat"); +const { fetchPeeringDB } = require("../../services/peeringdb"); +const { ensureAspaCache } = require("../../services/rpki"); +const { validateResultCache, resultCacheGet, resultCacheSet, RESULT_CACHE_TTL } = require("../../caches/response-caches"); +const { computeBogonResult } = require("./bogon"); +const checks = require("./checks"); + +// Calculate overall health score (0-100) +const SCORE_WEIGHTS = [ + { 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 }, +]; + +function scoreChecks(validations) { + var totalWeight = 0; + var earnedScore = 0; + var checkResults = []; + + SCORE_WEIGHTS.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; + return { healthScore: healthScore, checkResults: checkResults }; +} + +// Enrich geolocation (Bug 4 fix: handle anycast/CDN/global networks) +function enrichGeolocation(validations, facCountries, net) { + if (!validations.geolocation || validations.geolocation.status === "error") return; + 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"; + } +} + +// Build relationships from neighbour data +function buildRelationships(neighbourData) { + var relNeighbours = neighbourData && neighbourData.data && neighbourData.data.neighbour_counts + ? neighbourData.data.neighbour_counts : {}; + var relList = neighbourData && neighbourData.data && neighbourData.data.neighbours + ? neighbourData.data.neighbours : []; + var relUpstreams = relList.filter(function(n) { return n.type === "left"; }) + .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) + .slice(0, 20) + .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); + var relDownstreams = relList.filter(function(n) { return n.type === "right"; }) + .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) + .slice(0, 20) + .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); + var relPeers = relList.filter(function(n) { return n.type === "uncertain"; }) + .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) + .slice(0, 30) + .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); + + return { + 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.", + }; +} + +// Unified Validation endpoint: /api/validate?asn=X +// Runs ALL validations in parallel, returns comprehensive report +async function handleValidate(req, res, url) { + 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 : []; + + const bogonResult = computeBogonResult(allPrefixes, neighbours, prefixData, neighbourData); + + // Phase 2: All API-dependent validations in parallel + await ensureAspaCache(); // Ensure ROA data is loaded before RPKI-dependent checks + var validationPromises = { + irr: checks.checkIRR(rawAsn), + rpki_completeness: checks.checkRpkiCompleteness(rawAsn, allPrefixes), + abuse_contact: checks.checkAbuseContact(rawAsn), + blocklist: checks.checkBlocklist(samplePrefixes), + manrs: checks.checkManrs(rawAsn), + rdns: checks.checkRdns(samplePrefixes), + visibility: checks.checkVisibility(rawAsn, samplePrefixes), + communities: checks.checkCommunities(samplePrefixes), + geolocation: checks.checkGeolocation(samplePrefixes), + rpsl: checks.checkRpsl(rawAsn), + ix_route_server: checks.checkIxRouteServer(rawAsn), + resource_cert: checks.checkResourceCert(rawAsn, allPrefixes), + }; + + const facCountriesPromise = checks.fetchFacCountries(netId); + + // 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" }; + } + }); + + enrichGeolocation(validations, facCountries, net); + validations.bogon = bogonResult; + + const { healthScore, checkResults } = scoreChecks(validations); + const duration = Date.now() - start; + const relationships = buildRelationships(neighbourData); + + 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: relationships, + }; + // Cache 0-prefix results only briefly (90s) — they may be due to temporary API failures + // Full results with prefixes are cached for the standard 15 minutes + const validateCacheTTL = allPrefixes.length === 0 ? 90 * 1000 : RESULT_CACHE_TTL; + resultCacheSet(validateResultCache, rawAsn, validateResult, validateCacheTTL); + return res.end(JSON.stringify(validateResult, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "Validation failed", message: err.message })); + } +} + +module.exports = { handleValidate }; diff --git a/server/routes/whois.js b/server/routes/whois.js new file mode 100644 index 0000000..43ad972 --- /dev/null +++ b/server/routes/whois.js @@ -0,0 +1,26 @@ +const { fetchWhois } = require("../whois"); + +// Feature 27: WHOIS endpoint +async function handleWhoisRoute(req, res, url) { + 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 })); + } + +} + +module.exports = { handleWhoisRoute }; diff --git a/server/services/api-proxy.js b/server/services/api-proxy.js new file mode 100644 index 0000000..499a356 --- /dev/null +++ b/server/services/api-proxy.js @@ -0,0 +1,34 @@ +const http = require("http"); + +// Several routes were extracted into src/api/ + src/features/*/routes.ts +// (Fastify, dist/api/index.js, run as a separate PM2 process on +// API_SERVER_PORT) but were never re-wired here after that migration — +// they just silently 404'd. This forwards those specific paths through +// rather than reimplementing them inline a second time. +const API_SERVER_PORT = parseInt(process.env.API_SERVER_PORT || "3102", 10); + +function proxyToApiServer(req, res, targetUrl) { + const proxyReq = http.request( + { + hostname: "127.0.0.1", + port: API_SERVER_PORT, + path: targetUrl, + method: req.method, + headers: Object.assign({}, req.headers, { host: "127.0.0.1:" + API_SERVER_PORT }), + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + } + ); + proxyReq.on("error", (err) => { + console.error("[API Proxy] Error forwarding " + targetUrl + ":", err.message); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "API server unavailable", detail: err.message })); + } + }); + req.pipe(proxyReq); +} + +module.exports = { proxyToApiServer, API_SERVER_PORT }; diff --git a/server/services/http-helpers.js b/server/services/http-helpers.js new file mode 100644 index 0000000..a4e6809 --- /dev/null +++ b/server/services/http-helpers.js @@ -0,0 +1,131 @@ +const https = require("https"); +const { UA } = require("../data/constants"); + +const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || ""; +const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1"; + +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); }); + }); +} + +// 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); +} + +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(); + }); +} + +// 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; }); +} + +module.exports = { fetchJSON, fetchJSONWithRetry, fetchHTML, postJSON, fetchBgproutesVisibility }; diff --git a/server/services/manrs.js b/server/services/manrs.js new file mode 100644 index 0000000..6276dd1 --- /dev/null +++ b/server/services/manrs.js @@ -0,0 +1,64 @@ +const https = require("https"); +const { UA } = require("../data/constants"); + +// 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; + for (const m of html.matchAll(re)) { + 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/", + }; +} + +module.exports = { ensureManrsCache, checkManrsMembership }; diff --git a/server/services/peeringdb.js b/server/services/peeringdb.js new file mode 100644 index 0000000..0797731 --- /dev/null +++ b/server/services/peeringdb.js @@ -0,0 +1,216 @@ +const fs = require("fs"); +const { UA } = require("../data/constants"); +const { fetchJSON } = require("./http-helpers"); +const { Semaphore } = require("./ripe-stat"); + +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; + } +} + +// 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; +} + +module.exports = { + PEERINGDB_API_KEY, + PEERINGDB_API_URL, + getPdbLocal, + queryPeeringDBLocal, + pdbSemaphore, + fetchPeeringDB, + fetchPeeringDBWithRetry, +}; diff --git a/server/services/ripe-stat.js b/server/services/ripe-stat.js new file mode 100644 index 0000000..00a3480 --- /dev/null +++ b/server/services/ripe-stat.js @@ -0,0 +1,184 @@ +const fs = require("fs"); +const { fetchJSON } = require("./http-helpers"); +const localDb = require("../../local-db-client"); + +// 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 +// Renamed from fetchRipeStatCached 2026-07-16: a second function with that exact +// name was added ~150 lines down (the local-DB-routing wrapper) during the Postgres +// refactor. In JS, two `function` declarations sharing a name silently collapse to +// the LAST one in source order -- the real RIPE Stat fetch+cache+throttle +// implementation below was completely shadowed, and every call to +// fetchRipeStatCached() for any endpoint NOT in {announced-prefixes, asn-neighbours, +// as-overview, visibility, prefix-size-distribution} was unconditionally returning +// null instead of ever reaching this code. That silently broke blocklist, +// abuse-contact-finder, bgp-updates, routing-status, looking-glass, whois, +// reverse-dns-consistency, maxmind-geo-lite-pfx, and ixs lookups -- see the router's +// fallback call to this function for the fix. +async function fetchRipeStatCachedFromApi(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; + } +} + +// 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 every other RIPE Stat endpoint (not mirrored into the local Postgres DB) -- + // e.g. blocklist, abuse-contact-finder, bgp-updates, routing-status, looking-glass, + // whois, reverse-dns-consistency, maxmind-geo-lite-pfx, ixs -- fall through to the + // real RIPE Stat API with its own cache/throttle/never-cache-null protections, + // instead of returning null unconditionally (see fetchRipeStatCachedFromApi's + // header comment for how this silently broke ~9 checks until 2026-07-16). + return fetchRipeStatCachedFromApi(url, options); + } catch (e) { + console.error("[fetchRipeStatCached] Error:", e.message); + return Promise.resolve(null); + } +} + +module.exports = { + ripeStatCache, + fetchRipeStatCached, + fetchRipeStatCachedFromApi, + fetchRipeStatCachedWithRetry, + saveRipeStatCacheToDisk, + loadRipeStatCacheFromDisk, + Semaphore, +}; diff --git a/server/services/rpki.js b/server/services/rpki.js new file mode 100644 index 0000000..69fd6e5 --- /dev/null +++ b/server/services/rpki.js @@ -0,0 +1,200 @@ +const https = require("https"); +const localDb = require("../../local-db-client"); +const { UA } = require("../data/constants"); +const { fetchJSON } = require("./http-helpers"); +const { roaStore } = require("../caches/roa-store"); +const { recordAspaAdoptionSnapshot } = require("../aspa-adoption/tracker"); + +// RPKI ASPA + ROA Cache from Cloudflare RPKI JSON feed +const rpkiAspaMap = new Map(); // customer_asid -> Set +let rpkiAspaLastFetch = 0; +let rpkiAspaFetching = false; + +function fetchRpkiAspaFeed() { + if (rpkiAspaFetching) return Promise.resolve(); + rpkiAspaFetching = true; + console.log("[RPKI] Fetching Cloudflare RPKI feed (ASPA + ROA)..."); + return new Promise((resolve) => { + const options = { + headers: { "User-Agent": UA }, + timeout: 120000, + }; + https.get("https://rpki.cloudflare.com/rpki.json", options, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + try { + const parsed = JSON.parse(data); + + // Load ASPA objects + const aspas = parsed.aspas || []; + rpkiAspaMap.clear(); + aspas.forEach((a) => { + const customerAsid = Number(a.customer_asid); + const providers = (a.providers || []).map(Number); + rpkiAspaMap.set(customerAsid, new Set(providers)); + }); + + // Load ROA objects into local store (eliminates RIPE Stat per-prefix calls) + const roas = parsed.roas || []; + roaStore.build(roas); + roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json"); + + // Track ASPA adoption — persist to disk + update in-memory trend + recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count, rpkiAspaMap); + + 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. feedLoaded +// distinguishes "the feed loaded fine and this ASN genuinely has no ASPA object" +// from "the Cloudflare feed fetch has never succeeded, so we don't actually know" -- +// rpkiAspaLastFetch is only set on a successful parse (see fetchRpkiAspaFeed), so +// 0 means every attempt so far has failed and exists:false here is not trustworthy. +function lookupAspaFromRpki(asn) { + const asnNum = Number(asn); + const feedLoaded = rpkiAspaLastFetch > 0; + if (rpkiAspaMap.has(asnNum)) { + const providers = rpkiAspaMap.get(asnNum); + return { exists: true, providers: [...providers].sort((a, b) => a - b), feedLoaded }; + } + return { exists: false, providers: [], feedLoaded }; +} + +// Validate RPKI for a prefix — uses local PostgreSQL database (sub-10ms, zero external API calls) +// Returns: { prefix, status: "valid"|"invalid"|"not_found"|"unavailable", validating_roas: N } +// "not_found" means the DB was queried and genuinely has no covering ROA -- a real result. +// "unavailable" means the DB query itself failed (connection/timeout/error) -- we don't know. +// Conflating these used to make DB hiccups masquerade as "no ROA published" (a real, worse- +// sounding finding). Callers computing coverage percentages should exclude "unavailable". +async function validateRPKIWithCache(asn, prefix) { + try { + const result = await localDb.validateRpki(prefix, asn); + if (result.status === 'valid') { + return { prefix, status: "valid", validating_roas: 1 }; + } else if (result.status === 'invalid') { + return { prefix, status: "invalid", validating_roas: 1 }; + } else if (result.status === 'unknown') { + return { prefix, status: "unavailable", validating_roas: 0 }; + } else { + // 'not-found': genuinely no covering ROA + return { prefix, status: "not_found", validating_roas: 0 }; + } + } catch (_e) { + console.error("[RPKI] Error validating " + prefix + ":", _e.message); + return { prefix, status: "unavailable", validating_roas: 0 }; + } +} + +// 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). +// agreement_pct only counts pairs where BOTH sources actually returned a real +// verdict -- a zero-sample or all-RIPE-lookups-failed result reports agreement_pct: +// null (not a fabricated 100), since nothing was actually cross-checked. +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: null, disagreements: [], sample_size: 0, comparable_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; + let comparable = 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++; + + // A failed/unknown RIPE lookup isn't a comparison at all -- exclude it from + // the agreement percentage instead of counting it as a silent "agreement". + if (ripeState === "error" || ripeState === "unknown") { + continue; + } + + comparable++; + if (cfIsValid === ripeIsValid) { + agreements++; + } else { + disagreements.push({ + prefix: pfx, + cloudflare: cfStatus, + ripe: ripeState, + }); + } + } + + const agreementPct = comparable > 0 ? Math.round((agreements / comparable) * 100) : null; + return { cloudflare_valid: cloudflareValid, ripe_valid: ripeValid, agreement_pct: agreementPct, disagreements: disagreements, sample_size: sample.length, comparable_size: comparable }; +} + +// rpkiAspaLastFetch is a reassigned `let`, not a mutated object -- a bare +// export would only capture its value (0) at require time. Expose it via a +// getter so callers (e.g. /api/health) always see the current value. +function getRpkiAspaLastFetch() { + return rpkiAspaLastFetch; +} + +module.exports = { + rpkiAspaMap, + getRpkiAspaLastFetch, + fetchRpkiAspaFeed, + ensureAspaCache, + lookupAspaFromRpki, + validateRPKIWithCache, + fetchRipeRpkiValidator, + crossCheckRpki, +}; diff --git a/server/topology.js b/server/topology.js new file mode 100644 index 0000000..d9cbd39 --- /dev/null +++ b/server/topology.js @@ -0,0 +1,49 @@ +const { fetchRipeStatCached } = require("./services/ripe-stat"); + +// 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 }; +} + +module.exports = { fetchTopology }; diff --git a/server/visitors.js b/server/visitors.js new file mode 100644 index 0000000..b1ad486 --- /dev/null +++ b/server/visitors.js @@ -0,0 +1,21 @@ +const fs = require("fs"); +const crypto = require("crypto"); + +const VISITORS_FILE = "/opt/peercortex-app/visitors.json"; + +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; +} + +module.exports = { loadVisitors, trackVisitor }; diff --git a/server/whois.js b/server/whois.js new file mode 100644 index 0000000..d211453 --- /dev/null +++ b/server/whois.js @@ -0,0 +1,146 @@ +const { fetchJSON } = require("./services/http-helpers"); +const { whoisCacheGet, whoisCacheSet, WHOIS_NOT_FOUND_CACHE_TTL } = require("./caches/response-caches"); + +// Feature 27: WHOIS via RIPE DB +async function fetchWhois(resource) { + const result = { resource, type: null, data: null, error: null }; + try { + const trimmed = resource.trim(); + if (/^(AS)?\d+$/i.test(trimmed)) { + result.type = "aut-num"; + const asn = trimmed.replace(/^AS/i, ""); + + // 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)"; + // Short TTL: this "not found" likely reflects a transient failure across + // all 5 sources, not a confirmed non-existent ASN -- see WHOIS_NOT_FOUND_CACHE_TTL. + whoisCacheSet(asn, null, WHOIS_NOT_FOUND_CACHE_TTL); + } + } + } 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; +} + +module.exports = { fetchWhois };