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.
This commit is contained in:
parent
49aa46fef3
commit
570c178c61
573
server.js
573
server.js
@ -179,40 +179,6 @@ const { computeResilienceScore } = require("./server/resilience-score");
|
|||||||
const { computeRouteLeakDetection } = require("./server/route-leak");
|
const { computeRouteLeakDetection } = require("./server/route-leak");
|
||||||
const { fetchWhois } = require("./server/whois");
|
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
|
// HTTP Server
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -236,6 +202,11 @@ const ixDetailRoute = require("./server/routes/ix-detail");
|
|||||||
const topologyRoute = require("./server/routes/topology");
|
const topologyRoute = require("./server/routes/topology");
|
||||||
const whoisRoute = require("./server/routes/whois");
|
const whoisRoute = require("./server/routes/whois");
|
||||||
const enrichRoute = require("./server/routes/enrich");
|
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) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
@ -399,526 +370,92 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Feature 28: Submarine Cable overlay (TeleGeography proxy)
|
// Feature 28: Submarine Cable overlay + other already-migrated routes,
|
||||||
// ── Submarine Cables map data ─────────────────────────────────
|
// proxied through to the separate Fastify API server.
|
||||||
if (reqPath === '/api/submarine-cables') {
|
if (proxyStubsRoute.isProxyStubPath(reqPath)) {
|
||||||
return proxyToApiServer(req, res, req.url);
|
return proxyStubsRoute.handleProxyStub(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Global datacenter/IXP map (PeeringDB proxy) ───────────────
|
// Changelog page
|
||||||
if (reqPath === '/api/global-infra') {
|
if (reqPath === "/changelog") {
|
||||||
return proxyToApiServer(req, res, req.url);
|
return changelogRoute.handleChangelog(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CORS preflight for all /api/webhooks + /api/hijacks
|
||||||
// ── Changelog page ─────────────────────────────────────────
|
if (hijackAlertsRoute.matchesHijackGroup(reqPath) && req.method === "OPTIONS") {
|
||||||
if (reqPath === '/changelog') {
|
return hijackAlertsRoute.handleOptions(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 += `<h2 style="font-family:var(--serif);font-size:1.4rem;font-weight:800;margin:2rem 0 .5rem;border-top:2px solid var(--text);padding-top:1rem">${line.slice(3)}</h2>`;
|
|
||||||
} else if (line.startsWith('### ')) {
|
|
||||||
html += `<h3 style="font-family:var(--body);font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:1rem 0 .4rem">${line.slice(4)}</h3>`;
|
|
||||||
} else if (line.startsWith('- **')) {
|
|
||||||
const m = line.replace(/^- \*\*(.+?)\*\*(.*)$/, '<strong>$1</strong>$2');
|
|
||||||
html += `<p style="font-family:var(--body);font-size:.85rem;margin:.2rem 0;padding-left:1rem;border-left:2px solid var(--border)">· ${m}</p>`;
|
|
||||||
} else if (line.startsWith('- ')) {
|
|
||||||
html += `<p style="font-family:var(--body);font-size:.82rem;margin:.15rem 0;color:var(--muted);padding-left:1rem">· ${line.slice(2)}</p>`;
|
|
||||||
} else if (line.startsWith('# ')) {
|
|
||||||
html += `<h1 style="font-family:var(--serif);font-size:2rem;font-weight:900;margin-bottom:.25rem">${line.slice(2)}</h1>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const page = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>PeerCortex Changelog</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root{--bg:#F5F2EC;--text:#1C1917;--muted:#57534E;--border:#C9C3B6;--serif:'Playfair Display',Georgia,serif;--body:'Source Serif 4',Georgia,serif;--mono:'IBM Plex Mono',monospace;--purple:#B83A1B}
|
|
||||||
body{font-family:var(--body);background:var(--bg);color:var(--text);max-width:760px;margin:0 auto;padding:2rem}
|
|
||||||
a{color:var(--purple);text-decoration:none}
|
|
||||||
.back{font-family:var(--mono);font-size:.72rem;color:var(--muted);margin-bottom:2rem;display:block}
|
|
||||||
body.dark{--bg:#0f0f0f;--text:#e8e4dc;--muted:#a09890;--border:#333}
|
|
||||||
</style></head><body>
|
|
||||||
<a href="/" class="back">← peercortex.org</a>
|
|
||||||
${html}
|
|
||||||
<p style="margin-top:3rem;font-family:var(--mono);font-size:.6rem;color:var(--muted)">PeerCortex · v0.5.0 · Open Source · MIT</p>
|
|
||||||
</body></html>`;
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BGP Community Decoder ────────────────────────────────────
|
// Webhook: Register. POST /api/webhooks?asn=X
|
||||||
if (reqPath === '/api/communities') {
|
if (reqPath === "/api/webhooks" && req.method === "POST") {
|
||||||
return proxyToApiServer(req, res, req.url);
|
return hijackAlertsRoute.handleWebhookRegister(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── IRR Audit ─────────────────────────────────────────────────
|
// Webhook: List. GET /api/webhooks?asn=X
|
||||||
if (reqPath === '/api/irr-audit') {
|
if (reqPath === "/api/webhooks" && req.method === "GET") {
|
||||||
return proxyToApiServer(req, res, req.url);
|
return hijackAlertsRoute.handleWebhookList(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AS-SET Expander ───────────────────────────────────────────
|
// Webhook: Delete. DELETE /api/webhooks/:id
|
||||||
if (reqPath === '/api/asset-expand') {
|
if (reqPath.startsWith("/api/webhooks/") && req.method === "DELETE" && !reqPath.includes("/test")) {
|
||||||
return proxyToApiServer(req, res, req.url);
|
return hijackAlertsRoute.handleWebhookDelete(req, res, reqPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Routing History (prefix table via RIPE Stat routing-history) ──
|
// Webhook: Test. POST /api/webhooks/:id/test
|
||||||
if (reqPath === '/api/rpki-history') {
|
if (reqPath.match(/^\/api\/webhooks\/[^/]+\/test$/) && req.method === "POST") {
|
||||||
return proxyToApiServer(req, res, req.url);
|
return hijackAlertsRoute.handleWebhookTest(req, res, reqPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AS-PATH Visualizer (RIPE Stat looking-glass) ────────────────
|
// Hijack Events: List. GET /api/hijacks?asn=X&limit=50&resolved=false
|
||||||
if (reqPath === '/api/aspath') {
|
if (reqPath === "/api/hijacks" && req.method === "GET") {
|
||||||
return proxyToApiServer(req, res, req.url);
|
return hijackAlertsRoute.handleHijacksList(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Looking Glass (RIPE Stat) ─────────────────────────────────
|
// Hijack Events: Resolve. POST /api/hijacks/:id/resolve
|
||||||
if (reqPath === '/api/looking-glass') {
|
if (reqPath.match(/^\/api\/hijacks\/[^/]+\/resolve$/) && req.method === "POST") {
|
||||||
return proxyToApiServer(req, res, req.url);
|
return hijackAlertsRoute.handleHijackResolve(req, res, reqPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── IXP Peering Matrix ────────────────────────────────────────
|
// Hijack Subscribe (legacy)
|
||||||
if (reqPath === '/api/ix-matrix') {
|
if (reqPath === "/api/hijack-subscribe" && req.method === "POST") {
|
||||||
return proxyToApiServer(req, res, req.url);
|
return hijackAlertsRoute.handleHijackSubscribe(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hijack Alerts (legacy endpoint)
|
||||||
// ── CORS preflight for all /api/webhooks + /api/hijacks ──────
|
if (reqPath.startsWith("/api/hijack-alerts")) {
|
||||||
if ((reqPath.startsWith('/api/webhooks') || reqPath.startsWith('/api/hijacks') || reqPath === '/api/hijack-subscribe') && req.method === 'OPTIONS') {
|
return hijackAlertsRoute.handleHijackAlertsLegacy(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 ─────────────────────────────────────────
|
// PDF Export
|
||||||
// POST /api/webhooks?asn=X body: { endpoint_url, timeout_ms?, max_retries? }
|
if (reqPath === "/api/export/pdf" && req.method === "GET") {
|
||||||
if (reqPath === '/api/webhooks' && req.method === 'POST') {
|
return pdfExportRoute.handleExportPdf(req, res, url);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Webhook: List ─────────────────────────────────────────────
|
if (reqPath === "/api/export/pdf/compare" && req.method === "GET") {
|
||||||
// GET /api/webhooks?asn=X
|
return pdfExportRoute.handleExportPdfCompare(req, res, url);
|
||||||
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 ───────────────────────────────────────────
|
if (reqPath.startsWith("/api/export/") && req.method === "OPTIONS") {
|
||||||
// DELETE /api/webhooks/:id
|
return pdfExportRoute.handleExportOptions(req, res);
|
||||||
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 ─────────────────────────────────────────────
|
// ASPA Adoption Tracker
|
||||||
// POST /api/webhooks/:id/test
|
if (reqPath === "/aspa-adoption") {
|
||||||
if (reqPath.match(/^\/api\/webhooks\/[^/]+\/test$/) && req.method === 'POST') {
|
return aspaAdoptionRoute.handleDashboard(req, res);
|
||||||
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 ───────────────────────────────────────
|
if (reqPath === "/api/aspa-adoption-stats" && req.method === "GET") {
|
||||||
// GET /api/hijacks?asn=X&limit=50&resolved=false
|
return aspaAdoptionRoute.handleStats(req, res, url);
|
||||||
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 ────────────────────────────────────
|
if (reqPath === "/api/aspa-adoption-stats/export" && req.method === "GET") {
|
||||||
// POST /api/hijacks/:id/resolve body: { resolution_notes? }
|
return aspaAdoptionRoute.handleStatsExport(req, res, url);
|
||||||
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/ipv6-adoption-stats" && req.method === "GET") {
|
||||||
if (reqPath === '/api/hijack-subscribe' && req.method === 'POST') {
|
return aspaAdoptionRoute.handleIpv6Stats(req, res, url);
|
||||||
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) ─
|
|
||||||
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
|
// 404
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
|
|||||||
114
server/routes/aspa-adoption.js
Normal file
114
server/routes/aspa-adoption.js
Normal file
@ -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 };
|
||||||
46
server/routes/changelog.js
Normal file
46
server/routes/changelog.js
Normal file
@ -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 += `<h2 style="font-family:var(--serif);font-size:1.4rem;font-weight:800;margin:2rem 0 .5rem;border-top:2px solid var(--text);padding-top:1rem">${line.slice(3)}</h2>`;
|
||||||
|
} else if (line.startsWith('### ')) {
|
||||||
|
html += `<h3 style="font-family:var(--body);font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:1rem 0 .4rem">${line.slice(4)}</h3>`;
|
||||||
|
} else if (line.startsWith('- **')) {
|
||||||
|
const m = line.replace(/^- \*\*(.+?)\*\*(.*)$/, '<strong>$1</strong>$2');
|
||||||
|
html += `<p style="font-family:var(--body);font-size:.85rem;margin:.2rem 0;padding-left:1rem;border-left:2px solid var(--border)">· ${m}</p>`;
|
||||||
|
} else if (line.startsWith('- ')) {
|
||||||
|
html += `<p style="font-family:var(--body);font-size:.82rem;margin:.15rem 0;color:var(--muted);padding-left:1rem">· ${line.slice(2)}</p>`;
|
||||||
|
} else if (line.startsWith('# ')) {
|
||||||
|
html += `<h1 style="font-family:var(--serif);font-size:2rem;font-weight:900;margin-bottom:.25rem">${line.slice(2)}</h1>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>PeerCortex Changelog</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{--bg:#F5F2EC;--text:#1C1917;--muted:#57534E;--border:#C9C3B6;--serif:'Playfair Display',Georgia,serif;--body:'Source Serif 4',Georgia,serif;--mono:'IBM Plex Mono',monospace;--purple:#B83A1B}
|
||||||
|
body{font-family:var(--body);background:var(--bg);color:var(--text);max-width:760px;margin:0 auto;padding:2rem}
|
||||||
|
a{color:var(--purple);text-decoration:none}
|
||||||
|
.back{font-family:var(--mono);font-size:.72rem;color:var(--muted);margin-bottom:2rem;display:block}
|
||||||
|
body.dark{--bg:#0f0f0f;--text:#e8e4dc;--muted:#a09890;--border:#333}
|
||||||
|
</style></head><body>
|
||||||
|
<a href="/" class="back">← peercortex.org</a>
|
||||||
|
${html}
|
||||||
|
<p style="margin-top:3rem;font-family:var(--mono);font-size:.6rem;color:var(--muted)">PeerCortex · v0.5.0 · Open Source · MIT</p>
|
||||||
|
</body></html>`;
|
||||||
|
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 };
|
||||||
221
server/routes/hijack-alerts.js
Normal file
221
server/routes/hijack-alerts.js
Normal file
@ -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,
|
||||||
|
};
|
||||||
112
server/routes/pdf-export.js
Normal file
112
server/routes/pdf-export.js
Normal file
@ -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 };
|
||||||
30
server/routes/proxy-stubs.js
Normal file
30
server/routes/proxy-stubs.js
Normal file
@ -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 };
|
||||||
34
server/services/api-proxy.js
Normal file
34
server/services/api-proxy.js
Normal file
@ -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 };
|
||||||
Loading…
x
Reference in New Issue
Block a user