refactor: Migrate all API routes to src/features/ TypeScript modules #1
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "peercortex",
|
||||
"version": "0.6.5",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "peercortex",
|
||||
"version": "0.6.5",
|
||||
"version": "0.7.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
|
||||
687
server.js
687
server.js
@ -8,22 +8,7 @@ const localDb = require('./local-db-client');
|
||||
console.log('[PeerCortex] Local DB client initialized');
|
||||
|
||||
// Load .env file
|
||||
const envPath = "/opt/peercortex-app/.env";
|
||||
try {
|
||||
const envContent = fs.readFileSync(envPath, "utf8");
|
||||
envContent.split("\n").forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) return;
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx > 0) {
|
||||
const key = trimmed.substring(0, eqIdx).trim();
|
||||
const val = trimmed.substring(eqIdx + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = val;
|
||||
}
|
||||
});
|
||||
} catch (_e) {
|
||||
console.warn("Warning: Could not read .env file at", envPath);
|
||||
}
|
||||
require('./src/backend/config');
|
||||
|
||||
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
|
||||
const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1";
|
||||
@ -212,80 +197,7 @@ const FEEDBACK_FILE = "/opt/peercortex-app/feedback.json";
|
||||
const VISITORS_FILE = "/opt/peercortex-app/visitors.json";
|
||||
|
||||
// ── SMTP / Email ──────────────────────────────────────────────
|
||||
const SMTP_HOST = 'mail.fichtmueller.org';
|
||||
const SMTP_PORT = 587;
|
||||
const SMTP_USER = process.env.SMTP_USER;
|
||||
const SMTP_PASS = process.env.SMTP_PASS;
|
||||
const MAIL_TO = 'peercortex@context-x.org';
|
||||
const MAIL_FROM = 'PeerCortex Feedback <rene@fichtmueller.org>';
|
||||
|
||||
function sendFeedbackMail(entry) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var tls = require('tls');
|
||||
var net = require('net');
|
||||
var b64 = function(s) { return Buffer.from(s).toString('base64'); };
|
||||
var CRLF = '\r\n';
|
||||
var body = 'Category : ' + entry.category + CRLF +
|
||||
'Name : ' + entry.name + CRLF +
|
||||
'ASN : ' + (entry.asn || '-') + CRLF +
|
||||
'Time : ' + entry.timestamp + CRLF + CRLF +
|
||||
entry.message + CRLF + CRLF + '-' + CRLF + 'PeerCortex Feedback';
|
||||
var subj = '[PeerCortex Feedback] ' + entry.category + (entry.asn ? ' - AS' + entry.asn : '');
|
||||
var msg = 'From: ' + MAIL_FROM + CRLF +
|
||||
'To: ' + MAIL_TO + CRLF +
|
||||
'Subject: ' + subj + CRLF +
|
||||
'MIME-Version: 1.0' + CRLF +
|
||||
'Content-Type: text/plain; charset=UTF-8' + CRLF + CRLF +
|
||||
body;
|
||||
|
||||
var socket = net.connect(SMTP_PORT, SMTP_HOST);
|
||||
var tlsSocket = null;
|
||||
var buf = '';
|
||||
var step = 0;
|
||||
var done = false;
|
||||
|
||||
function send(line) {
|
||||
var s = tlsSocket || socket;
|
||||
s.write(line + CRLF);
|
||||
}
|
||||
|
||||
function onData(data) {
|
||||
buf += data.toString();
|
||||
var lines = buf.split(CRLF);
|
||||
buf = lines.pop();
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
var code = parseInt(line.slice(0, 3));
|
||||
if (isNaN(code) || line[3] === '-') continue;
|
||||
if (step === 0 && code === 220) { send('EHLO peercortex.org'); step = 1; }
|
||||
else if (step === 1 && code === 250) { send('STARTTLS'); step = 2; }
|
||||
else if (step === 2 && code === 220) {
|
||||
tlsSocket = tls.connect({ socket: socket, servername: SMTP_HOST, rejectUnauthorized: false }, function() {
|
||||
tlsSocket.on('data', onData);
|
||||
send('EHLO peercortex.org');
|
||||
step = 3;
|
||||
});
|
||||
tlsSocket.on('error', function(e) { if (!done) { done = true; reject(e); } });
|
||||
}
|
||||
else if (step === 3 && code === 250) { send('AUTH LOGIN'); step = 4; }
|
||||
else if (step === 4 && code === 334) { send(b64(SMTP_USER)); step = 5; }
|
||||
else if (step === 5 && code === 334) { send(b64(SMTP_PASS)); step = 6; }
|
||||
else if (step === 6 && code === 235) { send('MAIL FROM:<' + SMTP_USER + '>'); step = 7; }
|
||||
else if (step === 7 && code === 250) { send('RCPT TO:<' + MAIL_TO + '>'); step = 8; }
|
||||
else if (step === 8 && code === 250) { send('DATA'); step = 9; }
|
||||
else if (step === 9 && code === 354) { send(msg + CRLF + '.'); step = 10; }
|
||||
else if (step === 10 && code === 250) { send('QUIT'); if (!done) { done = true; resolve(); } }
|
||||
else if (code >= 400) { if (!done) { done = true; reject(new Error('SMTP ' + code + ': ' + line)); } }
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('data', onData);
|
||||
socket.on('error', function(e) { if (!done) { done = true; reject(e); } });
|
||||
setTimeout(function() { if (!done) { done = true; reject(new Error('SMTP timeout')); } }, 15000);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const { sendFeedbackMail } = require('./src/backend/services/smtp');
|
||||
// ── SMTP / Email ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -309,59 +221,7 @@ function trackVisitor(req) {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ── BGP Community Database ─────────────────────────────────────
|
||||
const BGP_COMMUNITY_DB = {
|
||||
'65535:666': { name:'BLACKHOLE', desc:'RFC 7999 — Null-route this prefix', type:'rfc' },
|
||||
'65535:65281':{ name:'NO_EXPORT', desc:'RFC 1997 — Do not export to EBGP peers', type:'rfc' },
|
||||
'65535:65282':{ name:'NO_ADVERTISE', desc:'RFC 1997 — Do not advertise to any peer', type:'rfc' },
|
||||
'65535:65283':{ name:'NO_EXPORT_SUBCONFED', desc:'RFC 1997 — No export to sub-AS', type:'rfc' },
|
||||
// Lumen/CenturyLink 3356
|
||||
'3356:2': { name:'Lumen Peer', desc:'Lumen — Learned from settlement-free peer', type:'carrier', asn:3356 },
|
||||
'3356:3': { name:'Lumen Customer', desc:'Lumen — Learned from customer', type:'carrier', asn:3356 },
|
||||
'3356:100':{ name:'Lumen Blackhole', desc:'Lumen — RTBH trigger', type:'carrier', asn:3356 },
|
||||
// NTT 2914
|
||||
'2914:420':{ name:'NTT Peer', desc:'NTT — Settlement-free peer route', type:'carrier', asn:2914 },
|
||||
'2914:421':{ name:'NTT Customer', desc:'NTT — Downstream customer route', type:'carrier', asn:2914 },
|
||||
'2914:666':{ name:'NTT Blackhole', desc:'NTT — RTBH trigger', type:'carrier', asn:2914 },
|
||||
// Cogent 174
|
||||
'174:21000':{ name:'Cogent Peer', desc:'Cogent — Learned from peer', type:'carrier', asn:174 },
|
||||
'174:22000':{ name:'Cogent Customer', desc:'Cogent — Learned from customer', type:'carrier', asn:174 },
|
||||
'174:666': { name:'Cogent Blackhole', desc:'Cogent — RTBH trigger', type:'carrier', asn:174 },
|
||||
// HE 6939
|
||||
'6939:7000':{ name:'HE RTBH', desc:'Hurricane Electric — Remotely triggered blackhole', type:'carrier', asn:6939 },
|
||||
// Telia 1299
|
||||
'1299:35000':{ name:'Telia RTBH', desc:'Telia — Remotely triggered blackhole', type:'carrier', asn:1299 },
|
||||
'1299:3000': { name:'Telia Peer', desc:'Telia — Learned from peer', type:'carrier', asn:1299 },
|
||||
// DTAG 3320
|
||||
'3320:1278':{ name:'DTAG Peer', desc:'Deutsche Telekom — Peering route', type:'carrier', asn:3320 },
|
||||
'3320:2001':{ name:'DTAG Customer', desc:'Deutsche Telekom — Customer route', type:'carrier', asn:3320 },
|
||||
'3320:9900':{ name:'DTAG Blackhole', desc:'Deutsche Telekom — RTBH trigger', type:'carrier', asn:3320 },
|
||||
// Cloudflare 13335
|
||||
'13335:10000':{ name:'CF Customer', desc:'Cloudflare — Customer route', type:'carrier', asn:13335 },
|
||||
'13335:10010':{ name:'CF Peering', desc:'Cloudflare — Learned via peering', type:'carrier', asn:13335 },
|
||||
'13335:20050':{ name:'CF Blackhole', desc:'Cloudflare — RTBH trigger', type:'carrier', asn:13335 },
|
||||
// Zayo 6461
|
||||
'6461:9000':{ name:'Zayo Blackhole', desc:'Zayo — RTBH trigger', type:'carrier', asn:6461 },
|
||||
// DE-CIX 6695
|
||||
'6695:1000':{ name:'DE-CIX RS', desc:'DE-CIX Frankfurt — Route server export', type:'ixp', asn:6695 },
|
||||
'6695:1001':{ name:'DE-CIX RS peer', desc:'DE-CIX — Received from route server peer', type:'ixp', asn:6695 },
|
||||
// AMS-IX 1200
|
||||
'1200:100': { name:'AMS-IX RS', desc:'AMS-IX — Route server export', type:'ixp', asn:1200 },
|
||||
// LINX 5459
|
||||
'5459:1001':{ name:'LINX RS', desc:'LINX — Route server export', type:'ixp', asn:5459 },
|
||||
// Seabone/TI 6762
|
||||
'6762:30': { name:'Seabone Customer', desc:'Telecom Italia Seabone — Customer route', type:'carrier', asn:6762 },
|
||||
// Turkcell 9121
|
||||
'9121:666': { name:'Turkcell BH', desc:'Turkcell — RTBH trigger', type:'carrier', asn:9121 },
|
||||
};
|
||||
|
||||
function decodeCommunities(communityList) {
|
||||
if (!Array.isArray(communityList)) return [];
|
||||
return communityList.map(c => {
|
||||
const key = Array.isArray(c) ? c.join(':') : String(c);
|
||||
const known = BGP_COMMUNITY_DB[key];
|
||||
return { raw: key, known: known || null };
|
||||
});
|
||||
}
|
||||
// Migrated to src/features/bgp-communities/bgp-community-db.ts
|
||||
|
||||
// ── Hijack Monitoring ──────────────────────────────────────────
|
||||
const HIJACK_SUBS_FILE = '/opt/peercortex-app/hijack-subs.json';
|
||||
@ -4817,52 +4677,11 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
// Feature 28: Submarine Cable overlay (TeleGeography proxy)
|
||||
if (reqPath === "/api/submarine-cables") {
|
||||
const CABLE_TTL = 24 * 60 * 60 * 1000;
|
||||
if (subCableCache && Date.now() - subCableCache.ts < CABLE_TTL) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
return res.end(subCableCache.data);
|
||||
}
|
||||
const cableData = await fetchJSONWithRetry("https://www.submarinecablemap.com/api/v3/cable/cable-geo.json", { timeout: 30000 });
|
||||
if (cableData) {
|
||||
subCableCache = { ts: Date.now(), data: JSON.stringify(cableData) };
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
return res.end(subCableCache.data);
|
||||
}
|
||||
res.writeHead(503);
|
||||
return res.end(JSON.stringify({ error: "Submarine cable data unavailable" }));
|
||||
}
|
||||
// ── Submarine Cables map data ─────────────────────────────────
|
||||
// Migrated to src/features/submarine-cables/
|
||||
|
||||
// Feature 29: Global datacenter/IXP map (PeeringDB proxy)
|
||||
if (reqPath === "/api/global-infra") {
|
||||
const FAC_TTL = 24 * 60 * 60 * 1000;
|
||||
if (globalFacCache && Date.now() - globalFacCache.ts < FAC_TTL) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
return res.end(globalFacCache.data);
|
||||
}
|
||||
const [facData, ixData] = await Promise.all([
|
||||
fetchJSONWithRetry(PEERINGDB_API_URL + "/fac?depth=1&limit=3000", { timeout: 30000 }),
|
||||
fetchJSONWithRetry(PEERINGDB_API_URL + "/ix?depth=1&limit=1000", { timeout: 30000 }),
|
||||
]);
|
||||
const facs = (facData && facData.data || [])
|
||||
.filter(f => f.latitude && f.longitude)
|
||||
.map(f => ({ id: f.id, name: f.name, city: f.city, country: f.country, lat: +f.latitude, lng: +f.longitude }));
|
||||
const ixps = (ixData && ixData.data || [])
|
||||
.filter(ix => ix.city && ix.country)
|
||||
.map(ix => ({ id: ix.id, name: ix.name, city: ix.city, country: ix.country, website: ix.website }));
|
||||
const result = JSON.stringify({ facs, ixps });
|
||||
globalFacCache = { ts: Date.now(), data: result };
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
return res.end(result);
|
||||
}
|
||||
// ── Global datacenter/IXP map (PeeringDB proxy) ───────────────
|
||||
// Migrated to src/features/global-infra/
|
||||
|
||||
|
||||
// ── Changelog page ─────────────────────────────────────────
|
||||
@ -4909,500 +4728,42 @@ ${html}
|
||||
}
|
||||
|
||||
// ── BGP Community Decoder ────────────────────────────────────
|
||||
if (reqPath === '/api/communities') {
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const asn = params.get('asn') || '';
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
try {
|
||||
const url = `https://stat.ripe.net/data/bgp-state/data.json?resource=AS${asn.replace('AS','')}`;
|
||||
const data = await fetchJSON(url, { timeout: 6000 });
|
||||
const rawComms = [];
|
||||
if (data && data.data && data.data.bgp_state) {
|
||||
for (const entry of data.data.bgp_state.slice(0, 50)) {
|
||||
if (entry.community) rawComms.push(...entry.community);
|
||||
}
|
||||
}
|
||||
const unique = [...new Set(rawComms.map(c => Array.isArray(c) ? c.join(':') : String(c)))];
|
||||
const decoded = unique.map(k => ({ raw: k, known: BGP_COMMUNITY_DB[k] || null }));
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ asn, communities: decoded, db_size: Object.keys(BGP_COMMUNITY_DB).length }));
|
||||
} catch(e) {
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ asn, communities: [], error: e.message }));
|
||||
}
|
||||
}
|
||||
// Migrated to src/features/bgp-communities/
|
||||
|
||||
// ── IRR Audit ─────────────────────────────────────────────────
|
||||
if (reqPath.startsWith('/api/irr-audit')) {
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const asn = (params.get('asn') || '').replace(/[^0-9]/g,'');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=1800');
|
||||
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
|
||||
try {
|
||||
// Use NLNOG IRR Explorer — covers RIPE, ARIN, APNIC, RPKI, and all major IRR databases
|
||||
const nlnogData = await fetchJSONWithRetry(
|
||||
'https://irrexplorer.nlnog.net/api/prefixes/asn/AS' + asn,
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
const prefixes = nlnogData && nlnogData.directOrigin || [];
|
||||
var irrRoutes = [];
|
||||
var irrDetails = [];
|
||||
var goodCount = 0;
|
||||
var warnCount = 0;
|
||||
var errorCount = 0;
|
||||
for (var i = 0; i < prefixes.length; i++) {
|
||||
var pfx = prefixes[i];
|
||||
var hasIrr = pfx.irrRoutes && Object.keys(pfx.irrRoutes).length > 0;
|
||||
var sources = hasIrr ? Object.keys(pfx.irrRoutes) : [];
|
||||
var cat = pfx.categoryOverall || 'unknown';
|
||||
if (hasIrr) irrRoutes.push(pfx.prefix);
|
||||
irrDetails.push({
|
||||
prefix: pfx.prefix,
|
||||
irr_sources: sources,
|
||||
rpki_status: pfx.rpkiRoutes && pfx.rpkiRoutes.length ? pfx.rpkiRoutes[0].rpkiStatus : 'not-found',
|
||||
category: cat,
|
||||
messages: (pfx.messages || []).map(function(m){ return m.text; })
|
||||
});
|
||||
if (cat === 'success') goodCount++;
|
||||
else if (cat === 'warning') warnCount++;
|
||||
else errorCount++;
|
||||
}
|
||||
var actualPfx = prefixes.map(function(p){ return p.prefix; });
|
||||
var inBgpNotIrr = actualPfx.filter(function(p){ return !irrRoutes.includes(p); });
|
||||
var score = actualPfx.length ? Math.round(irrRoutes.length / actualPfx.length * 100) : 0;
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({
|
||||
asn: asn,
|
||||
irr_routes: irrRoutes,
|
||||
actual_prefixes: actualPfx,
|
||||
in_irr_not_bgp: [],
|
||||
in_bgp_not_irr: inBgpNotIrr,
|
||||
score: score,
|
||||
details: irrDetails,
|
||||
summary: { good: goodCount, warning: warnCount, error: errorCount, total: prefixes.length },
|
||||
source: 'NLNOG IRR Explorer'
|
||||
}));
|
||||
} catch(e) {
|
||||
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
|
||||
}
|
||||
}
|
||||
// Migrated to src/features/irr-audit/
|
||||
|
||||
|
||||
// ── AS-SET Expander ───────────────────────────────────────────
|
||||
if (reqPath.startsWith('/api/asset-expand')) {
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const setName = params.get('set') || '';
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
if (!setName) { res.writeHead(400); return res.end(JSON.stringify({error:'set required (e.g. AS-FLEXOPTIX)'})); }
|
||||
try {
|
||||
async function expandSet(name, depth, visited) {
|
||||
if (depth > 4 || visited.has(name)) return { asns: [], sets: [] };
|
||||
visited.add(name);
|
||||
const url = `https://rest.db.ripe.net/search.json?query-string=${encodeURIComponent(name)}&type-filter=as-set&flags=no-referenced`;
|
||||
const data = await fetchJSONWithRetry(url, { timeout: 10000 });
|
||||
const asns = [], sets = [];
|
||||
if (data && data.objects && data.objects.object) {
|
||||
for (const obj of data.objects.object) {
|
||||
const attrs = obj.attributes && obj.attributes.attribute || [];
|
||||
for (const a of attrs) {
|
||||
if (a.name === 'members') {
|
||||
for (const m of (a.value || '').split(/[,\s]+/).filter(Boolean)) {
|
||||
if (/^AS\d+$/i.test(m)) asns.push(m.toUpperCase());
|
||||
else if (m.startsWith('AS-')) { sets.push(m); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const sub of sets.slice(0,10)) {
|
||||
const sub_r = await expandSet(sub, depth+1, visited);
|
||||
asns.push(...sub_r.asns);
|
||||
}
|
||||
return { asns: [...new Set(asns)], sets };
|
||||
}
|
||||
const visited = new Set();
|
||||
const result = await expandSet(setName.toUpperCase(), 0, visited);
|
||||
result.asns.sort((a,b) => parseInt(a.slice(2)) - parseInt(b.slice(2)));
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ set: setName.toUpperCase(), count: result.asns.length, asns: result.asns, sub_sets: result.sets }));
|
||||
} catch(e) {
|
||||
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
|
||||
}
|
||||
}
|
||||
|
||||
// Migrated to src/features/asset-expand/
|
||||
|
||||
// ── Routing History (prefix table via RIPE Stat routing-history) ──
|
||||
if (reqPath.startsWith('/api/rpki-history')) {
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const asn = (params.get('asn') || '').replace(/[^0-9]/g,'');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
|
||||
try {
|
||||
const url = 'https://stat.ripe.net/data/routing-history/data.json?resource=AS' + asn + '&max_rows=100';
|
||||
const data = await fetchJSON(url, { timeout: 6000 });
|
||||
const byOrigin = data && data.data && data.data.by_origin || [];
|
||||
// Flatten: each origin entry has prefixes[]
|
||||
var prefixes = [];
|
||||
for (var i = 0; i < byOrigin.length; i++) {
|
||||
var orig = byOrigin[i];
|
||||
if (orig.prefixes) {
|
||||
for (var j = 0; j < orig.prefixes.length; j++) {
|
||||
prefixes.push(orig.prefixes[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ asn: asn, prefixes: prefixes, source: 'RIPE Stat routing-history' }));
|
||||
} catch(e) {
|
||||
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
|
||||
}
|
||||
}
|
||||
// Migrated to src/features/rpki-history/
|
||||
|
||||
// ── AS-PATH Visualizer (RIPE Stat looking-glass) ────────────────
|
||||
if (reqPath.startsWith('/api/aspath')) {
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const asn = (params.get('asn') || '').replace(/[^0-9]/g,'');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
|
||||
try {
|
||||
// Use RIPE Stat announced-prefixes to get prefixes, then looking-glass for paths
|
||||
var annUrl = 'https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS' + asn;
|
||||
var annData = await fetchJSON(annUrl, { timeout: 5000 });
|
||||
var announced = annData && annData.data && annData.data.prefixes || [];
|
||||
var prefix = announced.length > 0 ? announced[0].prefix : null;
|
||||
if (!prefix) {
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ asn: asn, paths: [], source: 'RIPE Stat' }));
|
||||
}
|
||||
// Get looking-glass data for the first announced prefix
|
||||
var lgUrl = 'https://stat.ripe.net/data/looking-glass/data.json?resource=' + encodeURIComponent(prefix);
|
||||
var lgData = await fetchJSON(lgUrl, { timeout: 6000 });
|
||||
var rrcs = lgData && lgData.data && lgData.data.rrcs || [];
|
||||
var paths = [];
|
||||
var seen = new Set();
|
||||
for (var i = 0; i < rrcs.length && paths.length < 10; i++) {
|
||||
var rrc = rrcs[i];
|
||||
var peers = rrc.peers || [];
|
||||
for (var j = 0; j < peers.length && paths.length < 10; j++) {
|
||||
var p = peers[j];
|
||||
var pathStr = (p.as_path || '').trim();
|
||||
if (pathStr && !seen.has(pathStr)) {
|
||||
seen.add(pathStr);
|
||||
paths.push({
|
||||
path: pathStr,
|
||||
prefix: prefix,
|
||||
rrc: rrc.rrc + ' (' + (rrc.location||'') + ')',
|
||||
peer_asn: p.asn_origin || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ asn: asn, paths: paths, prefix: prefix, source: 'RIPE Stat looking-glass · ' + prefix }));
|
||||
} catch(e) {
|
||||
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
|
||||
}
|
||||
}
|
||||
// Migrated to src/features/aspath/
|
||||
|
||||
// ── Looking Glass (RIPE Stat) ─────────────────────────────────
|
||||
if (reqPath.startsWith('/api/looking-glass')) {
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const resource = params.get('prefix') || params.get('asn') || '';
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (!resource) { res.writeHead(400); return res.end(JSON.stringify({error:'prefix or asn required'})); }
|
||||
try {
|
||||
const url = `https://stat.ripe.net/data/looking-glass/data.json?resource=${encodeURIComponent(resource)}`;
|
||||
const data = await fetchJSON(url, { timeout: 6000 });
|
||||
const rrcs = data && data.data && data.data.rrcs || [];
|
||||
const results = rrcs.slice(0, 15).map(rrc => ({
|
||||
rrc: rrc.rrc,
|
||||
location: rrc.location,
|
||||
peers: (rrc.peers || []).slice(0,5).map(p => ({
|
||||
asn: p.asn_origin,
|
||||
as_path: p.as_path,
|
||||
community: p.community,
|
||||
next_hop: p.next_hop,
|
||||
}))
|
||||
}));
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ resource, rrcs: results, total_rrcs: rrcs.length }));
|
||||
} catch(e) {
|
||||
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
|
||||
}
|
||||
}
|
||||
// Migrated to src/features/looking-glass/
|
||||
|
||||
// ── IXP Peering Matrix ────────────────────────────────────────
|
||||
if (reqPath.startsWith('/api/ix-matrix')) {
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const ixId = (params.get('ix_id') || '').replace(/[^0-9]/g,'');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
if (!ixId) { res.writeHead(400); return res.end(JSON.stringify({error:'ix_id required'})); }
|
||||
try {
|
||||
const [netixData, ixData] = await Promise.all([
|
||||
fetchJSONWithRetry(`${PEERINGDB_API_URL}/netixlan?ix_id=${ixId}&depth=1&limit=200`, { timeout: 15000 }),
|
||||
fetchJSONWithRetry(`${PEERINGDB_API_URL}/ix/${ixId}`, { timeout: 10000 }),
|
||||
]);
|
||||
const ix = ixData && ixData.data && ixData.data[0];
|
||||
const members = (netixData && netixData.data || []).map(m => ({
|
||||
asn: m.asn, name: m.name, speed: m.speed, ipaddr4: m.ipaddr4, ipaddr6: m.ipaddr6, policy: m.policy_general
|
||||
}));
|
||||
members.sort((a,b) => (b.speed||0) - (a.speed||0));
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ ix_id: ixId, ix_name: ix && ix.name, ix_city: ix && ix.city, members, member_count: members.length }));
|
||||
} catch(e) {
|
||||
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
|
||||
}
|
||||
}
|
||||
// Migrated to src/features/ix-matrix/
|
||||
|
||||
|
||||
// ── Hijack Subscribe ──────────────────────────────────────────
|
||||
if (reqPath === '/api/hijack-subscribe' && req.method === 'POST') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { asn, email } = JSON.parse(body);
|
||||
const asnNum = String(asn).replace(/[^0-9]/g,'');
|
||||
if (!asnNum) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
|
||||
const subs = loadHijackSubs();
|
||||
const exists = subs.find(s => s.asn === asnNum);
|
||||
if (!exists) {
|
||||
const prefixes = await checkHijacksForAsn(asnNum);
|
||||
subs.push({ asn: asnNum, email: email || '', prefixes, subscribed: new Date().toISOString() });
|
||||
fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2));
|
||||
}
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: exists ? exists.prefixes.length : subs[subs.length-1].prefixes.length }));
|
||||
} catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (reqPath === '/api/hijack-subscribe' && req.method === 'OPTIONS') {
|
||||
res.setHeader('Access-Control-Allow-Origin','*');
|
||||
res.setHeader('Access-Control-Allow-Methods','POST,OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers','Content-Type');
|
||||
res.writeHead(204); return res.end();
|
||||
}
|
||||
|
||||
// ── Hijack Alerts ─────────────────────────────────────────────
|
||||
if (reqPath.startsWith('/api/hijack-alerts')) {
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const asn = (params.get('asn') || '').replace(/[^0-9]/g,'');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
const allAlerts = loadHijackAlerts();
|
||||
const alerts = asn ? allAlerts.filter(a => a.asn === asn) : allAlerts;
|
||||
const subs = loadHijackSubs();
|
||||
const sub = subs.find(s => s.asn === asn);
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ asn, alerts: alerts.slice(-50), monitoring: !!sub, prefix_count: sub ? sub.prefixes.length : 0 }));
|
||||
}
|
||||
|
||||
// Migrated to src/features/hijack-subscribe/
|
||||
|
||||
// ── Hijack Alerts (legacy read) ───────────────────────────────
|
||||
// Migrated to src/routes/hijack-alerts (Fastify feature)
|
||||
|
||||
// ── Changelog JSON API ────────────────────────────────────────
|
||||
if (reqPath === '/changelog-data') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
try {
|
||||
const md = fs.readFileSync('/opt/peercortex-app/CHANGELOG.md', 'utf8');
|
||||
const entries = [];
|
||||
let current = null;
|
||||
let currentSection = null;
|
||||
for (const line of md.split('\n')) {
|
||||
// Support both ## [0.6.x] — date AND ## v0.6.x — date
|
||||
const vMatch = line.match(/^## (?:v|\[)?([\d.]+)\]? — (.+)/);
|
||||
if (vMatch) {
|
||||
if (current) entries.push(current);
|
||||
current = { version: vMatch[1], date: vMatch[2].trim(), sections: [] };
|
||||
currentSection = null;
|
||||
continue;
|
||||
}
|
||||
const sMatch = line.match(/^### (.+)/);
|
||||
if (sMatch && current) {
|
||||
currentSection = { name: sMatch[1], items: [] };
|
||||
current.sections.push(currentSection);
|
||||
continue;
|
||||
}
|
||||
const iMatch = line.match(/^- (.+)/);
|
||||
if (iMatch && currentSection) {
|
||||
currentSection.items.push(iMatch[1]);
|
||||
}
|
||||
}
|
||||
if (current) entries.push(current);
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify(entries));
|
||||
} catch(e) {
|
||||
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
|
||||
}
|
||||
}
|
||||
// Migrated to src/features/changelog/
|
||||
|
||||
// ── bio-rd RIB: prefix lookup ──────────────────────────────────
|
||||
if (reqPath === '/api/rib/prefix') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); }
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const prefix = params.get('prefix') || '';
|
||||
const routerParam = params.get('router') || 'default';
|
||||
if (!prefix) { res.writeHead(400); return res.end(JSON.stringify({ error: 'prefix required' })); }
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const routers = await risClient.getRouters();
|
||||
const routerName = (routerParam === 'default' && routers.length > 0) ? routers[0] : routerParam;
|
||||
const [routes, longer] = await Promise.all([
|
||||
risClient.lpm(routerName, prefix),
|
||||
risClient.getLonger(routerName, prefix),
|
||||
]);
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({
|
||||
prefix,
|
||||
router: routerName,
|
||||
routes: routes || [],
|
||||
moreSpecifics: (longer || []).slice(0, 20),
|
||||
source: 'bio-rd-local',
|
||||
latencyMs: Date.now() - t0,
|
||||
}));
|
||||
} catch(e) {
|
||||
res.writeHead(500); return res.end(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
}
|
||||
|
||||
// ── bio-rd RIB: list routers ───────────────────────────────────
|
||||
if (reqPath === '/api/rib/routers') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); }
|
||||
try {
|
||||
const routers = await risClient.getRouters();
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ routers: routers || [], source: 'bio-rd-local' }));
|
||||
} catch(e) {
|
||||
res.writeHead(500); return res.end(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
}
|
||||
|
||||
// ── bio-rd RIB: dump ───────────────────────────────────────────
|
||||
if (reqPath === '/api/rib/dump') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); }
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const router = params.get('router') || '';
|
||||
const asnFilter = params.get('asn') ? parseInt(params.get('asn')) : undefined;
|
||||
const limit = Math.min(parseInt(params.get('limit') || '100', 10), 1000);
|
||||
if (!router) { res.writeHead(400); return res.end(JSON.stringify({ error: 'router required' })); }
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const allRoutes = await risClient.dumpRib(router, 'default', asnFilter);
|
||||
const routes = (allRoutes || []).slice(0, limit);
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({
|
||||
router,
|
||||
routes,
|
||||
total: (allRoutes || []).length,
|
||||
source: 'bio-rd-local',
|
||||
latencyMs: Date.now() - t0,
|
||||
}));
|
||||
} catch(e) {
|
||||
res.writeHead(500); return res.end(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
}
|
||||
// ── bio-rd RIB routes ─────────────────────────────────────────
|
||||
// Migrated to src/features/rib/
|
||||
|
||||
// ── Prefix Changes ──────────────────────────────────────────────
|
||||
if (reqPath === '/api/prefix-changes') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, '');
|
||||
if (!rawAsn) { res.writeHead(400); return res.end(JSON.stringify({ error: 'Missing ASN' })); }
|
||||
const fromParam = url.searchParams.get('from');
|
||||
const toParam = url.searchParams.get('to');
|
||||
const hoursParam = Math.min(parseInt(url.searchParams.get('hours') || '1', 10), 168);
|
||||
let starttime, endtime;
|
||||
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 fetchJSON(updUrl, { timeout: 8000 });
|
||||
const updates = (raw && raw.data && raw.data.updates && raw.data.updates.updates) || [];
|
||||
|
||||
const announcements = [], withdrawals = [], originChanges = [], rpkiIssues = [];
|
||||
const lastOriginByPrefix = {}, 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') {
|
||||
// Query local PostgreSQL for RPKI status (sub-10ms)
|
||||
let rpkiStatus = 'unknown';
|
||||
try {
|
||||
if (origin && prefix) {
|
||||
const rpkiResult = await validateRPKIWithCache(origin, prefix);
|
||||
rpkiStatus = rpkiResult.status;
|
||||
}
|
||||
} catch (e) {
|
||||
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') {
|
||||
rpkiSeen.add(prefix);
|
||||
rpkiIssues.push({ prefix, origin, rpki_status: rpkiStatus, timestamp: ts });
|
||||
}
|
||||
} else if (u.type === 'W') {
|
||||
withdrawals.push({ prefix, timestamp: ts, peer });
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({
|
||||
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) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
}
|
||||
// Migrated to src/features/prefix-changes/
|
||||
|
||||
// 404
|
||||
res.writeHead(404);
|
||||
|
||||
5660
server.js.backup-v0.7.0
Normal file
5660
server.js.backup-v0.7.0
Normal file
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,19 @@ 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'
|
||||
import { rpkiHistoryRoutes } from '../features/rpki-history/routes'
|
||||
import { aspathRoutes } from '../features/aspath/routes'
|
||||
import { ixMatrixRoutes } from '../features/ix-matrix/routes'
|
||||
import { lookingGlassRoutes } from '../features/looking-glass/routes'
|
||||
import { prefixChangesRoutes } from '../features/prefix-changes/routes'
|
||||
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 = 3100) {
|
||||
const fastify = Fastify({
|
||||
@ -15,6 +28,19 @@ export async function createApiServer(port: number = 3100) {
|
||||
await fastify.register(hijackAlertsRoutes, { prefix: '/api' })
|
||||
await fastify.register(pdfExportRoutes, { prefix: '/api' })
|
||||
await fastify.register(aspaAdoptionRoutes, { prefix: '/api' })
|
||||
await fastify.register(bgpCommunitiesRoutes, { prefix: '/api' })
|
||||
await fastify.register(irrAuditRoutes, { prefix: '/api' })
|
||||
await fastify.register(assetExpandRoutes, { prefix: '/api' })
|
||||
await fastify.register(rpkiHistoryRoutes, { prefix: '/api' })
|
||||
await fastify.register(aspathRoutes, { prefix: '/api' })
|
||||
await fastify.register(ixMatrixRoutes, { prefix: '/api' })
|
||||
await fastify.register(lookingGlassRoutes, { prefix: '/api' })
|
||||
await fastify.register(prefixChangesRoutes, { prefix: '/api' })
|
||||
await fastify.register(submarineCablesRoutes, { prefix: '/api' })
|
||||
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 () => {
|
||||
@ -32,7 +58,7 @@ export async function createApiServer(port: number = 3100) {
|
||||
return {
|
||||
name: 'PeerCortex API',
|
||||
version: '1.0.0',
|
||||
features: ['hijack-alerts', 'pdf-export', 'aspa-adoption-tracker'],
|
||||
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'],
|
||||
docs: 'https://peercortex.org/docs',
|
||||
}
|
||||
})
|
||||
|
||||
28
src/backend/config.js
Normal file
28
src/backend/config.js
Normal file
@ -0,0 +1,28 @@
|
||||
const fs = require("fs");
|
||||
|
||||
function loadEnv() {
|
||||
const envPath = "/opt/peercortex-app/.env";
|
||||
try {
|
||||
const envContent = fs.readFileSync(envPath, "utf8");
|
||||
envContent.split("\n").forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) return;
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx > 0) {
|
||||
const key = trimmed.substring(0, eqIdx).trim();
|
||||
const val = trimmed.substring(eqIdx + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = val;
|
||||
}
|
||||
});
|
||||
console.log('[Config] Environment variables loaded');
|
||||
} catch (_e) {
|
||||
console.warn("Warning: Could not read .env file at", envPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Automatically load environment variables when module is required
|
||||
loadEnv();
|
||||
|
||||
module.exports = {
|
||||
loadEnv
|
||||
};
|
||||
78
src/backend/services/smtp.js
Normal file
78
src/backend/services/smtp.js
Normal file
@ -0,0 +1,78 @@
|
||||
const tls = require('tls');
|
||||
const net = require('net');
|
||||
|
||||
const SMTP_HOST = 'mail.fichtmueller.org';
|
||||
const SMTP_PORT = 587;
|
||||
const MAIL_TO = 'peercortex@context-x.org';
|
||||
const MAIL_FROM = 'PeerCortex Feedback <rene@fichtmueller.org>';
|
||||
|
||||
function sendFeedbackMail(entry) {
|
||||
const SMTP_USER = process.env.SMTP_USER;
|
||||
const SMTP_PASS = process.env.SMTP_PASS;
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
var b64 = function(s) { return Buffer.from(s).toString('base64'); };
|
||||
var CRLF = '\r\n';
|
||||
var body = 'Category : ' + entry.category + CRLF +
|
||||
'Name : ' + entry.name + CRLF +
|
||||
'ASN : ' + (entry.asn || '-') + CRLF +
|
||||
'Time : ' + entry.timestamp + CRLF + CRLF +
|
||||
entry.message + CRLF + CRLF + '-' + CRLF + 'PeerCortex Feedback';
|
||||
var subj = '[PeerCortex Feedback] ' + entry.category + (entry.asn ? ' - AS' + entry.asn : '');
|
||||
var msg = 'From: ' + MAIL_FROM + CRLF +
|
||||
'To: ' + MAIL_TO + CRLF +
|
||||
'Subject: ' + subj + CRLF +
|
||||
'MIME-Version: 1.0' + CRLF +
|
||||
'Content-Type: text/plain; charset=UTF-8' + CRLF + CRLF +
|
||||
body;
|
||||
|
||||
var socket = net.connect(SMTP_PORT, SMTP_HOST);
|
||||
var tlsSocket = null;
|
||||
var buf = '';
|
||||
var step = 0;
|
||||
var done = false;
|
||||
|
||||
function send(line) {
|
||||
var s = tlsSocket || socket;
|
||||
s.write(line + CRLF);
|
||||
}
|
||||
|
||||
function onData(data) {
|
||||
buf += data.toString();
|
||||
var lines = buf.split(CRLF);
|
||||
buf = lines.pop();
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
var code = parseInt(line.slice(0, 3));
|
||||
if (isNaN(code) || line[3] === '-') continue;
|
||||
if (step === 0 && code === 220) { send('EHLO peercortex.org'); step = 1; }
|
||||
else if (step === 1 && code === 250) { send('STARTTLS'); step = 2; }
|
||||
else if (step === 2 && code === 220) {
|
||||
tlsSocket = tls.connect({ socket: socket, servername: SMTP_HOST, rejectUnauthorized: false }, function() {
|
||||
tlsSocket.on('data', onData);
|
||||
send('EHLO peercortex.org');
|
||||
step = 3;
|
||||
});
|
||||
tlsSocket.on('error', function(e) { if (!done) { done = true; reject(e); } });
|
||||
}
|
||||
else if (step === 3 && code === 250) { send('AUTH LOGIN'); step = 4; }
|
||||
else if (step === 4 && code === 334) { send(b64(SMTP_USER)); step = 5; }
|
||||
else if (step === 5 && code === 334) { send(b64(SMTP_PASS)); step = 6; }
|
||||
else if (step === 6 && code === 235) { send('MAIL FROM:<' + SMTP_USER + '>'); step = 7; }
|
||||
else if (step === 7 && code === 250) { send('RCPT TO:<' + MAIL_TO + '>'); step = 8; }
|
||||
else if (step === 8 && code === 250) { send('DATA'); step = 9; }
|
||||
else if (step === 9 && code === 354) { send(msg + CRLF + '.'); step = 10; }
|
||||
else if (step === 10 && code === 250) { send('QUIT'); if (!done) { done = true; resolve(); } }
|
||||
else if (code >= 400) { if (!done) { done = true; reject(new Error('SMTP ' + code + ': ' + line)); } }
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('data', onData);
|
||||
socket.on('error', function(e) { if (!done) { done = true; reject(e); } });
|
||||
setTimeout(function() { if (!done) { done = true; reject(new Error('SMTP timeout')); } }, 15000);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendFeedbackMail
|
||||
};
|
||||
79
src/features/aspath/routes.ts
Normal file
79
src/features/aspath/routes.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
interface AspathQuery {
|
||||
asn?: string;
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url: string, retries = 1, timeout = 10000): 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 aspathRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get<{ Querystring: AspathQuery }>(
|
||||
'/aspath',
|
||||
async (request: FastifyRequest<{ Querystring: AspathQuery }>, reply: FastifyReply) => {
|
||||
let asnStr = request.query.asn || '';
|
||||
const asn = asnStr.replace(/[^0-9]/g, '');
|
||||
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Cache-Control', 'public, max-age=300');
|
||||
|
||||
if (!asn) {
|
||||
return reply.status(400).send({ error: 'asn required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const pfxUrl = `https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}`;
|
||||
const pfxData = await fetchWithRetry(pfxUrl, 1, 6000);
|
||||
|
||||
let targetPrefix = '';
|
||||
if (pfxData && pfxData.data && pfxData.data.prefixes && pfxData.data.prefixes.length > 0) {
|
||||
targetPrefix = pfxData.data.prefixes[0].prefix;
|
||||
}
|
||||
|
||||
if (!targetPrefix) {
|
||||
return reply.status(200).send({ resource: `AS${asn}`, rrcs: [], error: 'No prefixes announced' });
|
||||
}
|
||||
|
||||
const lgUrl = `https://stat.ripe.net/data/looking-glass/data.json?resource=${encodeURIComponent(targetPrefix)}`;
|
||||
const lgData = await fetchWithRetry(lgUrl, 1, 10000);
|
||||
|
||||
const rrcs = (lgData && lgData.data && lgData.data.rrcs) || [];
|
||||
const results = rrcs.map((r: any) => ({
|
||||
rrc: r.rrc,
|
||||
location: r.location,
|
||||
peers: (r.peers || []).map((p: any) => ({
|
||||
asn: p.asn,
|
||||
as_path: p.as_path,
|
||||
community: p.community,
|
||||
next_hop: p.next_hop,
|
||||
}))
|
||||
}));
|
||||
|
||||
return reply.status(200).send({
|
||||
resource: targetPrefix,
|
||||
rrcs: results,
|
||||
total_rrcs: rrcs.length
|
||||
});
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
93
src/features/asset-expand/routes.ts
Normal file
93
src/features/asset-expand/routes.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
interface AssetExpandQuery {
|
||||
set?: string;
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url: string, retries = 1, timeout = 10000): 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 assetExpandRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get<{ Querystring: AssetExpandQuery }>(
|
||||
'/asset-expand',
|
||||
async (request: FastifyRequest<{ Querystring: AssetExpandQuery }>, reply: FastifyReply) => {
|
||||
const setName = request.query.set || '';
|
||||
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
if (!setName) {
|
||||
return reply.status(400).send({ error: 'set required (e.g. AS-FLEXOPTIX)' });
|
||||
}
|
||||
|
||||
async function expandSet(name: string, depth: number, visited: Set<string>): Promise<{ asns: string[], sets: string[] }> {
|
||||
if (depth > 4 || visited.has(name)) return { asns: [], sets: [] };
|
||||
visited.add(name);
|
||||
|
||||
const url = `https://rest.db.ripe.net/search.json?query-string=${encodeURIComponent(name)}&type-filter=as-set&flags=no-referenced`;
|
||||
let data;
|
||||
try {
|
||||
data = await fetchWithRetry(url);
|
||||
} catch (e) {
|
||||
// If RIPE DB fails on a specific set, gracefully continue with empty array for that branch
|
||||
return { asns: [], sets: [] };
|
||||
}
|
||||
|
||||
const asns: string[] = [];
|
||||
const sets: string[] = [];
|
||||
|
||||
if (data && data.objects && data.objects.object) {
|
||||
for (const obj of data.objects.object) {
|
||||
const attrs = (obj.attributes && obj.attributes.attribute) || [];
|
||||
for (const a of attrs) {
|
||||
if (a.name === 'members') {
|
||||
for (const m of (a.value || '').split(/[,\s]+/).filter(Boolean)) {
|
||||
if (/^AS\d+$/i.test(m)) asns.push(m.toUpperCase());
|
||||
else if (m.startsWith('AS-')) sets.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const sub of sets.slice(0, 10)) { // Limit sub-sets to prevent overwhelming API
|
||||
const sub_r = await expandSet(sub, depth + 1, visited);
|
||||
asns.push(...sub_r.asns);
|
||||
}
|
||||
return { asns: [...new Set(asns)], sets };
|
||||
}
|
||||
|
||||
try {
|
||||
const visited = new Set<string>();
|
||||
const result = await expandSet(setName.toUpperCase(), 0, visited);
|
||||
result.asns.sort((a, b) => parseInt(a.slice(2)) - parseInt(b.slice(2)));
|
||||
|
||||
return reply.status(200).send({
|
||||
set: setName.toUpperCase(),
|
||||
count: result.asns.length,
|
||||
asns: result.asns,
|
||||
sub_sets: result.sets
|
||||
});
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
60
src/features/bgp-communities/bgp-community-db.ts
Normal file
60
src/features/bgp-communities/bgp-community-db.ts
Normal file
@ -0,0 +1,60 @@
|
||||
export interface BGPCommunity {
|
||||
name: string;
|
||||
desc: string;
|
||||
type: 'rfc' | 'carrier' | 'ixp';
|
||||
asn?: number;
|
||||
}
|
||||
|
||||
export const BGP_COMMUNITY_DB: Record<string, BGPCommunity> = {
|
||||
'65535:666': { name:'BLACKHOLE', desc:'RFC 7999 — Null-route this prefix', type:'rfc' },
|
||||
'65535:65281':{ name:'NO_EXPORT', desc:'RFC 1997 — Do not export to EBGP peers', type:'rfc' },
|
||||
'65535:65282':{ name:'NO_ADVERTISE', desc:'RFC 1997 — Do not advertise to any peer', type:'rfc' },
|
||||
'65535:65283':{ name:'NO_EXPORT_SUBCONFED', desc:'RFC 1997 — No export to sub-AS', type:'rfc' },
|
||||
// Lumen/CenturyLink 3356
|
||||
'3356:2': { name:'Lumen Peer', desc:'Lumen — Learned from settlement-free peer', type:'carrier', asn:3356 },
|
||||
'3356:3': { name:'Lumen Customer', desc:'Lumen — Learned from customer', type:'carrier', asn:3356 },
|
||||
'3356:100':{ name:'Lumen Blackhole', desc:'Lumen — RTBH trigger', type:'carrier', asn:3356 },
|
||||
// NTT 2914
|
||||
'2914:420':{ name:'NTT Peer', desc:'NTT — Settlement-free peer route', type:'carrier', asn:2914 },
|
||||
'2914:421':{ name:'NTT Customer', desc:'NTT — Downstream customer route', type:'carrier', asn:2914 },
|
||||
'2914:666':{ name:'NTT Blackhole', desc:'NTT — RTBH trigger', type:'carrier', asn:2914 },
|
||||
// Cogent 174
|
||||
'174:21000':{ name:'Cogent Peer', desc:'Cogent — Learned from peer', type:'carrier', asn:174 },
|
||||
'174:22000':{ name:'Cogent Customer', desc:'Cogent — Learned from customer', type:'carrier', asn:174 },
|
||||
'174:666': { name:'Cogent Blackhole', desc:'Cogent — RTBH trigger', type:'carrier', asn:174 },
|
||||
// HE 6939
|
||||
'6939:7000':{ name:'HE RTBH', desc:'Hurricane Electric — Remotely triggered blackhole', type:'carrier', asn:6939 },
|
||||
// Telia 1299
|
||||
'1299:35000':{ name:'Telia RTBH', desc:'Telia — Remotely triggered blackhole', type:'carrier', asn:1299 },
|
||||
'1299:3000': { name:'Telia Peer', desc:'Telia — Learned from peer', type:'carrier', asn:1299 },
|
||||
// DTAG 3320
|
||||
'3320:1278':{ name:'DTAG Peer', desc:'Deutsche Telekom — Peering route', type:'carrier', asn:3320 },
|
||||
'3320:2001':{ name:'DTAG Customer', desc:'Deutsche Telekom — Customer route', type:'carrier', asn:3320 },
|
||||
'3320:9900':{ name:'DTAG Blackhole', desc:'Deutsche Telekom — RTBH trigger', type:'carrier', asn:3320 },
|
||||
// Cloudflare 13335
|
||||
'13335:10000':{ name:'CF Customer', desc:'Cloudflare — Customer route', type:'carrier', asn:13335 },
|
||||
'13335:10010':{ name:'CF Peering', desc:'Cloudflare — Learned via peering', type:'carrier', asn:13335 },
|
||||
'13335:20050':{ name:'CF Blackhole', desc:'Cloudflare — RTBH trigger', type:'carrier', asn:13335 },
|
||||
// Zayo 6461
|
||||
'6461:9000':{ name:'Zayo Blackhole', desc:'Zayo — RTBH trigger', type:'carrier', asn:6461 },
|
||||
// DE-CIX 6695
|
||||
'6695:1000':{ name:'DE-CIX RS', desc:'DE-CIX Frankfurt — Route server export', type:'ixp', asn:6695 },
|
||||
'6695:1001':{ name:'DE-CIX RS peer', desc:'DE-CIX — Received from route server peer', type:'ixp', asn:6695 },
|
||||
// AMS-IX 1200
|
||||
'1200:100': { name:'AMS-IX RS', desc:'AMS-IX — Route server export', type:'ixp', asn:1200 },
|
||||
// LINX 5459
|
||||
'5459:1001':{ name:'LINX RS', desc:'LINX — Route server export', type:'ixp', asn:5459 },
|
||||
// Seabone/TI 6762
|
||||
'6762:30': { name:'Seabone Customer', desc:'Telecom Italia Seabone — Customer route', type:'carrier', asn:6762 },
|
||||
// Turkcell 9121
|
||||
'9121:666': { name:'Turkcell BH', desc:'Turkcell — RTBH trigger', type:'carrier', asn:9121 },
|
||||
};
|
||||
|
||||
export function decodeCommunities(communityList: any[]): Array<{ raw: string; known: BGPCommunity | null }> {
|
||||
if (!Array.isArray(communityList)) return [];
|
||||
return communityList.map(c => {
|
||||
const key = Array.isArray(c) ? c.join(':') : String(c);
|
||||
const known = BGP_COMMUNITY_DB[key] || null;
|
||||
return { raw: key, known };
|
||||
});
|
||||
}
|
||||
65
src/features/bgp-communities/routes.ts
Normal file
65
src/features/bgp-communities/routes.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { BGP_COMMUNITY_DB, decodeCommunities } from './bgp-community-db.js';
|
||||
|
||||
interface CommunityQuery {
|
||||
asn?: string;
|
||||
}
|
||||
|
||||
export async function bgpCommunitiesRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get<{ Querystring: CommunityQuery }>(
|
||||
'/communities',
|
||||
async (request: FastifyRequest<{ Querystring: CommunityQuery }>, reply: FastifyReply) => {
|
||||
let asnStr = request.query.asn || '';
|
||||
const asn = asnStr.replace(/[^0-9]/g, '');
|
||||
|
||||
if (!asn) {
|
||||
return reply.status(400).send({ error: 'asn required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://stat.ripe.net/data/bgp-state/data.json?resource=AS${asn}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`RIPE Stat API returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const rawComms: string[] = [];
|
||||
if (data && data.data && data.data.bgp_state) {
|
||||
for (const entry of data.data.bgp_state.slice(0, 50)) {
|
||||
if (entry.community) {
|
||||
rawComms.push(...entry.community);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unique = [...new Set(rawComms.map((c: any) => Array.isArray(c) ? c.join(':') : String(c)))];
|
||||
const decoded = unique.map(k => ({ raw: k, known: BGP_COMMUNITY_DB[k] || null }));
|
||||
|
||||
// Cache control
|
||||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
|
||||
return reply.send({
|
||||
asn: `AS${asn}`,
|
||||
communities: decoded,
|
||||
db_size: Object.keys(BGP_COMMUNITY_DB).length
|
||||
});
|
||||
} catch (error: any) {
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
return reply.status(200).send({
|
||||
asn: `AS${asn}`,
|
||||
communities: [],
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
51
src/features/changelog/routes.ts
Normal file
51
src/features/changelog/routes.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const CHANGELOG_PATH = process.env.CHANGELOG_PATH || '/opt/peercortex-app/CHANGELOG.md';
|
||||
|
||||
export async function changelogRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get(
|
||||
'/changelog-data',
|
||||
{
|
||||
// Override prefix — this is a top-level route, not under /api
|
||||
config: { noPrefix: true }
|
||||
},
|
||||
async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
try {
|
||||
const md = fs.readFileSync(CHANGELOG_PATH, 'utf8');
|
||||
const entries: any[] = [];
|
||||
let current: any = null;
|
||||
let currentSection: any = null;
|
||||
|
||||
for (const line of md.split('\n')) {
|
||||
const vMatch = line.match(/^## (?:v|\[)?([\d.]+)\]? — (.+)/);
|
||||
if (vMatch) {
|
||||
if (current) entries.push(current);
|
||||
current = { version: vMatch[1], date: vMatch[2].trim(), sections: [] };
|
||||
currentSection = null;
|
||||
continue;
|
||||
}
|
||||
const sMatch = line.match(/^### (.+)/);
|
||||
if (sMatch && current) {
|
||||
currentSection = { name: sMatch[1], items: [] };
|
||||
current.sections.push(currentSection);
|
||||
continue;
|
||||
}
|
||||
const iMatch = line.match(/^- (.+)/);
|
||||
if (iMatch && currentSection) {
|
||||
currentSection.items.push(iMatch[1]);
|
||||
}
|
||||
}
|
||||
if (current) entries.push(current);
|
||||
|
||||
return reply.status(200).send(entries);
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
76
src/features/global-infra/routes.ts
Normal file
76
src/features/global-infra/routes.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
// In-memory cache
|
||||
let globalFacCache: { ts: number; data: any } | null = null;
|
||||
const FAC_TTL = 24 * 60 * 60 * 1000;
|
||||
|
||||
async function fetchWithRetry(url: string, retries = 1, timeout = 30000): 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 globalInfraRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get(
|
||||
'/global-infra',
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
if (globalFacCache && Date.now() - globalFacCache.ts < FAC_TTL) {
|
||||
return reply.status(200).send(globalFacCache.data);
|
||||
}
|
||||
|
||||
try {
|
||||
const PEERINGDB_API_URL = 'https://www.peeringdb.com/api';
|
||||
|
||||
const [facData, ixData] = await Promise.all([
|
||||
fetchWithRetry(`${PEERINGDB_API_URL}/fac?depth=1&limit=3000`, 1, 30000),
|
||||
fetchWithRetry(`${PEERINGDB_API_URL}/ix?depth=1&limit=1000`, 1, 30000),
|
||||
]);
|
||||
|
||||
const facs = (facData && facData.data || [])
|
||||
.filter((f: any) => f.latitude && f.longitude)
|
||||
.map((f: any) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
city: f.city,
|
||||
country: f.country,
|
||||
lat: +f.latitude,
|
||||
lng: +f.longitude
|
||||
}));
|
||||
|
||||
const ixps = (ixData && ixData.data || [])
|
||||
.filter((ix: any) => ix.city && ix.country)
|
||||
.map((ix: any) => ({
|
||||
id: ix.id,
|
||||
name: ix.name,
|
||||
city: ix.city,
|
||||
country: ix.country,
|
||||
website: ix.website
|
||||
}));
|
||||
|
||||
const result = { facs, ixps };
|
||||
globalFacCache = { ts: Date.now(), data: result };
|
||||
|
||||
return reply.status(200).send(result);
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
73
src/features/hijack-subscribe/routes.ts
Normal file
73
src/features/hijack-subscribe/routes.ts
Normal file
@ -0,0 +1,73 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
91
src/features/irr-audit/routes.ts
Normal file
91
src/features/irr-audit/routes.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
interface IrrAuditQuery {
|
||||
asn?: string;
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url: string, retries = 1, timeout = 20000): 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 irrAuditRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get<{ Querystring: IrrAuditQuery }>(
|
||||
'/irr-audit',
|
||||
async (request: FastifyRequest<{ Querystring: IrrAuditQuery }>, reply: FastifyReply) => {
|
||||
let asnStr = request.query.asn || '';
|
||||
const asn = asnStr.replace(/[^0-9]/g, '');
|
||||
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Cache-Control', 'public, max-age=1800');
|
||||
|
||||
if (!asn) {
|
||||
return reply.status(400).send({ error: 'asn required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const nlnogData = await fetchWithRetry(`https://irrexplorer.nlnog.net/api/prefixes/asn/AS${asn}`);
|
||||
const prefixes = (nlnogData && nlnogData.directOrigin) || [];
|
||||
|
||||
let irrRoutes: string[] = [];
|
||||
let irrDetails: any[] = [];
|
||||
let goodCount = 0;
|
||||
let warnCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const pfx of prefixes) {
|
||||
const hasIrr = pfx.irrRoutes && Object.keys(pfx.irrRoutes).length > 0;
|
||||
const sources = hasIrr ? Object.keys(pfx.irrRoutes) : [];
|
||||
const cat = pfx.categoryOverall || 'unknown';
|
||||
|
||||
if (hasIrr) irrRoutes.push(pfx.prefix);
|
||||
|
||||
irrDetails.push({
|
||||
prefix: pfx.prefix,
|
||||
irr_sources: sources,
|
||||
rpki_status: pfx.rpkiRoutes && pfx.rpkiRoutes.length ? pfx.rpkiRoutes[0].rpkiStatus : 'not-found',
|
||||
category: cat,
|
||||
messages: (pfx.messages || []).map((m: any) => m.text)
|
||||
});
|
||||
|
||||
if (cat === 'success') goodCount++;
|
||||
else if (cat === 'warning') warnCount++;
|
||||
else errorCount++;
|
||||
}
|
||||
|
||||
const actualPfx = prefixes.map((p: any) => p.prefix);
|
||||
const inBgpNotIrr = actualPfx.filter((p: string) => !irrRoutes.includes(p));
|
||||
const score = actualPfx.length ? Math.round((irrRoutes.length / actualPfx.length) * 100) : 0;
|
||||
|
||||
return reply.status(200).send({
|
||||
asn: asn,
|
||||
irr_routes: irrRoutes,
|
||||
actual_prefixes: actualPfx,
|
||||
in_irr_not_bgp: [],
|
||||
in_bgp_not_irr: inBgpNotIrr,
|
||||
score: score,
|
||||
details: irrDetails,
|
||||
summary: { good: goodCount, warning: warnCount, error: errorCount, total: prefixes.length },
|
||||
source: 'NLNOG IRR Explorer'
|
||||
});
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
74
src/features/ix-matrix/routes.ts
Normal file
74
src/features/ix-matrix/routes.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
interface IxMatrixQuery {
|
||||
ix_id?: string;
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url: string, retries = 1, timeout = 10000): 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 ixMatrixRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get<{ Querystring: IxMatrixQuery }>(
|
||||
'/ix-matrix',
|
||||
async (request: FastifyRequest<{ Querystring: IxMatrixQuery }>, reply: FastifyReply) => {
|
||||
let ixIdStr = request.query.ix_id || '';
|
||||
const ixId = ixIdStr.replace(/[^0-9]/g, '');
|
||||
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
if (!ixId) {
|
||||
return reply.status(400).send({ error: 'ix_id required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const PEERINGDB_API_URL = 'https://www.peeringdb.com/api';
|
||||
|
||||
const [netixData, ixData] = await Promise.all([
|
||||
fetchWithRetry(`${PEERINGDB_API_URL}/netixlan?ix_id=${ixId}&depth=1&limit=200`, 1, 15000),
|
||||
fetchWithRetry(`${PEERINGDB_API_URL}/ix/${ixId}`, 1, 10000),
|
||||
]);
|
||||
|
||||
const ix = ixData && ixData.data && ixData.data[0];
|
||||
|
||||
const members = ((netixData && netixData.data) || []).map((m: any) => ({
|
||||
asn: m.asn,
|
||||
name: m.name,
|
||||
speed: m.speed,
|
||||
ipaddr4: m.ipaddr4,
|
||||
ipaddr6: m.ipaddr6,
|
||||
policy: m.policy_general
|
||||
}));
|
||||
|
||||
members.sort((a: any, b: any) => (b.speed || 0) - (a.speed || 0));
|
||||
|
||||
return reply.status(200).send({
|
||||
ix_id: ixId,
|
||||
ix_name: ix && ix.name,
|
||||
ix_city: ix && ix.city,
|
||||
members,
|
||||
member_count: members.length
|
||||
});
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
67
src/features/looking-glass/routes.ts
Normal file
67
src/features/looking-glass/routes.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
interface LookingGlassQuery {
|
||||
prefix?: string;
|
||||
asn?: string;
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url: string, retries = 1, timeout = 10000): 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 lookingGlassRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get<{ Querystring: LookingGlassQuery }>(
|
||||
'/looking-glass',
|
||||
async (request: FastifyRequest<{ Querystring: LookingGlassQuery }>, reply: FastifyReply) => {
|
||||
const resource = request.query.prefix || request.query.asn || '';
|
||||
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
|
||||
if (!resource) {
|
||||
return reply.status(400).send({ error: 'prefix or asn required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://stat.ripe.net/data/looking-glass/data.json?resource=${encodeURIComponent(resource)}`;
|
||||
const data = await fetchWithRetry(url, 1, 6000);
|
||||
|
||||
const rrcs = (data && data.data && data.data.rrcs) || [];
|
||||
const results = rrcs.slice(0, 15).map((rrc: any) => ({
|
||||
rrc: rrc.rrc,
|
||||
location: rrc.location,
|
||||
peers: (rrc.peers || []).slice(0, 5).map((p: any) => ({
|
||||
asn: p.asn_origin,
|
||||
as_path: p.as_path,
|
||||
community: p.community,
|
||||
next_hop: p.next_hop,
|
||||
}))
|
||||
}));
|
||||
|
||||
return reply.status(200).send({
|
||||
resource,
|
||||
rrcs: results,
|
||||
total_rrcs: rrcs.length
|
||||
});
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
128
src/features/prefix-changes/routes.ts
Normal file
128
src/features/prefix-changes/routes.ts
Normal file
@ -0,0 +1,128 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
117
src/features/rib/routes.ts
Normal file
117
src/features/rib/routes.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
interface RibPrefixQuery {
|
||||
prefix?: string;
|
||||
router?: string;
|
||||
}
|
||||
|
||||
interface RibDumpQuery {
|
||||
router?: string;
|
||||
asn?: string;
|
||||
limit?: string;
|
||||
}
|
||||
|
||||
// risClient is injected from outside; lazy import avoids circular deps at startup
|
||||
let risClient: any = null;
|
||||
|
||||
export function setRisClient(client: any): void {
|
||||
risClient = client;
|
||||
}
|
||||
|
||||
export async function ribRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
// GET /api/rib/prefix
|
||||
fastify.get<{ Querystring: RibPrefixQuery }>(
|
||||
'/rib/prefix',
|
||||
async (request: FastifyRequest<{ Querystring: RibPrefixQuery }>, reply: FastifyReply) => {
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
|
||||
if (!risClient) {
|
||||
return reply.status(503).send({ error: 'bio-rd RIS not configured' });
|
||||
}
|
||||
|
||||
const prefix = request.query.prefix || '';
|
||||
const routerParam = request.query.router || 'default';
|
||||
|
||||
if (!prefix) {
|
||||
return reply.status(400).send({ error: 'prefix required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const routers = await risClient.getRouters();
|
||||
const routerName = (routerParam === 'default' && routers.length > 0) ? routers[0] : routerParam;
|
||||
const [routes, longer] = await Promise.all([
|
||||
risClient.lpm(routerName, prefix),
|
||||
risClient.getLonger(routerName, prefix),
|
||||
]);
|
||||
return reply.status(200).send({
|
||||
prefix,
|
||||
router: routerName,
|
||||
routes: routes || [],
|
||||
moreSpecifics: (longer || []).slice(0, 20),
|
||||
source: 'bio-rd-local',
|
||||
latencyMs: Date.now() - t0,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/rib/routers
|
||||
fastify.get(
|
||||
'/rib/routers',
|
||||
async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
|
||||
if (!risClient) {
|
||||
return reply.status(503).send({ error: 'bio-rd RIS not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
const routers = await risClient.getRouters();
|
||||
return reply.status(200).send({ routers: routers || [], source: 'bio-rd-local' });
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/rib/dump
|
||||
fastify.get<{ Querystring: RibDumpQuery }>(
|
||||
'/rib/dump',
|
||||
async (request: FastifyRequest<{ Querystring: RibDumpQuery }>, reply: FastifyReply) => {
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
|
||||
if (!risClient) {
|
||||
return reply.status(503).send({ error: 'bio-rd RIS not configured' });
|
||||
}
|
||||
|
||||
const router = request.query.router || '';
|
||||
const asnFilter = request.query.asn ? parseInt(request.query.asn, 10) : undefined;
|
||||
const limit = Math.min(parseInt(request.query.limit || '100', 10), 1000);
|
||||
|
||||
if (!router) {
|
||||
return reply.status(400).send({ error: 'router required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const allRoutes = await risClient.dumpRib(router, 'default', asnFilter);
|
||||
const routes = (allRoutes || []).slice(0, limit);
|
||||
return reply.status(200).send({
|
||||
router,
|
||||
routes,
|
||||
total: (allRoutes || []).length,
|
||||
source: 'bio-rd-local',
|
||||
latencyMs: Date.now() - t0,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
55
src/features/rpki-history/routes.ts
Normal file
55
src/features/rpki-history/routes.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
interface RpkiHistoryQuery {
|
||||
asn?: string;
|
||||
}
|
||||
|
||||
export async function rpkiHistoryRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get<{ Querystring: RpkiHistoryQuery }>(
|
||||
'/rpki-history',
|
||||
async (request: FastifyRequest<{ Querystring: RpkiHistoryQuery }>, reply: FastifyReply) => {
|
||||
let asnStr = request.query.asn || '';
|
||||
const asn = asnStr.replace(/[^0-9]/g, '');
|
||||
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
if (!asn) {
|
||||
return reply.status(400).send({ error: 'asn required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://stat.ripe.net/data/routing-history/data.json?resource=AS${asn}&max_rows=100`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`RIPE Stat returned HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
const byOrigin = (data && data.data && data.data.by_origin) || [];
|
||||
const prefixes: any[] = [];
|
||||
|
||||
for (const orig of byOrigin) {
|
||||
if (orig.prefixes) {
|
||||
for (const pfx of orig.prefixes) {
|
||||
prefixes.push(pfx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(200).send({
|
||||
asn: asn,
|
||||
prefixes: prefixes,
|
||||
source: 'RIPE Stat routing-history'
|
||||
});
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
52
src/features/submarine-cables/routes.ts
Normal file
52
src/features/submarine-cables/routes.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
// In-memory cache
|
||||
let subCableCache: { ts: number; data: any } | null = null;
|
||||
const CABLE_TTL = 24 * 60 * 60 * 1000;
|
||||
|
||||
async function fetchWithRetry(url: string, retries = 1, timeout = 30000): 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 submarineCablesRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get(
|
||||
'/submarine-cables',
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
if (subCableCache && Date.now() - subCableCache.ts < CABLE_TTL) {
|
||||
return reply.status(200).send(subCableCache.data);
|
||||
}
|
||||
|
||||
try {
|
||||
const cableData = await fetchWithRetry("https://www.submarinecablemap.com/api/v3/cable/cable-geo.json", 1, 30000);
|
||||
|
||||
if (cableData) {
|
||||
subCableCache = { ts: Date.now(), data: cableData };
|
||||
return reply.status(200).send(cableData);
|
||||
}
|
||||
|
||||
return reply.status(503).send({ error: "Submarine cable data unavailable" });
|
||||
} catch (e: any) {
|
||||
return reply.status(500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user