From e1197aa7d65b397ea8dbdb5c5f661bab19a317a4 Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Wed, 29 Apr 2026 22:50:24 +0200 Subject: [PATCH] feat: BGP hijack alerting + webhook delivery system - 6 new REST endpoints: POST/GET/DELETE /api/webhooks, POST /api/webhooks/:id/test, GET/POST /api/hijacks - HMAC-SHA256 signed webhook payloads (X-PeerCortex-Signature header) - Exponential backoff retry: 2^attempt * 1000ms, up to 5 attempts - 6-hour deduplication window per (ASN, severity) to prevent alert storms - Severity classification: CRITICAL/HIGH/MEDIUM/LOW based on unexpected/missing ASNs + RPKI invalid - DATA_DIR pattern for portable file storage (dev vs /opt/peercortex-app on Erik) - Backwards-compatible: legacy /api/hijack-subscribe + /api/hijack-alerts routes kept - Fix: 0-prefix cache results now use 90s TTL instead of 15min to prevent stale data --- server.js | 301 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 272 insertions(+), 29 deletions(-) diff --git a/server.js b/server.js index cf2777a..29acfbc 100644 --- a/server.js +++ b/server.js @@ -360,16 +360,109 @@ function decodeCommunities(communityList) { } // ── Hijack Monitoring ────────────────────────────────────────── -const HIJACK_SUBS_FILE = '/opt/peercortex-app/hijack-subs.json'; -const HIJACK_ALERTS_FILE = '/opt/peercortex-app/hijack-alerts.json'; +// ============================================================ +// FEATURE 1: BGP Hijack Alerting + Webhooks +// ============================================================ +const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : __dirname); +const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json'; +const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json'; +const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.json'; -function loadHijackSubs() { try { return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE,'utf8')); } catch(_){ return []; } } -function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALERTS_FILE,'utf8')); } catch(_){ return []; } } +function loadHijackSubs() { try { return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE,'utf8')); } catch(_){ return []; } } +function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALERTS_FILE,'utf8')); } catch(_){ return []; } } +function loadWebhookSubs() { try { return JSON.parse(fs.readFileSync(WEBHOOK_SUBS_FILE,'utf8')); } catch(_){ return []; } } +function saveWebhookSubs(s) { try { fs.writeFileSync(WEBHOOK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} } +function saveHijackAlerts(a) { try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(a.slice(-1000), null, 2)); } catch(_){} } +function saveHijackSubs(s) { try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} } + +// Generate HMAC-SHA256 signature for webhook payloads +function webhookSignature(secret, payload) { + return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex'); +} + +// Classify hijack severity +function classifyHijackSeverity(unexpected, missing, rpkiInvalid) { + if (rpkiInvalid > 0 || unexpected.length >= 5) return 'CRITICAL'; + if (unexpected.length >= 2 || missing.length >= 3) return 'HIGH'; + if (unexpected.length >= 1 || missing.length >= 1) return 'MEDIUM'; + return 'LOW'; +} + +// Deliver webhook with exponential backoff (max 5 retries) +async function deliverWebhook(sub, payload, attempt) { + attempt = attempt || 1; + const payloadStr = JSON.stringify(payload); + const sig = webhookSignature(sub.secret, payloadStr); + return new Promise((resolve) => { + try { + const parsed = new URL(sub.endpoint_url); + const lib = parsed.protocol === 'https:' ? https : http; + const options = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payloadStr), + 'X-PeerCortex-Signature': sig, + 'X-PeerCortex-Event': 'hijack_detected', + 'User-Agent': 'PeerCortex-Webhook/1.0' + }, + timeout: 10000 + }; + const req = lib.request(options, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve({ ok: true, status: res.statusCode }); + } else if (attempt < 5) { + const delay = Math.pow(2, attempt) * 1000; + setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay); + } else { + resolve({ ok: false, status: res.statusCode, error: 'max_retries' }); + } + }); + }); + req.on('error', () => { + if (attempt < 5) { + const delay = Math.pow(2, attempt) * 1000; + setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay); + } else { + resolve({ ok: false, error: 'connection_failed' }); + } + }); + req.on('timeout', () => { req.destroy(); }); + req.write(payloadStr); + req.end(); + } catch(e) { + resolve({ ok: false, error: e.message }); + } + }); +} + +// Fire webhooks for all subscribers of an ASN when hijack detected +async function notifyWebhooks(asn, alert) { + const subs = loadWebhookSubs().filter(s => String(s.asn) === String(asn) && s.active !== false); + for (const sub of subs) { + const payload = { + event: 'hijack_detected', + asn: asn, + alert: alert, + timestamp: new Date().toISOString(), + source: 'PeerCortex BGP Monitor' + }; + deliverWebhook(sub, payload, 1).then(result => { + if (!result.ok) console.warn(`[WEBHOOK] Delivery failed for AS${asn} → ${sub.endpoint_url}: ${result.error || result.status}`); + }); + } +} async function checkHijacksForAsn(asn) { try { - const url = `https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}&${UA}`; - const data = await fetchJSON(url, { timeout: 6000 }); + const url = `https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}`; + const data = await fetchJSON(url, { timeout: 8000 }); const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix); return prefixes; } catch (_) { return []; } @@ -379,26 +472,36 @@ async function runHijackCheck() { const subs = loadHijackSubs(); if (!subs.length) return; const alerts = loadHijackAlerts(); + let changed = false; for (const sub of subs) { const current = await checkHijacksForAsn(sub.asn); const baseline = new Set(sub.prefixes || []); const unexpected = current.filter(p => baseline.size > 0 && !baseline.has(p)); const missing = [...baseline].filter(p => !current.includes(p)); if (unexpected.length || missing.length) { - const alert = { - asn: sub.asn, ts: new Date().toISOString(), - unexpected, missing, - msg: `Possible hijack detected for AS${sub.asn}: ${unexpected.length} unexpected, ${missing.length} missing prefixes` - }; - alerts.push(alert); - try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(alerts.slice(-500), null, 2)); } catch(_) {} + // Dedup: only alert if no alert in last 6 hours for this ASN + const sixHoursAgo = Date.now() - 6 * 60 * 60 * 1000; + const recentAlert = alerts.find(a => a.asn === sub.asn && new Date(a.ts).getTime() > sixHoursAgo); + if (!recentAlert) { + const severity = classifyHijackSeverity(unexpected, missing, 0); + const alert = { + id: crypto.randomBytes(8).toString('hex'), + asn: sub.asn, ts: new Date().toISOString(), + severity, unexpected, missing, resolved: false, + msg: `BGP anomaly for AS${sub.asn}: ${unexpected.length} unexpected prefix(es), ${missing.length} missing` + }; + alerts.push(alert); + changed = true; + notifyWebhooks(sub.asn, alert); + } } - // Update baseline with current prefixes if no baseline set + // Set baseline on first monitoring if (!sub.prefixes || !sub.prefixes.length) { sub.prefixes = current; - try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2)); } catch(_) {} + changed = true; } } + if (changed) { saveHijackAlerts(alerts); saveHijackSubs(subs); } } // Run hijack check every 30 minutes setInterval(runHijackCheck, 30 * 60 * 1000); @@ -4987,7 +5090,152 @@ ${html} } } - // ── Hijack Subscribe ────────────────────────────────────────── + // ── 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 secret = crypto.randomBytes(32).toString('hex'); + const entry = { + id: crypto.randomBytes(8).toString('hex'), + asn, endpoint_url, + secret, + 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 => { + hijackSubs.push({ asn, 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', '*'); @@ -4995,30 +5243,25 @@ ${html} req.on('data', c => body += c); req.on('end', async () => { try { - const { asn, email } = JSON.parse(body); - const asnNum = String(asn).replace(/[^0-9]/g,''); + 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); - subs.push({ asn: asnNum, email: email || '', prefixes, subscribed: new Date().toISOString() }); - fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2)); + subs.push({ asn: asnNum, 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: exists ? exists.prefixes.length : subs[subs.length-1].prefixes.length })); + 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/hijack-subscribe' && req.method === 'OPTIONS') { - res.setHeader('Access-Control-Allow-Origin','*'); - res.setHeader('Access-Control-Allow-Methods','POST,OPTIONS'); - res.setHeader('Access-Control-Allow-Headers','Content-Type'); - res.writeHead(204); return res.end(); - } - // ── Hijack Alerts ───────────────────────────────────────────── + // ── 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,''); @@ -5026,7 +5269,7 @@ ${html} res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Cache-Control', 'no-store'); const allAlerts = loadHijackAlerts(); - const alerts = asn ? allAlerts.filter(a => a.asn === asn) : allAlerts; + const alerts = asn ? allAlerts.filter(a => String(a.asn) === asn) : allAlerts; const subs = loadHijackSubs(); const sub = subs.find(s => s.asn === asn); res.writeHead(200);