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, };