const fs = require("fs"); const http = require("http"); const https = require("https"); const crypto = require("crypto"); // ── LOCAL DATABASE CLIENT (BGP, RPKI, Threat Intel) ────────────── const localDb = require('./local-db-client'); console.log('[PeerCortex] Local DB client initialized'); // Load .env file require('./src/backend/config'); const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || ""; const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1"; // bio-rd gRPC client (optional — graceful fallback if unavailable) let risClient = null; try { const { createRisClient } = require('./bio-rd-client'); const BIO_RD_HOST = process.env.BIO_RD_HOST || 'localhost'; const BIO_RD_PORT = parseInt(process.env.BIO_RD_PORT || '4321'); risClient = createRisClient(BIO_RD_HOST, BIO_RD_PORT); console.log(`[bio-rd] RIS client configured → ${BIO_RD_HOST}:${BIO_RD_PORT}`); } catch (e) { console.log('[bio-rd] RIS client not available (bio-rd-client.js missing or gRPC not installed)'); } const { PEERINGDB_API_KEY, PEERINGDB_API_URL, getPdbLocal, queryPeeringDBLocal, pdbSemaphore, fetchPeeringDB, fetchPeeringDBWithRetry, } = require("./server/services/peeringdb"); // ── SMTP / Email ────────────────────────────────────────────── const { sendFeedbackMail } = require('./src/backend/services/smtp'); // ── SMTP / Email ────────────────────────────────────────────── const { loadVisitors, trackVisitor } = require("./server/visitors"); // ═══════════════════════════════════════════════════════════════ // PEERCORTEX v0.6.1 — New Features // ═══════════════════════════════════════════════════════════════ // ── BGP Community Database ───────────────────────────────────── // Migrated to src/features/bgp-communities/bgp-community-db.ts // ── Hijack Monitoring ────────────────────────────────────────── 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"); const { aspaAdoptionDailyHistory, recordAspaAdoptionSnapshot, getAdoptionTrend, buildAspaAdoptionDashboard, } = require("./server/aspa-adoption/tracker"); const { fetchRirDelegation, fetchIpv6AdoptionStats } = require("./server/ipv6-adoption"); const { UA } = require("./server/data/constants"); const { CITY_COORDS } = require("./server/data/city-coords"); // ============================================================ const { scoreRingSvg, pdfBaseCSS, buildPdfHtml, buildComparisonPdfHtml, formatLabel, escHtml, } = require("./server/pdf-export/templates"); const { getPuppeteerBrowser, pdfCacheGet, pdfCacheSet, renderHtmlToPdf, } = require("./server/pdf-export/renderer"); 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. const { ensureManrsCache, checkManrsMembership } = require("./server/services/manrs"); // ============================================================ // 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"); // ============================================================ // 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); } // ============================================================ // 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 server = http.createServer(async (req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); } let url, reqPath; try { url = new URL(req.url, "http://localhost"); reqPath = url.pathname; } catch (_urlErr) { res.writeHead(400); return res.end('Bad Request'); } // Serve static files — host-based routing const host = (req.headers.host || '').split(':')[0]; if (reqPath === "/" || reqPath === "/index.html") { return staticRoutes.handleRoot(req, res, host, reqPath); } // Direct access to editorial version if (reqPath === "/index-editorial.html") { return staticRoutes.handleIndexEditorial(req, res); } // 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") { return searchRoutes.handleVisitors(req, res); } // OPTIONS preflight (CORS) 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") { return feedbackRoutes.handlePost(req, res); } // GET /api/feedback?token=... — admin: fetch all entries as JSON if (reqPath === "/api/feedback" && req.method === "GET") { return feedbackRoutes.handleGet(req, res, url); } // Serve favicon if (reqPath === "/favicon.ico") { return staticRoutes.handleFavicon(req, res); } // Lia's Atlas Paradise - Easter egg page if (reqPath === "/lia" || reqPath === "/lia/") { return staticRoutes.handleLia(req, res); } // Lia's Atlas Paradise: Atlas probe coverage endpoint if (reqPath === "/api/atlas/coverage") { return liaRoutes.handleAtlasCoverage(req, res); } // Lia's Paradise: File parsing endpoint (for binary uploads) if (reqPath === "/api/lia/parse-file" && req.method === "POST") { return liaRoutes.handleParseFile(req, res); } // Lia's Paradise: Combined PeeringDB + Atlas coverage data if (reqPath === "/api/lia/coverage") { 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") { return healthRoute.handleHealth(req, res); } // ============================================================ // ASPA Deep Verification endpoint: /api/aspa/verify?asn=X if (reqPath === "/api/aspa/verify") { return aspaVerifyRoute.handleAspaVerify(req, res, url); } // ASPA Check endpoint: /api/aspa?asn=X (existing, kept for compat) if (reqPath === "/api/aspa") { return aspaLegacyRoute.handleAspaLegacy(req, res, url); } // BGP endpoint (LOCAL DB): /api/bgp?asn=X (or prefix=X) if (reqPath === "/api/bgp") { return bgpRoute.handleBgp(req, res, url); } // Unified Validation endpoint: /api/validate?asn=X if (reqPath === "/api/validate") { return validateRoute.handleValidate(req, res, url); } // Main lookup endpoint: /api/lookup?asn=X if (reqPath === "/api/lookup") { return lookupRoute.handleLookup(req, res, url); } // 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 })); } } // ============================================================ // Compare endpoint: /api/compare?asn1=X&asn2=Y // ============================================================ if (reqPath === "/api/compare") { const asn1 = (url.searchParams.get("asn1") || "").replace(/[^0-9]/g, ""); const asn2 = (url.searchParams.get("asn2") || "").replace(/[^0-9]/g, ""); if (!asn1 || !asn2) { res.writeHead(400); return res.end(JSON.stringify({ error: "Need asn1 and asn2 parameters" })); } const compareCacheKey = "compare:" + asn1 + ":" + asn2; const compareCached = cacheGet(compareCacheKey); if (compareCached) { res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); return res.end(JSON.stringify(compareCached)); } const start = Date.now(); try { // ALL calls in parallel — single batch // Phase 1: Get PDB net objects + RIPE data const [pdb1, pdb2, nb1Data, nb2Data, pfx1Data, pfx2Data] = await Promise.all([ fetchPeeringDB("/net?asn=" + asn1), fetchPeeringDB("/net?asn=" + asn2), fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn1, { timeout: 8000 }), fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2, { timeout: 8000 }), fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1, { timeout: 8000 }), fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2, { timeout: 8000 }), ]); const net1 = pdb1?.data?.[0] || {}; const net2 = pdb2?.data?.[0] || {}; const netId1 = net1.id; const netId2 = net2.id; // Phase 2: IX + Facility using net_id (Bug 1 fix: netfac requires net_id, not asn) const ixFacPromises = []; ixFacPromises.push(netId1 ? fetchPeeringDB("/netixlan?net_id=" + netId1) : Promise.resolve(null)); ixFacPromises.push(netId2 ? fetchPeeringDB("/netixlan?net_id=" + netId2) : Promise.resolve(null)); ixFacPromises.push(netId1 ? fetchPeeringDB("/netfac?net_id=" + netId1) : Promise.resolve(null)); ixFacPromises.push(netId2 ? fetchPeeringDB("/netfac?net_id=" + netId2) : Promise.resolve(null)); const [ix1Data, ix2Data, fac1Data, fac2Data] = await Promise.all(ixFacPromises); const ix1Set = new Set((ix1Data?.data || []).map((ix) => ix.ix_id)); const ix2Set = new Set((ix2Data?.data || []).map((ix) => ix.ix_id)); const ix1Names = {}; (ix1Data?.data || []).forEach((ix) => (ix1Names[ix.ix_id] = ix.name)); const ix2Names = {}; (ix2Data?.data || []).forEach((ix) => (ix2Names[ix.ix_id] = ix.name)); const commonIX = [...ix1Set].filter((id) => ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || ix2Names[id] || "" })); const only1IX = [...ix1Set].filter((id) => !ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || "" })); const only2IX = [...ix2Set].filter((id) => !ix1Set.has(id)).map((id) => ({ ix_id: id, name: ix2Names[id] || "" })); const fac1Set = new Set((fac1Data?.data || []).map((f) => f.fac_id)); const fac2Set = new Set((fac2Data?.data || []).map((f) => f.fac_id)); const fac1Names = {}; (fac1Data?.data || []).forEach((f) => (fac1Names[f.fac_id] = f.name)); const fac2Names = {}; (fac2Data?.data || []).forEach((f) => (fac2Names[f.fac_id] = f.name)); const commonFac = [...fac1Set].filter((id) => fac2Set.has(id)).map((id) => ({ fac_id: id, name: fac1Names[id] || fac2Names[id] || "" })); const nb1 = (nb1Data?.data?.neighbours || []).filter((n) => n.type === "left"); const nb2 = (nb2Data?.data?.neighbours || []).filter((n) => n.type === "left"); const up1Set = new Set(nb1.map((n) => n.asn)); const up2Set = new Set(nb2.map((n) => n.asn)); const nb1Map = {}; nb1.forEach((n) => (nb1Map[n.asn] = n.as_name || "")); const nb2Map = {}; nb2.forEach((n) => (nb2Map[n.asn] = n.as_name || "")); const commonUpstreams = [...up1Set] .filter((a) => up2Set.has(a)) .map((a) => ({ asn: a, name: nb1Map[a] || nb2Map[a] || "" })); // ---- Threat Intelligence Enrichment for Common Upstreams ---- const threatMap = {}; const threatPromises = commonUpstreams.slice(0, 50).map(async (n) => { try { const asNum = String(n.asn); const threat = await localDb.getThreatIntel(asNum); if (threat) { threatMap[n.asn] = { threat_level: threat.threat_level, confidence_score: threat.confidence_score, source: threat.source, }; } } catch (e) { console.error(`[Compare Threat Lookup] Error checking ASN ${n.asn}:`, e.message); } }); await Promise.race([ Promise.all(threatPromises), new Promise(r => setTimeout(r, 2000)), ]); // Attach threat status to upstream objects commonUpstreams.forEach((n) => { if (threatMap[n.asn]) { n.threat_level = threatMap[n.asn].threat_level; n.threat_confidence = threatMap[n.asn].confidence_score; n.threat_source = threatMap[n.asn].source; } }); // Resolve names + RPKI sample (max 3+3 prefixes) all in parallel with 5s timeout const pfx1 = (pfx1Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix); const pfx2 = (pfx2Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix); const [, rpki1Results, rpki2Results] = await Promise.race([ Promise.all([ commonUpstreams.length > 0 ? Promise.all(commonUpstreams.map(n => fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 }) .then(r => { if (r?.data?.holder) n.name = r.data.holder; }) .catch(() => {}) )) : Promise.resolve([]), Promise.all(pfx1.map((p) => fetchRPKIPerPrefix(asn1, p))), Promise.all(pfx2.map((p) => fetchRPKIPerPrefix(asn2, p))), ]), new Promise(r => setTimeout(() => r([[], [], []]), 5000)), ]); const rpki1Valid = rpki1Results.filter((r) => r.status === "valid").length; const rpki2Valid = rpki2Results.filter((r) => r.status === "valid").length; const rpki1Pct = rpki1Results.length > 0 ? Math.round((rpki1Valid / rpki1Results.length) * 100) : 0; const rpki2Pct = rpki2Results.length > 0 ? Math.round((rpki2Valid / rpki2Results.length) * 100) : 0; const duration = Date.now() - start; const compareResult = { meta: { duration_ms: duration, timestamp: new Date().toISOString() }, asn1: { asn: parseInt(asn1), name: net1.name || "Unknown", ix_count: ix1Set.size, fac_count: fac1Set.size, upstream_count: up1Set.size, rpki_coverage: rpki1Pct, }, asn2: { asn: parseInt(asn2), name: net2.name || "Unknown", ix_count: ix2Set.size, fac_count: fac2Set.size, upstream_count: up2Set.size, rpki_coverage: rpki2Pct, }, common_ixps: commonIX, only_asn1_ixps: only1IX, only_asn2_ixps: only2IX, common_facilities: commonFac, common_upstreams: commonUpstreams, rpki_comparison: { asn1_coverage: rpki1Pct, asn2_coverage: rpki2Pct, asn1_checked: rpki1Results.length, asn2_checked: rpki2Results.length, better: rpki1Pct > rpki2Pct ? "AS" + asn1 : rpki2Pct > rpki1Pct ? "AS" + asn2 : "equal", }, }; cacheSet(compareCacheKey, compareResult, CACHE_TTL_DEFAULT); res.end(JSON.stringify(compareResult, null, 2)); } catch (err) { res.writeHead(500); res.end(JSON.stringify({ error: "Compare failed", message: err.message })); } return; } // ============================================================ // Quick-IX endpoint: /api/quick-ix?asn=X // Lightweight: only IX connections from PeeringDB, 1h cached // Used by Peering Recommendations to avoid 20x full lookups // ============================================================ if (reqPath === "/api/quick-ix") { const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); if (!rawAsn) { res.writeHead(400); return res.end(JSON.stringify({ error: "Missing asn parameter" })); } const cached = quickIxCacheGet(rawAsn); if (cached !== undefined) { res.writeHead(200, { "Content-Type": "application/json" }); return res.end(JSON.stringify(cached)); } try { const [pdbNetData, pdbIxlanData] = await Promise.all([ fetchJSON("https://www.peeringdb.com/api/net?asn=" + rawAsn + "&depth=0", { timeout: 5000 }).catch(() => null), fetchJSON("https://www.peeringdb.com/api/netixlan?asn=" + rawAsn + "&limit=100", { timeout: 6000 }).catch(() => null), ]); const netName = pdbNetData?.data?.[0]?.name || ""; const ixConnections = []; if (pdbIxlanData && pdbIxlanData.data) { pdbIxlanData.data.forEach((row) => { ixConnections.push({ ix_id: row.ixlan_id, name: row.name || "", speed: row.speed || 0 }); }); } // Fall back to RIPE Stat if PeeringDB returns nothing if (ixConnections.length === 0) { const rsStat = await fetchRipeStatCached("https://stat.ripe.net/data/ixs/data.json?resource=AS" + rawAsn, { timeout: 5000 }).catch(() => null); const ixs = rsStat?.data?.ixs || []; ixs.forEach((ix) => ixConnections.push({ ix_id: ix.ixp_id || 0, name: ix.name || "", speed: 0 })); } const result = { asn: parseInt(rawAsn), name: netName, ix_connections: ixConnections }; quickIxCacheSet(rawAsn, result); res.writeHead(200, { "Content-Type": "application/json" }); return res.end(JSON.stringify(result)); } catch (err) { res.writeHead(500); return res.end(JSON.stringify({ error: "quick-ix lookup failed", message: err.message })); } } // ============================================================ // Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000 // ============================================================ if (reqPath === "/api/peers/find") { const ixName = url.searchParams.get("ix") || ""; const policy = url.searchParams.get("policy") || ""; const minSpeed = parseInt(url.searchParams.get("min_speed") || "0"); const netType = url.searchParams.get("type") || ""; if (!ixName) { res.writeHead(400); return res.end(JSON.stringify({ error: "Missing ix parameter (IX name)" })); } const start = Date.now(); try { // Search for IX by name const ixSearch = await fetchPeeringDB("/ix?name__contains=" + encodeURIComponent(ixName)); const ixResults = ixSearch?.data || []; if (ixResults.length === 0) { return res.end(JSON.stringify({ error: "No IX found matching: " + ixName, matches: [] })); } // Use first matching IX const ix = ixResults[0]; const ixId = ix.id; // Get ixlan for this IX const ixlanData = await fetchPeeringDB("/ixlan?ix_id=" + ixId); const ixlans = ixlanData?.data || []; if (ixlans.length === 0) { return res.end(JSON.stringify({ ix: { id: ixId, name: ix.name }, matches: [] })); } const ixlanId = ixlans[0].id; // Get all networks at this IX const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId); const netixlans = netixlanData?.data || []; // Get unique net_ids const netIds = [...new Set(netixlans.map(n => n.net_id))]; // Fetch network details in batches const networks = []; const batchSize = 20; for (let i = 0; i < Math.min(netIds.length, 200); i += batchSize) { const batch = netIds.slice(i, i + batchSize); const batchResults = await Promise.all( batch.map(nid => fetchPeeringDB("/net/" + nid)) ); batchResults.forEach(r => { if (r?.data?.[0]) networks.push(r.data[0]); }); } // Filter and rank let filtered = networks.map(net => { const nix = netixlans.filter(n => n.net_id === net.id); const maxSpeed = Math.max(...nix.map(n => n.speed || 0)); return { asn: net.asn, name: net.name || "", policy: net.policy_general || "", type: net.info_type || "", speed_mbps: maxSpeed, speed_gbps: maxSpeed >= 1000 ? (maxSpeed / 1000) + " Gbps" : maxSpeed + " Mbps", traffic: net.info_traffic || "", website: net.website || "", peeringdb_id: net.id, ipv4: nix[0]?.ipaddr4 || null, ipv6: nix[0]?.ipaddr6 || null, }; }); // Apply filters if (policy) { filtered = filtered.filter(n => n.policy.toLowerCase().includes(policy.toLowerCase())); } if (minSpeed > 0) { filtered = filtered.filter(n => n.speed_mbps >= minSpeed); } if (netType) { filtered = filtered.filter(n => n.type.toLowerCase().includes(netType.toLowerCase())); } // Sort by speed desc filtered.sort((a, b) => b.speed_mbps - a.speed_mbps); // Also find common IXPs for each match (check if they share other IXPs) const duration = Date.now() - start; return res.end(JSON.stringify({ meta: { duration_ms: duration, timestamp: new Date().toISOString() }, ix: { id: ixId, name: ix.name, ixlan_id: ixlanId }, total_members: netixlans.length, filtered_count: filtered.length, matches: filtered.slice(0, 50), }, null, 2)); } catch (err) { res.writeHead(500); return res.end(JSON.stringify({ error: "Peer matching failed", message: err.message })); } } // ============================================================ // Prefix Detail endpoint: /api/prefix/detail?prefix=X // ============================================================ if (reqPath === "/api/prefix/detail") { const prefix = url.searchParams.get("prefix") || ""; if (!prefix) { res.writeHead(400); return res.end(JSON.stringify({ error: "Missing prefix parameter" })); } const start = Date.now(); try { const [routingStatus, visibility] = await Promise.all([ fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=" + encodeURIComponent(prefix)), fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=" + encodeURIComponent(prefix)), ]); const origins = routingStatus?.data?.origins || []; const firstSeen = routingStatus?.data?.first_seen?.time || null; // RPKI validation: use local PostgreSQL database (sub-10ms, zero external API calls) let rpkiStatus = "unknown"; let rpkiRoas = []; const originAsn = origins.length > 0 ? origins[0].asn : null; if (originAsn) { try { const localRpki = await validateRPKIWithCache(originAsn, prefix); rpkiStatus = localRpki.status; rpkiRoas = new Array(localRpki.validating_roas); // count only, no detail } catch (e) { console.error("[Prefix Detail] RPKI validation error:", e.message); rpkiStatus = "unknown"; rpkiRoas = []; } } var visData = visibility?.data?.visibilities || []; var risPeersSeeingIt = visData.length > 0 ? visData.filter(v => v.ris_peers_seeing > 0).length : 0; var visibilitySource = "ripe_stat"; // bgproutes.io fallback if RIPE Stat visibility returned no data if (visData.length === 0 && BGPROUTES_API_KEY) { var bgprVis = await fetchBgproutesVisibility(prefix); if (bgprVis && bgprVis.vps_seeing > 0) { risPeersSeeingIt = bgprVis.vps_seeing; visData = []; // keep empty, use risPeersSeeingIt visibilitySource = "bgproutes.io"; } } // Try to get IRR data let irrStatus = "unknown"; try { const whoisData = await fetchRipeStatCached("https://stat.ripe.net/data/whois/data.json?resource=" + encodeURIComponent(prefix)); const records = whoisData?.data?.records || []; if (records.length > 0) irrStatus = "found"; } catch(_e) {} const duration = Date.now() - start; return res.end(JSON.stringify({ meta: { duration_ms: duration, timestamp: new Date().toISOString() }, prefix: prefix, origins: origins.map(o => ({ asn: o.asn, prefix: o.prefix })), rpki: { status: rpkiStatus, validating_roas: rpkiRoas.length }, irr_status: irrStatus, visibility: { ris_peers_seeing: risPeersSeeingIt, total_probes: visData.length || risPeersSeeingIt, source: visibilitySource }, first_seen: firstSeen, }, null, 2)); } catch (err) { res.writeHead(500); return res.end(JSON.stringify({ error: "Prefix detail failed", message: err.message })); } } // ============================================================ // IX Detail endpoint: /api/ix/detail?ix_id=X // ============================================================ if (reqPath === "/api/ix/detail") { const ixId = (url.searchParams.get("ix_id") || "").replace(/[^0-9]/g, ""); if (!ixId) { res.writeHead(400); return res.end(JSON.stringify({ error: "Missing ix_id parameter" })); } const start = Date.now(); try { const [ixData, ixlanData] = await Promise.all([ fetchPeeringDB("/ix/" + ixId), fetchPeeringDB("/ixlan?ix_id=" + ixId), ]); const ix = ixData?.data?.[0] || {}; const ixlans = ixlanData?.data || []; const ixlanId = ixlans.length > 0 ? ixlans[0].id : null; let members = []; if (ixlanId) { const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId); members = (netixlanData?.data || []).map(m => ({ asn: m.asn, name: m.name || "", speed_mbps: m.speed || 0, speed_display: (m.speed || 0) >= 1000 ? ((m.speed || 0) / 1000) + " Gbps" : (m.speed || 0) + " Mbps", ipv4: m.ipaddr4 || null, ipv6: m.ipaddr6 || null, })); } // Sort by speed desc for top members const sorted = members.slice().sort((a, b) => b.speed_mbps - a.speed_mbps); const duration = Date.now() - start; return res.end(JSON.stringify({ meta: { duration_ms: duration, timestamp: new Date().toISOString() }, ix: { id: parseInt(ixId), name: ix.name || "", city: ix.city || "", country: ix.country || "", website: ix.website || "", peeringdb_url: "https://www.peeringdb.com/ix/" + ixId, }, total_members: members.length, top_members_by_speed: sorted.slice(0, 20), all_members: sorted, }, null, 2)); } catch (err) { res.writeHead(500); return res.end(JSON.stringify({ error: "IX detail failed", message: err.message })); } } // ============================================================ // Feature 25: Topology endpoint // ============================================================ if (reqPath === "/api/topology") { const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); const depth = parseInt(url.searchParams.get("depth") || "2") || 2; if (!rawAsn) { res.writeHead(400); return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); } const start = Date.now(); try { const topology = await fetchTopology(parseInt(rawAsn), depth); // ---- Threat Intelligence Enrichment for Topology Nodes ---- const threatMap = {}; const threatPromises = topology.nodes.slice(0, 100).map(async (node) => { try { const asNum = String(node.asn); const threat = await localDb.getThreatIntel(asNum); if (threat) { threatMap[node.asn] = { threat_level: threat.threat_level, confidence_score: threat.confidence_score, source: threat.source, }; } } catch (e) { console.error(`[Topology Threat Lookup] Error checking ASN ${node.asn}:`, e.message); } }); await Promise.race([ Promise.all(threatPromises), new Promise(r => setTimeout(r, 3000)), ]); // Attach threat status to node objects topology.nodes.forEach((node) => { if (threatMap[node.asn]) { node.threat_level = threatMap[node.asn].threat_level; node.threat_confidence = threatMap[node.asn].confidence_score; node.threat_source = threatMap[node.asn].source; } }); topology.meta = { query: "AS" + rawAsn, depth: depth, duration_ms: Date.now() - start, timestamp: new Date().toISOString(), node_count: topology.nodes.length, edge_count: topology.edges.length, }; return res.end(JSON.stringify(topology, null, 2)); } catch (err) { res.writeHead(500); return res.end(JSON.stringify({ error: "Topology query failed", message: err.message })); } } // ============================================================ // Feature 27: WHOIS endpoint // ============================================================ if (reqPath === "/api/whois") { const resource = url.searchParams.get("resource") || ""; if (!resource) { res.writeHead(400); return res.end(JSON.stringify({ error: "Missing resource parameter (ASN, prefix, or domain)" })); } const start = Date.now(); try { const whoisResult = await fetchWhois(resource); if (!whoisResult || typeof whoisResult !== "object") { res.writeHead(503); return res.end(JSON.stringify({ error: "WHOIS data temporarily unavailable" })); } whoisResult.meta = { duration_ms: Date.now() - start, timestamp: new Date().toISOString() }; return res.end(JSON.stringify(whoisResult, null, 2)); } catch (err) { res.writeHead(500); return res.end(JSON.stringify({ error: "WHOIS lookup failed", message: err.message })); } } // Feature 28b: Company enrichment via Wikipedia + website meta scraping if (reqPath === "/api/enrich") { res.setHeader("Content-Type", "application/json"); res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Cache-Control", "no-store"); const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); const companyName = (url.searchParams.get("name") || "").trim(); const website = (url.searchParams.get("website") || "").trim(); const UA_SCRAPE = "Mozilla/5.0 (compatible; PeerCortex/1.0; +https://peercortex.org)"; let description = null; let wikiUrl = null; try { // 1. Direct Wikipedia lookup by company name if (companyName) { const nameLower = companyName.toLowerCase(); const isRelevant = (title, extract) => { const titleLower = (title || "").toLowerCase(); const extractLower = (extract || "").toLowerCase(); const nameWords = nameLower.replace(/\s+(gmbh|ag|ltd|inc|llc|bv|sa|sas|oy|ab)\s*$/i, "").split(/\s+/).filter(w => w.length > 3); const titleMatch = nameWords.some(w => titleLower.includes(w)); const netTerms = ["internet", "network", "isp", "hosting", "provider", "transit", "data center", "datacenter", "telecommunications", "telecom", "bandwidth", "peering", "routing", "autonomous system", "colocation", "colo", "fiber", "optical", "transceiver"]; const hasNetContext = netTerms.some(t => extractLower.includes(t)); return titleMatch && (hasNetContext || titleMatch); }; // Direct title lookup — try full name first, then first word as fallback const cleanName = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC|BV|SA|SAS|Oy|AB)$/i, "").trim(); const firstName = cleanName.split(/\s+/)[0]; const namesToTry = cleanName === firstName ? [cleanName] : [cleanName, firstName]; for (const tryName of namesToTry) { const wikiDirect = await fetchJSON( "https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(tryName), { timeout: 5000 } ); if (wikiDirect && wikiDirect.type === "disambiguation") continue; // skip disambiguation pages if (wikiDirect && wikiDirect.extract && wikiDirect.extract.length > 30 && isRelevant(wikiDirect.title, wikiDirect.extract)) { description = wikiDirect.extract.replace(/\s+/g, " ").trim().slice(0, 300); wikiUrl = wikiDirect.content_urls && wikiDirect.content_urls.desktop && wikiDirect.content_urls.desktop.page; break; } } // 2. Wikipedia search if direct lookup didn't match if (!description) { const searchQuery = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC)$/i, "") + " internet service provider"; const searchData = await fetchJSON( "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=" + encodeURIComponent(searchQuery) + "&limit=3", { timeout: 5000 } ); if (searchData && Array.isArray(searchData) && searchData[1] && searchData[1].length > 0) { const topTitle = searchData[1][0]; const wikiSearch = await fetchJSON( "https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(topTitle), { timeout: 5000 } ); if (wikiSearch && wikiSearch.extract && wikiSearch.extract.length > 30) { if (isRelevant(wikiSearch.title, wikiSearch.extract)) { description = wikiSearch.extract.replace(/\s+/g, " ").trim().slice(0, 300); wikiUrl = wikiSearch.content_urls && wikiSearch.content_urls.desktop && wikiSearch.content_urls.desktop.page; } } } } } // 3. Fallback: scrape website meta description (follows up to 3 redirects) function fetchPage(pageUrl, hops) { if (hops <= 0) return Promise.resolve(null); return new Promise((resolve) => { const mod = pageUrl.startsWith("https") ? https : http; const req = mod.get(pageUrl, { headers: { "User-Agent": UA_SCRAPE }, timeout: 6000 }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); // drain to free socket const next = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, pageUrl).href; return resolve(fetchPage(next, hops - 1)); } let data = ""; res.on("data", (c) => { data += c; if (data.length > 40000) { req.destroy(); resolve(data); } }); res.on("end", () => resolve(data)); }); req.on("error", () => resolve(null)); req.on("timeout", () => { req.destroy(); resolve(null); }); }); } if (!description && website) { let wsUrl = website; if (!wsUrl.startsWith("http")) wsUrl = "https://" + wsUrl; const aboutUrl = wsUrl.replace(/\/$/, "") + "/about"; const tryUrls = [aboutUrl, wsUrl]; for (const tryUrl of tryUrls) { const resp = await fetchPage(tryUrl, 3); if (!resp) continue; const metaPatterns = [ /]+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 })); } // 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); } // ── 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 += `
· ${m}
`; } else if (line.startsWith('- ')) { html += `· ${line.slice(2)}
`; } else if (line.startsWith('# ')) { html += `PeerCortex · v0.5.0 · Open Source · MIT
`; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); res.writeHead(200); return res.end(page); } catch(e) { res.writeHead(500); return res.end('Changelog not available'); } } // ── BGP Community Decoder ──────────────────────────────────── if (reqPath === '/api/communities') { return proxyToApiServer(req, res, req.url); } // ── IRR Audit ───────────────────────────────────────────────── if (reqPath === '/api/irr-audit') { return proxyToApiServer(req, res, req.url); } // ── AS-SET Expander ─────────────────────────────────────────── if (reqPath === '/api/asset-expand') { return proxyToApiServer(req, res, req.url); } // ── Routing History (prefix table via RIPE Stat routing-history) ── if (reqPath === '/api/rpki-history') { return proxyToApiServer(req, res, req.url); } // ── AS-PATH Visualizer (RIPE Stat looking-glass) ──────────────── if (reqPath === '/api/aspath') { return proxyToApiServer(req, res, req.url); } // ── Looking Glass (RIPE Stat) ───────────────────────────────── if (reqPath === '/api/looking-glass') { return proxyToApiServer(req, res, req.url); } // ── IXP Peering Matrix ──────────────────────────────────────── if (reqPath === '/api/ix-matrix') { return proxyToApiServer(req, res, req.url); } // ── CORS preflight for all /api/webhooks + /api/hijacks ────── if ((reqPath.startsWith('/api/webhooks') || reqPath.startsWith('/api/hijacks') || reqPath === '/api/hijack-subscribe') && req.method === 'OPTIONS') { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); res.writeHead(204); return res.end(); } // ── Webhook: Register ───────────────────────────────────────── // POST /api/webhooks?asn=X body: { endpoint_url, timeout_ms?, max_retries? } if (reqPath === '/api/webhooks' && req.method === 'POST') { res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); const params = new URL(req.url, 'http://localhost').searchParams; const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : ''; if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn query param required'})); } let body = ''; req.on('data', c => body += c); req.on('end', () => { try { const { endpoint_url, timeout_ms, max_retries } = JSON.parse(body || '{}'); if (!endpoint_url || !endpoint_url.startsWith('http')) { res.writeHead(400); return res.end(JSON.stringify({error:'endpoint_url required (http/https)'})); } const subs = loadWebhookSubs(); const exists = subs.find(s => s.asn === asn && s.endpoint_url === endpoint_url); if (exists) { res.writeHead(200); return res.end(JSON.stringify({id: exists.id, already_exists: true, secret_key: exists.secret})); } const webhookToken = crypto.randomBytes(32).toString('hex'); const entry = { id: crypto.randomBytes(8).toString('hex'), asn, endpoint_url, secret: webhookToken, timeout_ms: timeout_ms || 10000, max_retries: max_retries || 5, active: true, created_at: new Date().toISOString(), last_triggered_at: null, failure_count: 0 }; subs.push(entry); saveWebhookSubs(subs); // Also ensure this ASN is in hijack monitoring const hijackSubs = loadHijackSubs(); if (!hijackSubs.find(s => s.asn === asn)) { checkHijacksForAsn(asn).then(prefixes => { // 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; } // ── Webhook: List ───────────────────────────────────────────── // GET /api/webhooks?asn=X if (reqPath === '/api/webhooks' && req.method === 'GET') { res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); const params = new URL(req.url, 'http://localhost').searchParams; const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : ''; const subs = loadWebhookSubs(); const result = (asn ? subs.filter(s => s.asn === asn) : subs) .map(s => ({ id: s.id, asn: s.asn, endpoint_url: s.endpoint_url, active: s.active, failure_count: s.failure_count, last_triggered_at: s.last_triggered_at, created_at: s.created_at })); res.writeHead(200); return res.end(JSON.stringify({ webhooks: result, total: result.length })); } // ── Webhook: Delete ─────────────────────────────────────────── // DELETE /api/webhooks/:id if (reqPath.startsWith('/api/webhooks/') && req.method === 'DELETE' && !reqPath.includes('/test')) { res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); const id = reqPath.split('/')[3]; const subs = loadWebhookSubs(); const idx = subs.findIndex(s => s.id === id); if (idx === -1) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); } subs.splice(idx, 1); saveWebhookSubs(subs); res.writeHead(200); return res.end(JSON.stringify({ deleted: true, id })); } // ── Webhook: Test ───────────────────────────────────────────── // POST /api/webhooks/:id/test if (reqPath.match(/^\/api\/webhooks\/[^/]+\/test$/) && req.method === 'POST') { res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); const id = reqPath.split('/')[3]; const subs = loadWebhookSubs(); const sub = subs.find(s => s.id === id); if (!sub) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); } const testPayload = { event: 'test', asn: sub.asn, message: 'PeerCortex webhook test — if you receive this, delivery works!', timestamp: new Date().toISOString() }; const start = Date.now(); deliverWebhook(sub, testPayload, 1).then(result => { res.writeHead(result.ok ? 200 : 502); res.end(JSON.stringify({ ok: result.ok, response_time_ms: Date.now() - start, status: result.status, error: result.error || null })); }); return; } // ── Hijack Events: List ─────────────────────────────────────── // GET /api/hijacks?asn=X&limit=50&resolved=false if (reqPath === '/api/hijacks' && req.method === 'GET') { const params = new URL(req.url, 'http://localhost').searchParams; const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : ''; const limit = Math.min(parseInt(params.get('limit') || '50', 10), 200); const resolvedFilter = params.get('resolved'); res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Cache-Control', 'no-store'); let events = loadHijackAlerts(); if (asn) events = events.filter(a => String(a.asn) === asn); if (resolvedFilter === 'false') events = events.filter(a => !a.resolved); if (resolvedFilter === 'true') events = events.filter(a => a.resolved); const total = events.length; events = events.slice(-limit).reverse(); res.writeHead(200); return res.end(JSON.stringify({ total, events })); } // ── Hijack Events: Resolve ──────────────────────────────────── // POST /api/hijacks/:id/resolve body: { resolution_notes? } if (reqPath.match(/^\/api\/hijacks\/[^/]+\/resolve$/) && req.method === 'POST') { res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); const id = reqPath.split('/')[3]; let body = ''; req.on('data', c => body += c); req.on('end', () => { const alerts = loadHijackAlerts(); const alert = alerts.find(a => a.id === id); if (!alert) { res.writeHead(404); return res.end(JSON.stringify({error:'event not found'})); } alert.resolved = true; alert.resolved_at = new Date().toISOString(); try { const { resolution_notes } = JSON.parse(body || '{}'); if (resolution_notes) alert.resolution_notes = resolution_notes; } catch(_){} saveHijackAlerts(alerts); res.writeHead(200); res.end(JSON.stringify({ resolved: true, id, resolved_at: alert.resolved_at })); }); return; } // ── Hijack Subscribe (legacy, kept for backwards compatibility) ─ if (reqPath === '/api/hijack-subscribe' && req.method === 'POST') { res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); let body = ''; req.on('data', c => body += c); req.on('end', async () => { try { const { asn } = JSON.parse(body || '{}'); const asnNum = String(asn || '').replace(/[^0-9]/g,''); if (!asnNum) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); } const subs = loadHijackSubs(); const exists = subs.find(s => s.asn === asnNum); if (!exists) { const prefixes = await checkHijacksForAsn(asnNum); // 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) ─ 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 })); } // ── 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); } // ── 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); res.end( JSON.stringify({ error: "Not found. Endpoints: /api/health, /api/validate?asn=X, /api/lookup?asn=X, /api/aspa?asn=X, /api/aspa/verify?asn=X, /api/bgproutes?asn=X, /api/compare?asn1=X&asn2=Y, /api/peers/find?ix=NAME, /api/prefix/detail?prefix=X, /api/ix/detail?ix_id=X, /api/export/pdf?asn=X&format=report|executive|technical, /api/export/pdf/compare?asn1=X&asn2=Y, /api/aspa-adoption-stats?period=30d, /api/ipv6-adoption-stats", }) ); }); const { atlasState, fetchAllAtlasProbes } = require("./server/atlas-probes"); const { pdbOrgState, fetchPdbOrgCountries } = require("./server/pdb-org-countries"); const PORT = process.env.PORT || 3101; // ============================================================ // Startup Sequence — load disk caches first, then fetch fresh data // ============================================================ // Phase 0: Load disk caches for fast restart (instant) roaStore.loadFromDisk("/opt/peercortex-app/.roa-cache.json"); pdbSourceCache.loadFromDisk("/opt/peercortex-app/.pdb-source-cache.json"); loadRipeStatCacheFromDisk("/opt/peercortex-app/.ripe-stat-cache.json"); // Phase 1: Fetch fresh RPKI feed (ASPA + ROA) + Atlas probes + PDB org countries + MANRS participants ensureManrsCache(); // fire-and-forget, 24h cache Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => { // Re-record ASPA adoption snapshot now that Atlas data is fully loaded recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count, rpkiAspaMap); server.listen(PORT, "0.0.0.0", () => { console.log("PeerCortex v0.6.5 running on http://0.0.0.0:" + PORT); console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured")); console.log("PeeringDB API key: " + (PEERINGDB_API_KEY ? "configured" : "NOT configured")); console.log("RPKI ASPA objects: " + rpkiAspaMap.size); console.log("ROA store: " + roaStore.count + " entries (" + (roaStore.ready ? "ready" : "loading...") + ")"); console.log("PDB source cache: net=" + pdbSourceCache.net.size + " ix=" + pdbSourceCache.netixlan.size + " fac=" + pdbSourceCache.netfac.size); console.log("RIPE Stat cache: " + ripeStatCache.size + " entries"); }); }); // ============================================================ // bio-rd RIB WebSocket — live route streaming on /ws/rib // ============================================================ let WebSocketServer = null; try { WebSocketServer = require('ws').Server; } catch(_e) {} if (WebSocketServer) { const ribWss = new WebSocketServer({ server, path: '/ws/rib' }); ribWss.on('connection', function(ws) { let cancelStream = null; ws.on('message', function(raw) { try { const msg = JSON.parse(raw); if (msg.type === 'rib-subscribe') { if (cancelStream) { cancelStream(); cancelStream = null; } if (!risClient) { ws.send(JSON.stringify({ type: 'error', error: 'bio-rd RIS not configured' })); return; } const router = msg.router || 'default'; cancelStream = risClient.observeRib( router, 'default', function(update) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(update)); }, function(err) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: err.message })); } ); } } catch(e) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: e.message })); } }); ws.on('close', function() { if (cancelStream) { cancelStream(); cancelStream = null; } }); }); console.log('[bio-rd] RIB WebSocket server listening on /ws/rib'); } else { console.log('[bio-rd] WebSocket server skipped (ws package not installed)'); } // ============================================================ // Refresh timers — jittered to avoid thundering herd // ============================================================ // RPKI feed (ASPA + ROA): every 4h ± 5min jitter setInterval(() => { fetchRpkiAspaFeed(); }, 4 * 60 * 60 * 1000 + Math.floor(Math.random() * 10 * 60 * 1000) - 5 * 60 * 1000); // Atlas probe cache: every 12h ± 10min jitter setInterval(function() { fetchAllAtlasProbes(); }, 12 * 60 * 60 * 1000 + Math.floor(Math.random() * 20 * 60 * 1000) - 10 * 60 * 1000); // Disk cache persistence: every 30 minutes setInterval(function() { pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json"); saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json"); }, 30 * 60 * 1000); // Save caches on graceful shutdown process.on("SIGTERM", function() { console.log("[SHUTDOWN] Saving caches to disk..."); pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json"); saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json"); roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json"); process.exit(0); }); process.on("SIGINT", function() { console.log("[SHUTDOWN] Saving caches to disk..."); pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json"); saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json"); roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json"); process.exit(0); });