Compare commits
2 Commits
f0fe8125e0
...
f1b1a3a940
| Author | SHA1 | Date | |
|---|---|---|---|
| f1b1a3a940 | |||
|
|
b93492edff |
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "peercortex",
|
"name": "peercortex",
|
||||||
"version": "0.6.5",
|
"version": "0.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "peercortex",
|
"name": "peercortex",
|
||||||
"version": "0.6.5",
|
"version": "0.7.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "^1.14.3",
|
"@grpc/grpc-js": "^1.14.3",
|
||||||
|
|||||||
685
server.js
685
server.js
@ -8,22 +8,7 @@ const localDb = require('./local-db-client');
|
|||||||
console.log('[PeerCortex] Local DB client initialized');
|
console.log('[PeerCortex] Local DB client initialized');
|
||||||
|
|
||||||
// Load .env file
|
// Load .env file
|
||||||
const envPath = "/opt/peercortex-app/.env";
|
require('./src/backend/config');
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
|
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
|
||||||
const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1";
|
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";
|
const VISITORS_FILE = "/opt/peercortex-app/visitors.json";
|
||||||
|
|
||||||
// ── SMTP / Email ──────────────────────────────────────────────
|
// ── SMTP / Email ──────────────────────────────────────────────
|
||||||
const SMTP_HOST = 'mail.fichtmueller.org';
|
const { sendFeedbackMail } = require('./src/backend/services/smtp');
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ── SMTP / Email ──────────────────────────────────────────────
|
// ── SMTP / Email ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@ -309,59 +221,7 @@ function trackVisitor(req) {
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ── BGP Community Database ─────────────────────────────────────
|
// ── BGP Community Database ─────────────────────────────────────
|
||||||
const BGP_COMMUNITY_DB = {
|
// Migrated to src/features/bgp-communities/bgp-community-db.ts
|
||||||
'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 };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hijack Monitoring ──────────────────────────────────────────
|
// ── Hijack Monitoring ──────────────────────────────────────────
|
||||||
const HIJACK_SUBS_FILE = '/opt/peercortex-app/hijack-subs.json';
|
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)
|
// Feature 28: Submarine Cable overlay (TeleGeography proxy)
|
||||||
if (reqPath === "/api/submarine-cables") {
|
// ── Submarine Cables map data ─────────────────────────────────
|
||||||
const CABLE_TTL = 24 * 60 * 60 * 1000;
|
// Migrated to src/features/submarine-cables/
|
||||||
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" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Feature 29: Global datacenter/IXP map (PeeringDB proxy)
|
// ── Global datacenter/IXP map (PeeringDB proxy) ───────────────
|
||||||
if (reqPath === "/api/global-infra") {
|
// Migrated to src/features/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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ── Changelog page ─────────────────────────────────────────
|
// ── Changelog page ─────────────────────────────────────────
|
||||||
@ -4909,500 +4728,42 @@ ${html}
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── BGP Community Decoder ────────────────────────────────────
|
// ── BGP Community Decoder ────────────────────────────────────
|
||||||
if (reqPath === '/api/communities') {
|
// Migrated to src/features/bgp-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 }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── IRR Audit ─────────────────────────────────────────────────
|
// ── IRR Audit ─────────────────────────────────────────────────
|
||||||
if (reqPath.startsWith('/api/irr-audit')) {
|
// Migrated to src/features/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}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── AS-SET Expander ───────────────────────────────────────────
|
// ── AS-SET Expander ───────────────────────────────────────────
|
||||||
if (reqPath.startsWith('/api/asset-expand')) {
|
// Migrated to src/features/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}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Routing History (prefix table via RIPE Stat routing-history) ──
|
// ── Routing History (prefix table via RIPE Stat routing-history) ──
|
||||||
if (reqPath.startsWith('/api/rpki-history')) {
|
// Migrated to src/features/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}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── AS-PATH Visualizer (RIPE Stat looking-glass) ────────────────
|
// ── AS-PATH Visualizer (RIPE Stat looking-glass) ────────────────
|
||||||
if (reqPath.startsWith('/api/aspath')) {
|
// Migrated to src/features/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}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Looking Glass (RIPE Stat) ─────────────────────────────────
|
// ── Looking Glass (RIPE Stat) ─────────────────────────────────
|
||||||
if (reqPath.startsWith('/api/looking-glass')) {
|
// Migrated to src/features/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}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── IXP Peering Matrix ────────────────────────────────────────
|
// ── IXP Peering Matrix ────────────────────────────────────────
|
||||||
if (reqPath.startsWith('/api/ix-matrix')) {
|
// Migrated to src/features/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}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hijack Subscribe ──────────────────────────────────────────
|
// ── Hijack Subscribe ──────────────────────────────────────────
|
||||||
if (reqPath === '/api/hijack-subscribe' && req.method === 'POST') {
|
// Migrated to src/features/hijack-subscribe/
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
// ── Hijack Alerts (legacy read) ───────────────────────────────
|
||||||
|
// Migrated to src/routes/hijack-alerts (Fastify feature)
|
||||||
|
|
||||||
// ── Changelog JSON API ────────────────────────────────────────
|
// ── Changelog JSON API ────────────────────────────────────────
|
||||||
if (reqPath === '/changelog-data') {
|
// Migrated to src/features/changelog/
|
||||||
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}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── bio-rd RIB: prefix lookup ──────────────────────────────────
|
// ── bio-rd RIB routes ─────────────────────────────────────────
|
||||||
if (reqPath === '/api/rib/prefix') {
|
// Migrated to src/features/rib/
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Prefix Changes ──────────────────────────────────────────────
|
// ── Prefix Changes ──────────────────────────────────────────────
|
||||||
if (reqPath === '/api/prefix-changes') {
|
// Migrated to src/features/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 }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 404
|
// 404
|
||||||
res.writeHead(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 { hijackAlertsRoutes } from '../routes/hijack-alerts'
|
||||||
import { pdfExportRoutes } from '../routes/pdf-export'
|
import { pdfExportRoutes } from '../routes/pdf-export'
|
||||||
import { aspaAdoptionRoutes } from '../routes/aspa-adoption'
|
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) {
|
export async function createApiServer(port: number = 3100) {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
@ -15,6 +28,19 @@ export async function createApiServer(port: number = 3100) {
|
|||||||
await fastify.register(hijackAlertsRoutes, { prefix: '/api' })
|
await fastify.register(hijackAlertsRoutes, { prefix: '/api' })
|
||||||
await fastify.register(pdfExportRoutes, { prefix: '/api' })
|
await fastify.register(pdfExportRoutes, { prefix: '/api' })
|
||||||
await fastify.register(aspaAdoptionRoutes, { 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
|
// Health check endpoint
|
||||||
fastify.get('/health', async () => {
|
fastify.get('/health', async () => {
|
||||||
@ -32,7 +58,7 @@ export async function createApiServer(port: number = 3100) {
|
|||||||
return {
|
return {
|
||||||
name: 'PeerCortex API',
|
name: 'PeerCortex API',
|
||||||
version: '1.0.0',
|
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',
|
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