Compare commits

...

2 Commits

20 changed files with 6900 additions and 666 deletions

4
package-lock.json generated
View File

@ -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
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
};

View 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
};

View 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 });
}
}
);
}

View 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 });
}
}
);
}

View 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 };
});
}

View 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
});
}
}
);
}

View 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 });
}
}
);
}

View 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 });
}
}
);
}

View 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 });
}
}
);
}

View 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 });
}
}
);
}

View 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 });
}
}
);
}

View 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 });
}
}
);
}

View 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
View 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 });
}
}
);
}

View 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 });
}
}
);
}

View 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 });
}
}
);
}