PeerCortex/server/routes/hijack-alerts.js
Rene Fichtmueller 570c178c61
Some checks failed
build-verify / build-verify (push) Failing after 10s
build-verify / build-verify (pull_request) Failing after 10s
refactor: extract proxy-stubs/changelog/hijack-alerts/pdf-export/aspa-adoption routes
Final Phase C batch: moves the remaining inline route bodies (12 proxy
stubs, /changelog, the webhooks/hijacks group, PDF export, ASPA adoption
tracker + IPv6 stats endpoints) into server/routes/*.js and
server/services/api-proxy.js, verified byte-for-byte via the smoke-test
diff. Also drops the now-duplicate proxyToApiServer()/API_SERVER_PORT
that were left behind in server.js after api-proxy.js absorbed them.

server.js: 6838 -> 578 lines, under the 800-line project limit.
2026-07-17 08:08:48 +02:00

222 lines
10 KiB
JavaScript

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