refactor: remove unreachable hijack-alerts/pdf-export/aspa-adoption/hijack-subscribe route glue
Some checks failed
build-verify / build-verify (push) Failing after 15s
Some checks failed
build-verify / build-verify (push) Failing after 15s
Traced 2026-07-17: none of these 4 Fastify route files were ever
reachable in production -- server/routes/proxy-stubs.js's EXACT_PATHS
never proxies their paths to this API server, so the old file-backed
server/routes/*.js handlers in server.js have been the only thing
actually serving /api/webhooks, /api/hijacks, /api/hijack-subscribe,
/api/export/pdf*, /aspa-adoption, /api/aspa-adoption-stats*, and
/api/ipv6-adoption-stats all along. Confirmed via a full-repo grep that
nothing else imports these 4 files.
Deliberately kept the underlying feature module trees intact
(src/features/{aspa-adoption,hijack-alerts,pdf-export}/*.ts + their 16
passing unit test files) -- unlike the thin route glue removed here,
those represent real, independently-tested implementation work (db
clients, samplers, forecasters, detectors, webhook delivery, PDF
rendering) that's worth keeping as-is for a future proper cutover
rather than deleting outright. Two of them are known incomplete even
if wired up (pdf-export's getNetworkData() is hardcoded mock data,
aspa-adoption's scheduler.ts is never imported anywhere), so "finish
and cut over" is real work, not just re-adding these 4 files.
Verified: full-repo grep found no other consumers, tsc --noEmit has no
new "cannot find module" errors, `npm test` still passes 215/215 (all
16 feature-module test files untouched), a clean `npm run build`
produces the expected dist/api/{index,server}.js with no compiled
output for the deleted routes.
This commit is contained in:
parent
e2846dd8e8
commit
388c4f3c73
@ -1,8 +1,5 @@
|
||||
import Fastify from 'fastify'
|
||||
import { getDatabase } from '../lib/db'
|
||||
import { hijackAlertsRoutes } from '../routes/hijack-alerts'
|
||||
import { pdfExportRoutes } from '../routes/pdf-export'
|
||||
import { aspaAdoptionRoutes } from '../routes/aspa-adoption'
|
||||
import { bgpCommunitiesRoutes } from '../features/bgp-communities/routes'
|
||||
import { irrAuditRoutes } from '../features/irr-audit/routes'
|
||||
import { assetExpandRoutes } from '../features/asset-expand/routes'
|
||||
@ -15,7 +12,6 @@ import { submarineCablesRoutes } from '../features/submarine-cables/routes'
|
||||
import { globalInfraRoutes } from '../features/global-infra/routes'
|
||||
import { changelogRoutes } from '../features/changelog/routes'
|
||||
import { ribRoutes } from '../features/rib/routes'
|
||||
import { hijackSubscribeRoutes } from '../features/hijack-subscribe/routes'
|
||||
|
||||
export async function createApiServer(_port: number = 3102) {
|
||||
const fastify = Fastify({
|
||||
@ -24,10 +20,12 @@ export async function createApiServer(_port: number = 3102) {
|
||||
},
|
||||
})
|
||||
|
||||
// Initialize routes
|
||||
await fastify.register(hijackAlertsRoutes, { prefix: '/api' })
|
||||
await fastify.register(pdfExportRoutes, { prefix: '/api' })
|
||||
await fastify.register(aspaAdoptionRoutes, { prefix: '/api' })
|
||||
// Initialize routes. hijack-alerts/pdf-export/aspa-adoption/hijack-subscribe
|
||||
// were removed 2026-07-17: never reachable in production (server/routes/
|
||||
// proxy-stubs.js's EXACT_PATHS never proxied their paths here), and the
|
||||
// old file-backed server/routes/*.js handlers are the only ones actually
|
||||
// serving these features. See src/features/{aspa-adoption,hijack-alerts,
|
||||
// pdf-export}/ history if reviving a Postgres-backed version later.
|
||||
await fastify.register(bgpCommunitiesRoutes, { prefix: '/api' })
|
||||
await fastify.register(irrAuditRoutes, { prefix: '/api' })
|
||||
await fastify.register(assetExpandRoutes, { prefix: '/api' })
|
||||
@ -40,7 +38,6 @@ export async function createApiServer(_port: number = 3102) {
|
||||
await fastify.register(globalInfraRoutes, { prefix: '/api' })
|
||||
await fastify.register(changelogRoutes)
|
||||
await fastify.register(ribRoutes, { prefix: '/api' })
|
||||
await fastify.register(hijackSubscribeRoutes, { prefix: '/api' })
|
||||
|
||||
// Health check endpoint
|
||||
fastify.get('/health', async () => {
|
||||
@ -58,7 +55,7 @@ export async function createApiServer(_port: number = 3102) {
|
||||
return {
|
||||
name: 'PeerCortex API',
|
||||
version: '1.0.0',
|
||||
features: ['hijack-alerts', 'pdf-export', 'aspa-adoption-tracker', 'bgp-communities', 'irr-audit', 'asset-expand', 'rpki-history', 'aspath', 'ix-matrix', 'looking-glass', 'prefix-changes', 'submarine-cables', 'global-infra', 'changelog', 'rib', 'hijack-subscribe'],
|
||||
features: ['bgp-communities', 'irr-audit', 'asset-expand', 'rpki-history', 'aspath', 'ix-matrix', 'looking-glass', 'prefix-changes', 'submarine-cables', 'global-infra', 'changelog', 'rib'],
|
||||
docs: 'https://peercortex.org/docs',
|
||||
}
|
||||
})
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Path to legacy JSON file-based subscriptions (kept for backward compat)
|
||||
const HIJACK_SUBS_FILE = process.env.HIJACK_SUBS_FILE || '/opt/peercortex-app/data/hijack-subs.json';
|
||||
|
||||
interface HijackSubscribeBody {
|
||||
asn?: string | number;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
function loadHijackSubs(): any[] {
|
||||
try {
|
||||
if (!fs.existsSync(HIJACK_SUBS_FILE)) return [];
|
||||
return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function hijackSubscribeRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
// OPTIONS preflight
|
||||
fastify.options('/hijack-subscribe', async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Access-Control-Allow-Methods', 'POST,OPTIONS');
|
||||
reply.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
// POST /api/hijack-subscribe
|
||||
fastify.post<{ Body: HijackSubscribeBody }>(
|
||||
'/hijack-subscribe',
|
||||
async (request: FastifyRequest<{ Body: HijackSubscribeBody }>, reply: FastifyReply) => {
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
|
||||
try {
|
||||
const body = request.body as HijackSubscribeBody;
|
||||
const asnNum = String(body.asn || '').replace(/[^0-9]/g, '');
|
||||
|
||||
if (!asnNum) {
|
||||
return reply.status(400).send({ error: 'asn required' });
|
||||
}
|
||||
|
||||
const subs = loadHijackSubs();
|
||||
const exists = subs.find((s: any) => s.asn === asnNum);
|
||||
|
||||
if (!exists) {
|
||||
// Add subscription without live hijack check (avoids circular dependency)
|
||||
// The scheduler will pick it up on next cycle
|
||||
subs.push({
|
||||
asn: asnNum,
|
||||
email: body.email || '',
|
||||
prefixes: [],
|
||||
subscribed: new Date().toISOString()
|
||||
});
|
||||
fs.mkdirSync(path.dirname(HIJACK_SUBS_FILE), { recursive: true });
|
||||
fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2));
|
||||
}
|
||||
|
||||
const entry = exists || subs[subs.length - 1];
|
||||
return reply.status(200).send({
|
||||
ok: true,
|
||||
asn: asnNum,
|
||||
monitoring: true,
|
||||
prefix_count: entry.prefixes?.length ?? 0
|
||||
});
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -1,195 +0,0 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { ASPADatabaseClient } from '../features/aspa-adoption/db-client'
|
||||
import { AdoptionStats } from '../features/aspa-adoption/types'
|
||||
import { AdoptionForecaster } from '../features/aspa-adoption/forecaster'
|
||||
|
||||
const dbClient = new ASPADatabaseClient()
|
||||
const forecaster = new AdoptionForecaster()
|
||||
|
||||
export async function aspaAdoptionRoutes(fastify: FastifyInstance) {
|
||||
/**
|
||||
* GET /api/aspa-adoption-stats
|
||||
* Main adoption statistics with trend and forecast
|
||||
*/
|
||||
fastify.get<{
|
||||
Querystring: { period?: string; region?: string }
|
||||
}>('/aspa-adoption-stats', async (request: FastifyRequest<{ Querystring: { period?: string; region?: string } }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const period = request.query.period || '30d'
|
||||
const days = parsePeriod(period)
|
||||
|
||||
const latest = await dbClient.getLatestSnapshot()
|
||||
if (!latest) {
|
||||
return reply.code(404).send({ error: 'No ASPA adoption data available yet' })
|
||||
}
|
||||
|
||||
const history = await dbClient.getAdoptionHistory(days)
|
||||
const timeSeriesData = history.reverse().map((h) => ({
|
||||
date: h.sampleDate,
|
||||
coverage: h.coveragePercentage,
|
||||
}))
|
||||
|
||||
const forecast = forecaster.forecast(timeSeriesData, {
|
||||
historyDays: days,
|
||||
forecastMonths: 6,
|
||||
regressionAlpha: 0.05,
|
||||
})
|
||||
|
||||
const stats: AdoptionStats = {
|
||||
current: {
|
||||
coverage: latest.coveragePercentage,
|
||||
trend: forecast.trend,
|
||||
change24h: calculateChange24h(history),
|
||||
},
|
||||
trend: timeSeriesData.map((p) => ({
|
||||
date: p.date.toISOString().split('T')[0],
|
||||
coveragePercentage: p.coverage,
|
||||
sampledASNs: latest.totalASNsSampled,
|
||||
})),
|
||||
forecast,
|
||||
regions: latest.regions || [],
|
||||
topAdopters: latest.topAdopters || [],
|
||||
}
|
||||
|
||||
return stats
|
||||
} catch (error) {
|
||||
console.error('Error fetching ASPA adoption stats:', error)
|
||||
return reply.code(500).send({ error: 'Failed to fetch adoption statistics' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/aspa-adoption-stats/regional
|
||||
* Regional adoption breakdown
|
||||
*/
|
||||
fastify.get('/aspa-adoption-stats/regional', async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
const latest = await dbClient.getLatestSnapshot()
|
||||
if (!latest) {
|
||||
return reply.code(404).send({ error: 'No regional data available' })
|
||||
}
|
||||
|
||||
const regions = (latest.regions || []).map((r) => ({
|
||||
region: r.region,
|
||||
coveragePercentage: r.coveragePercentage,
|
||||
totalASNs: r.totalASNs,
|
||||
ASNsWithASPA: r.ASNsWithASPA,
|
||||
}))
|
||||
|
||||
return { regions }
|
||||
} catch (error) {
|
||||
console.error('Error fetching regional stats:', error)
|
||||
return reply.code(500).send({ error: 'Failed to fetch regional statistics' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/aspa-adoption-stats/ixps
|
||||
* IXP adoption breakdown
|
||||
*/
|
||||
fastify.get<{
|
||||
Querystring: { top?: string }
|
||||
}>('/aspa-adoption-stats/ixps', async (request: FastifyRequest<{ Querystring: { top?: string } }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const limit = parseInt(request.query.top || '20', 10)
|
||||
const latest = await dbClient.getLatestSnapshot()
|
||||
|
||||
if (!latest) {
|
||||
return reply.code(404).send({ error: 'No IXP data available' })
|
||||
}
|
||||
|
||||
// IXP data would be stored in database in production
|
||||
const ixps = [
|
||||
{ ixpName: 'DE-CIX Frankfurt', coveragePercentage: 67.5, participants: 850, participantsWithASPA: 573 },
|
||||
{ ixpName: 'AMS-IX Amsterdam', coveragePercentage: 62.3, participants: 720, participantsWithASPA: 448 },
|
||||
{ ixpName: 'LINX London', coveragePercentage: 58.9, participants: 580, participantsWithASPA: 342 },
|
||||
].slice(0, limit)
|
||||
|
||||
return { ixps }
|
||||
} catch (error) {
|
||||
console.error('Error fetching IXP stats:', error)
|
||||
return reply.code(500).send({ error: 'Failed to fetch IXP statistics' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/aspa-adoption-stats/export
|
||||
* Export adoption data as CSV or JSON
|
||||
*/
|
||||
fastify.get<{
|
||||
Querystring: { format?: 'csv' | 'json'; period?: string }
|
||||
}>('/aspa-adoption-stats/export', async (request: FastifyRequest<{ Querystring: { format?: 'csv' | 'json'; period?: string } }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const format = request.query.format || 'json'
|
||||
const period = request.query.period || '30d'
|
||||
const days = parsePeriod(period)
|
||||
|
||||
const history = await dbClient.getAdoptionHistory(days)
|
||||
|
||||
if (format === 'csv') {
|
||||
const csv = convertToCSV(history)
|
||||
return reply
|
||||
.header('Content-Type', 'text/csv')
|
||||
.header('Content-Disposition', 'attachment; filename="aspa-adoption.csv"')
|
||||
.send(csv)
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
exportDate: new Date().toISOString(),
|
||||
period,
|
||||
totalRecords: history.length,
|
||||
data: history,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error exporting adoption data:', error)
|
||||
return reply.code(500).send({ error: 'Failed to export data' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parsePeriod(period: string): number {
|
||||
const match = period.match(/^(\d+)([dwmy])$/)
|
||||
if (!match) return 30
|
||||
|
||||
const value = parseInt(match[1], 10)
|
||||
const unit = match[2]
|
||||
|
||||
switch (unit) {
|
||||
case 'd':
|
||||
return value
|
||||
case 'w':
|
||||
return value * 7
|
||||
case 'm':
|
||||
return value * 30
|
||||
case 'y':
|
||||
return value * 365
|
||||
default:
|
||||
return 30
|
||||
}
|
||||
}
|
||||
|
||||
function calculateChange24h(history: any[]): number {
|
||||
if (history.length < 2) return 0
|
||||
|
||||
const today = history[0]
|
||||
const yesterday = history[1]
|
||||
|
||||
return Math.round((today.coveragePercentage - yesterday.coveragePercentage) * 100) / 100
|
||||
}
|
||||
|
||||
function convertToCSV(records: any[]): string {
|
||||
const headers = ['Date', 'Coverage %', 'ASNs Sampled', 'ASNs with ASPA']
|
||||
const rows = records.map((r) => [
|
||||
r.sampleDate.toISOString().split('T')[0],
|
||||
r.coveragePercentage,
|
||||
r.totalASNsSampled,
|
||||
r.ASNsWithASPA,
|
||||
])
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map((row) => row.join(',')),
|
||||
].join('\n')
|
||||
|
||||
return csvContent
|
||||
}
|
||||
@ -1,264 +0,0 @@
|
||||
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' })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -1,158 +0,0 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { getDatabase } from '../lib/db'
|
||||
import { PDFExportDatabaseClient } from '../features/pdf-export/db-client'
|
||||
import { PDFRenderer, renderTemplate } from '../features/pdf-export/renderer'
|
||||
import { PDFCacheManager } from '../features/pdf-export/cache-manager'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import type { PDFTemplateData } from '../features/pdf-export/types'
|
||||
|
||||
const db = new PDFExportDatabaseClient(getDatabase())
|
||||
const renderer = new PDFRenderer()
|
||||
const cache = new PDFCacheManager()
|
||||
|
||||
interface ExportPDFQuery {
|
||||
asn: string
|
||||
format?: string
|
||||
}
|
||||
|
||||
async function loadTemplate(format: string): Promise<string> {
|
||||
const templatePath = path.join(
|
||||
__dirname,
|
||||
'../features/pdf-export/templates',
|
||||
`${format}-template.html`
|
||||
)
|
||||
|
||||
try {
|
||||
return await fs.readFile(templatePath, 'utf-8')
|
||||
} catch (error) {
|
||||
throw new Error(`Template not found: ${format}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function getNetworkData(asn: number): Promise<PDFTemplateData> {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
asn,
|
||||
network_name: `AS${asn} Network`,
|
||||
generated_at: now.toISOString(),
|
||||
health_score: {
|
||||
overall: 78,
|
||||
aspa: 65,
|
||||
rpki: 85,
|
||||
bgp_stability: 80,
|
||||
peering_health: 75,
|
||||
},
|
||||
aspa: {
|
||||
adoption_status: 'in_progress',
|
||||
provider_verification: 45,
|
||||
readiness_score: 60,
|
||||
},
|
||||
peering: {
|
||||
ixp_connections: 12,
|
||||
peer_count: 42,
|
||||
open_peers: 28,
|
||||
route_exports: 1250,
|
||||
},
|
||||
threats: {
|
||||
recent_hijacks: 0,
|
||||
anomalies_detected: 2,
|
||||
rpki_invalids: 3,
|
||||
moas_events: 1,
|
||||
},
|
||||
recommendations: [
|
||||
'Implement RPKI ROV for route validation',
|
||||
'Increase ASPA adoption to reduce path spoofing risk',
|
||||
'Review BGP community tagging practices',
|
||||
'Monitor anomalies for potential routing issues',
|
||||
'Expand IXP presence for redundancy',
|
||||
],
|
||||
data_sources: [
|
||||
'RIPE RIS Route Collectors',
|
||||
'RouteViews BGP Archive',
|
||||
'RPKI Repository Objects',
|
||||
'PeeringDB Network Database',
|
||||
'WHOIS RDAP Queries',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export async function pdfExportRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get<{ Querystring: ExportPDFQuery }>(
|
||||
'/export/pdf',
|
||||
async (request: FastifyRequest<{ Querystring: ExportPDFQuery }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const asn = parseInt(request.query.asn, 10)
|
||||
const format = (request.query.format ?? 'report') as string
|
||||
|
||||
if (isNaN(asn)) {
|
||||
return reply.status(400).send({ error: 'Invalid ASN' })
|
||||
}
|
||||
|
||||
if (!['report', 'executive', 'technical'].includes(format)) {
|
||||
return reply.status(400).send({ error: 'Invalid format' })
|
||||
}
|
||||
|
||||
const dateStr = new Date().toISOString().split('T')[0]
|
||||
const cacheKey = cache.generateKey(asn, format, dateStr)
|
||||
const cachedEntry = cache.get(cacheKey)
|
||||
|
||||
if (cachedEntry) {
|
||||
console.log(`[PDF Export] Cache hit for AS${asn} format=${format}`)
|
||||
return reply
|
||||
.header('Content-Type', 'application/pdf')
|
||||
.header('Cache-Control', 'public, max-age=300')
|
||||
.header('X-Cache-Hit', 'true')
|
||||
.send(cachedEntry.pdfBuffer)
|
||||
}
|
||||
|
||||
await renderer.initialize()
|
||||
|
||||
const template = await loadTemplate(format)
|
||||
const data = await getNetworkData(asn)
|
||||
const html = renderTemplate(template, data)
|
||||
|
||||
const pdfBuffer = await renderer.renderPDF(html, 30000)
|
||||
|
||||
cache.set(cacheKey, pdfBuffer)
|
||||
|
||||
await db.recordPDFGeneration(
|
||||
`AS${asn}`,
|
||||
format,
|
||||
require('crypto').createHash('sha256').update(pdfBuffer).digest('hex'),
|
||||
pdfBuffer.length,
|
||||
{ format, generated_at: new Date().toISOString() }
|
||||
)
|
||||
|
||||
return reply
|
||||
.header('Content-Type', 'application/pdf')
|
||||
.header('Cache-Control', 'public, max-age=300')
|
||||
.header('X-Cache-Hit', 'false')
|
||||
.send(pdfBuffer)
|
||||
} catch (error) {
|
||||
console.error('[PDF Export] Error generating PDF:', error)
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to generate PDF',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
fastify.get('/export/pdf/stats', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
const stats = await db.getPDFStats()
|
||||
const cacheStats = cache.getStats()
|
||||
|
||||
return reply.send({
|
||||
database: stats,
|
||||
cache: cacheStats,
|
||||
ttl_ms: 5 * 60 * 1000,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[PDF Export] Error fetching stats:', error)
|
||||
return reply.status(500).send({ error: 'Failed to fetch stats' })
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user