129 lines
4.7 KiB
TypeScript

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<any> {
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<void> {
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<string, number | null> = {};
const rpkiSeen = new Set<string>();
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 });
}
}
);
}