import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { validateRpki } from '../../db/rpki-client'; interface PrefixChangesQuery { asn?: string; from?: string; to?: string; hours?: string; } async function fetchWithRetry(url: string, retries = 1, timeout = 8000): Promise { for (let i = 0; i <= retries; i++) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { if (i === retries) throw new Error(`HTTP ${response.status}`); continue; } return await response.json(); } catch (e) { clearTimeout(timeoutId); if (i === retries) throw e; await new Promise(r => setTimeout(r, 1500)); } } } export async function prefixChangesRoutes(fastify: FastifyInstance): Promise { fastify.get<{ Querystring: PrefixChangesQuery }>( '/prefix-changes', async (request: FastifyRequest<{ Querystring: PrefixChangesQuery }>, reply: FastifyReply) => { const rawAsn = (request.query.asn || '').replace(/[^0-9]/g, ''); reply.header('Access-Control-Allow-Origin', '*'); reply.header('Cache-Control', 'no-store'); if (!rawAsn) { return reply.status(400).send({ error: 'Missing ASN' }); } const fromParam = request.query.from; const toParam = request.query.to; const hoursParam = Math.min(parseInt(request.query.hours || '1', 10), 168); let starttime: string; let endtime: string; if (fromParam && toParam) { starttime = new Date(fromParam).toISOString(); endtime = new Date(toParam).toISOString(); } else { endtime = new Date().toISOString(); starttime = new Date(Date.now() - hoursParam * 3600000).toISOString(); } try { const updUrl = `https://stat.ripe.net/data/bgp-updates/data.json?resource=AS${rawAsn}&starttime=${encodeURIComponent(starttime)}&endtime=${encodeURIComponent(endtime)}&limit=1000`; const raw = await fetchWithRetry(updUrl, 1, 8000); const updates = (raw && raw.data && raw.data.updates && raw.data.updates.updates) || []; const announcements: any[] = []; const withdrawals: any[] = []; const originChanges: any[] = []; const rpkiIssues: any[] = []; const lastOriginByPrefix: Record = {}; const rpkiSeen = new Set(); for (const u of updates) { const prefix = u.attrs && u.attrs.prefix; if (!prefix) continue; const originRaw = u.attrs && u.attrs.origin; const origin = originRaw ? parseInt(String(originRaw).replace('AS', ''), 10) : null; const ts = u.timestamp || ''; const peer = u.peer || ''; if (u.type === 'A') { let rpkiStatus = 'unknown'; try { if (origin && prefix) { const rpkiResult = await validateRpki(prefix, origin); rpkiStatus = rpkiResult.status; } } catch (e: any) { console.error("[Prefix Changes] RPKI lookup error:", e.message); } announcements.push({ prefix, timestamp: ts, peer, origin, rpki_status: rpkiStatus }); if (lastOriginByPrefix[prefix] !== undefined && lastOriginByPrefix[prefix] !== origin) { originChanges.push({ prefix, from_origin: lastOriginByPrefix[prefix], to_origin: origin, timestamp: ts, peer }); } lastOriginByPrefix[prefix] = origin; if (!rpkiSeen.has(prefix) && rpkiStatus !== 'valid' && rpkiStatus !== 'unknown' && rpkiStatus !== 'not-found') { rpkiSeen.add(prefix); rpkiIssues.push({ prefix, origin, rpki_status: rpkiStatus, timestamp: ts }); } } else if (u.type === 'W') { withdrawals.push({ prefix, timestamp: ts, peer }); } } return reply.status(200).send({ asn: parseInt(rawAsn, 10), time_range: { from: starttime, to: endtime }, total_updates: updates.length, summary: { announcements: announcements.length, withdrawals: withdrawals.length, origin_changes: originChanges.length, rpki_issues: rpkiIssues.length }, announcements, withdrawals, origin_changes: originChanges, rpki_issues: rpkiIssues, }); } catch (e: any) { return reply.status(500).send({ error: e.message }); } } ); }