- Deterministic Classification: MOAS/HIJACK/LEAK type detection - Severity scoring: CRITICAL/HIGH/MEDIUM/LOW based on prefix length - Optional Ollama enrichment (qwen2.5:3b) for CRITICAL only (5s timeout) - PostgreSQL backend: hijack_events, webhook_subscriptions, webhook_deliveries - HMAC-SHA256 webhook signing with exponential backoff retry - Retry scheduler: node-cron job every 5 minutes - 6 API endpoints: POST/GET/DELETE webhooks, test delivery, list/resolve hijacks - 22 comprehensive tests (80%+ coverage) - Zero external API costs (deterministic + local Ollama only)
265 lines
8.5 KiB
TypeScript
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 } }>(
|
|
'/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' })
|
|
}
|
|
}
|
|
)
|
|
}
|