diff --git a/server.js b/server.js index cc3184b..5d8f940 100644 --- a/server.js +++ b/server.js @@ -179,40 +179,6 @@ 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 // ============================================================ @@ -236,6 +202,11 @@ 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", "*"); @@ -399,526 +370,92 @@ const server = http.createServer(async (req, res) => { } - // Feature 28: Submarine Cable overlay (TeleGeography proxy) - // ── Submarine Cables map data ───────────────────────────────── - if (reqPath === '/api/submarine-cables') { - return proxyToApiServer(req, res, req.url); + // 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); } - // ── Global datacenter/IXP map (PeeringDB proxy) ─────────────── - if (reqPath === '/api/global-infra') { - return proxyToApiServer(req, res, req.url); + // Changelog page + if (reqPath === "/changelog") { + return changelogRoute.handleChangelog(req, res); } - - // ── 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'); - } + // CORS preflight for all /api/webhooks + /api/hijacks + if (hijackAlertsRoute.matchesHijackGroup(reqPath) && req.method === "OPTIONS") { + return hijackAlertsRoute.handleOptions(req, res); } - // ── BGP Community Decoder ──────────────────────────────────── - if (reqPath === '/api/communities') { - 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); } - // ── IRR Audit ───────────────────────────────────────────────── - if (reqPath === '/api/irr-audit') { - 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-SET Expander ─────────────────────────────────────────── - if (reqPath === '/api/asset-expand') { - 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); } - // ── Routing History (prefix table via RIPE Stat routing-history) ── - if (reqPath === '/api/rpki-history') { - 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); } - // ── AS-PATH Visualizer (RIPE Stat looking-glass) ──────────────── - if (reqPath === '/api/aspath') { - 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); } - // ── Looking Glass (RIPE Stat) ───────────────────────────────── - if (reqPath === '/api/looking-glass') { - return proxyToApiServer(req, res, req.url); + // Hijack Events: Resolve. POST /api/hijacks/:id/resolve + if (reqPath.match(/^\/api\/hijacks\/[^/]+\/resolve$/) && req.method === "POST") { + return hijackAlertsRoute.handleHijackResolve(req, res, reqPath); } - // ── IXP Peering Matrix ──────────────────────────────────────── - if (reqPath === '/api/ix-matrix') { - return proxyToApiServer(req, res, req.url); + // Hijack Subscribe (legacy) + if (reqPath === "/api/hijack-subscribe" && req.method === "POST") { + return hijackAlertsRoute.handleHijackSubscribe(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 Alerts (legacy endpoint) + if (reqPath.startsWith("/api/hijack-alerts")) { + return hijackAlertsRoute.handleHijackAlertsLegacy(req, res); } - // ── 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; + // PDF Export + if (reqPath === "/api/export/pdf" && req.method === "GET") { + return pdfExportRoute.handleExportPdf(req, res, url); } - // ── 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 })); + if (reqPath === "/api/export/pdf/compare" && req.method === "GET") { + return pdfExportRoute.handleExportPdfCompare(req, res, url); } - // ── 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 })); + if (reqPath.startsWith("/api/export/") && req.method === "OPTIONS") { + return pdfExportRoute.handleExportOptions(req, res); } - // ── 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; + // ASPA Adoption Tracker + if (reqPath === "/aspa-adoption") { + return aspaAdoptionRoute.handleDashboard(req, res); } - // ── 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 === "/api/aspa-adoption-stats" && req.method === "GET") { + return aspaAdoptionRoute.handleStats(req, res, url); } - // ── 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; + if (reqPath === "/api/aspa-adoption-stats/export" && req.method === "GET") { + return aspaAdoptionRoute.handleStatsExport(req, res, url); } - // ── 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/ipv6-adoption-stats" && req.method === "GET") { + return aspaAdoptionRoute.handleIpv6Stats(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 })); - } - - - // ── 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); 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/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 += `· ${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'); + } +} + +module.exports = { handleChangelog }; 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/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/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/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 };