129 lines
4.7 KiB
TypeScript
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 });
|
|
}
|
|
}
|
|
);
|
|
}
|