PeerCortex/src/routes/hijack-alerts.ts
Rene Fichtmueller 141911537d feat: wire up the 13 orphaned Fastify routes via internal API server proxy
The 2026-07-14 backend refactor (PR #1) moved 13 features' route logic
out of server.js into src/features/*/routes.ts as Fastify plugins, and
left "// Migrated to src/features/X/" comments behind -- but never
actually built or started anything that served those plugins. Found a
complete, unused Fastify app bootstrap already sitting at
src/api/server.ts (added in an earlier commit, 5554c1a, alongside the
BGP hijack/PDF-export/ASPA-adoption work) that imports and registers
all 16 route modules, including 3 (pdf-export, aspa-adoption,
hijack-alerts) with proper Postgres-backed implementations, tests, and
PDF templates -- more capable than the puppeteer-based inline version
kept from the production snapshot. Also confirmed there is no code
anywhere that calls initializeDatabase() or startApiServer(): this
Fastify server had never actually been run, not even once.

What this commit does:

- Fixes 6 real TypeScript compile errors blocking `tsc` from building
  the affected modules cleanly (all pre-existing, none introduced by
  this branch):
  - src/routes/hijack-alerts.ts: route generic was missing `Body` in
    its type param, so it didn't match the handler's own declared type
  - src/features/aspa-adoption/scheduler.ts: node-cron v4's
    ScheduledTask dropped `.destroy()` (relevant now that this branch
    already bumped node-cron 3->4 for the uuid CVE fix) -- `.stop()`
    alone is sufficient
  - src/features/{bgp-communities,rpki-history}/routes.ts:
    `response.json()` typed as `{}` under this @types/node version,
    cast to `any` to match this file's existing loose-typing style
  - src/features/pdf-export/cache-manager.ts: `NodeJS.Timer` ->
    `NodeJS.Timeout` (what setInterval/clearInterval actually use)
  - src/features/pdf-export/renderer.ts: this uses Playwright, not
    Puppeteer -- `timeout` isn't a page.pdf() option in Playwright,
    moved to page.setDefaultTimeout() before the call

- Adds src/api/index.ts as the actual entry point (there wasn't one).
  Calls initializeDatabase() before dynamically importing ./server.js,
  because src/routes/hijack-alerts.ts calls getDatabase() at module
  load time -- a static top-level import of ./server would have
  crashed on startup before initializeDatabase() ever ran. Verified
  this boots cleanly and serves real responses (changelog-data,
  ix-matrix, rib/prefix graceful-503, bgp-communities with a live
  RIPE Stat call) in local testing.

- Wires server.js to reverse-proxy the 11 genuinely-orphaned paths
  (submarine-cables, global-infra, communities, irr-audit,
  asset-expand, rpki-history, aspath, looking-glass, ix-matrix,
  changelog-data, rib/*, prefix-changes) to this new internal server
  via a plain http.request() proxy -- chosen over exposing a second
  Cloudflare Tunnel ingress rule so this stays a same-origin,
  internal-only change with no DNS/tunnel config to touch. Verified
  the proxy mechanism itself against a live instance of the new server.
  Deliberately did NOT proxy /api/webhooks, /api/hijacks, or
  /api/hijack-subscribe -- server.js already serves those paths
  inline (file-backed, live, working), and src/routes/hijack-alerts.ts
  registers the same path names against an empty Postgres table.
  Proxying would have silently shadowed working data with nothing.

- Default port 3102, not 3100: checked Erik and found 3100 already
  bound by an unrelated docker-proxy container. 3102 is free and
  reads as "the second peercortex port" next to the existing 3101.

- Adds a `peercortex-api` PM2 app to ecosystem.config.js on Erik
  (backed up first via rollback-standard.sh) -- config only, NOT
  started. Nothing is deployed to Erik as part of this commit: no
  dist/ upload, no `npm install` for fastify/pg on Erik, no `pm2
  start`. server.js and public/index.html on Erik are untouched.
2026-07-16 11:54:33 +02:00

265 lines
8.5 KiB
TypeScript

import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import { getDatabase } from '../lib/db'
import { HijackAlertsDatabaseClient } from '../features/hijack-alerts/db-client'
import { WebhookClient } from '../features/hijack-alerts/webhook-client'
import { checkForHijacks } from '../features/hijack-alerts/detector'
import crypto from 'crypto'
const db = new HijackAlertsDatabaseClient(getDatabase())
const webhookClient = new WebhookClient()
function generateSecretKey(): string {
return 'sk_' + crypto.randomBytes(32).toString('hex')
}
interface RegisterWebhookRequest {
endpoint_url: string
timeout_ms?: number
max_retries?: number
}
interface CreateHijackRequest {
asn: number
prefix: string
}
export async function hijackAlertsRoutes(fastify: FastifyInstance): Promise<void> {
// Register webhook subscription
fastify.post<{ Querystring: { asn: string }; Body: RegisterWebhookRequest }>(
'/webhooks',
async (request: FastifyRequest<{ Querystring: { asn: string }; Body: RegisterWebhookRequest }>, reply: FastifyReply) => {
try {
const asn = parseInt(request.query.asn, 10)
const body = request.body as RegisterWebhookRequest
if (isNaN(asn)) {
return reply.status(400).send({ error: 'Invalid ASN' })
}
if (!body.endpoint_url) {
return reply.status(400).send({ error: 'endpoint_url is required' })
}
const secretKey = generateSecretKey()
const webhook = await db.createWebhookSubscription(
{
asn,
endpoint_url: body.endpoint_url,
timeout_ms: body.timeout_ms,
max_retries: body.max_retries,
},
secretKey
)
return reply.status(201).send({
id: webhook.id,
asn: webhook.asn,
endpoint_url: webhook.endpoint_url,
secret_key: secretKey,
created_at: webhook.created_at,
active: webhook.active,
})
} catch (error) {
console.error('[Routes] Error registering webhook:', error)
return reply.status(500).send({ error: 'Failed to register webhook' })
}
}
)
// List webhooks for ASN
fastify.get<{ Querystring: { asn: string } }>(
'/webhooks',
async (request: FastifyRequest<{ Querystring: { asn: string } }>, reply: FastifyReply) => {
try {
const asn = parseInt(request.query.asn, 10)
if (isNaN(asn)) {
return reply.status(400).send({ error: 'Invalid ASN' })
}
const webhooks = await db.getWebhooksByAsn(asn)
return reply.send({
asn,
webhooks: webhooks.map((w) => ({
id: w.id,
endpoint_url: w.endpoint_url,
active: w.active,
failure_count: w.failure_count,
last_triggered_at: w.last_triggered_at,
max_retries: w.max_retries,
timeout_ms: w.timeout_ms,
})),
})
} catch (error) {
console.error('[Routes] Error listing webhooks:', error)
return reply.status(500).send({ error: 'Failed to list webhooks' })
}
}
)
// Delete webhook subscription
fastify.delete<{ Params: { id: string } }>(
'/webhooks/:id',
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
try {
const webhookId = parseInt(request.params.id, 10)
if (isNaN(webhookId)) {
return reply.status(400).send({ error: 'Invalid webhook ID' })
}
await db.deleteWebhookSubscription(webhookId)
return reply.send({ deleted: true, id: webhookId })
} catch (error) {
console.error('[Routes] Error deleting webhook:', error)
return reply.status(500).send({ error: 'Failed to delete webhook' })
}
}
)
// Test webhook delivery
fastify.post<{ Params: { id: string } }>(
'/webhooks/:id/test',
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
try {
const webhookId = parseInt(request.params.id, 10)
if (isNaN(webhookId)) {
return reply.status(400).send({ error: 'Invalid webhook ID' })
}
const webhook = await db.getWebhookSubscription(webhookId)
if (!webhook) {
return reply.status(404).send({ error: 'Webhook not found' })
}
const testEvent = {
id: 0,
asn: webhook.asn,
prefix: '0.0.0.0/0',
detected_at: new Date(),
expected_asn: webhook.asn,
detected_asns: [webhook.asn],
hijack_type: 'HIJACK' as const,
severity: 'HIGH' as const,
description: 'Test event from PeerCortex',
details: { source: 'api-test', timestamp: new Date().toISOString() },
resolved: false,
resolved_at: null,
created_at: new Date(),
}
const result = await webhookClient.sendWebhook(
testEvent,
webhook.endpoint_url,
webhook.secret_key,
webhook.timeout_ms
)
return reply.send({
success: result.success,
status: result.status,
error: result.error ?? null,
response_time_ms: result.response_time_ms,
})
} catch (error) {
console.error('[Routes] Error testing webhook:', error)
return reply.status(500).send({ error: 'Failed to test webhook' })
}
}
)
// List hijack events for ASN
fastify.get<{ Querystring: { asn: string; limit?: string; offset?: string; resolved?: string } }>(
'/hijacks',
async (request: FastifyRequest<{ Querystring: { asn: string; limit?: string; offset?: string; resolved?: string } }>, reply: FastifyReply) => {
try {
const asn = parseInt(request.query.asn, 10)
const limit = parseInt(request.query.limit ?? '50', 10)
const offset = parseInt(request.query.offset ?? '0', 10)
const resolved = request.query.resolved ? request.query.resolved === 'true' : undefined
if (isNaN(asn)) {
return reply.status(400).send({ error: 'Invalid ASN' })
}
const result = await db.getHijacksByAsn(asn, Math.min(limit, 500), offset, resolved)
return reply.send({
asn,
total: result.total,
limit,
offset,
events: result.events.map((e) => ({
id: e.id,
prefix: e.prefix,
hijack_type: e.hijack_type,
severity: e.severity,
detected_at: e.detected_at,
resolved: e.resolved,
resolved_at: e.resolved_at,
description: e.description,
})),
})
} catch (error) {
console.error('[Routes] Error listing hijacks:', error)
return reply.status(500).send({ error: 'Failed to list hijacks' })
}
}
)
// Manually trigger hijack detection
fastify.post<{ Body: CreateHijackRequest }>(
'/hijacks/detect',
async (request: FastifyRequest<{ Body: CreateHijackRequest }>, reply: FastifyReply) => {
try {
const body = request.body as CreateHijackRequest
if (!body.asn || !body.prefix) {
return reply.status(400).send({ error: 'asn and prefix are required' })
}
const results = await checkForHijacks(`${body.asn}:${body.prefix}`, db)
if (results[0]?.detected && results[0]?.event) {
const hijack = await db.insertHijackEvent(results[0].event)
return reply.status(201).send({ detected: true, event: hijack })
}
return reply.send({ detected: false, reason: results[0]?.reason ?? 'No hijack detected' })
} catch (error) {
console.error('[Routes] Error detecting hijacks:', error)
return reply.status(500).send({ error: 'Failed to detect hijacks' })
}
}
)
// Resolve hijack
fastify.post<{ Params: { id: string }; Body: { resolution_notes: string } }>(
'/hijacks/:id/resolve',
async (request: FastifyRequest<{ Params: { id: string }; Body: { resolution_notes: string } }>, reply: FastifyReply) => {
try {
const eventId = parseInt(request.params.id, 10)
if (isNaN(eventId)) {
return reply.status(400).send({ error: 'Invalid event ID' })
}
const hijack = await db.resolveHijack(eventId, request.body.resolution_notes)
return reply.send({
id: hijack.id,
resolved: hijack.resolved,
resolved_at: hijack.resolved_at,
})
} catch (error) {
console.error('[Routes] Error resolving hijack:', error)
return reply.status(500).send({ error: 'Failed to resolve hijack' })
}
}
)
}