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.
222 lines
10 KiB
JavaScript
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,
|
|
};
|