From 35b89c05aa6847e1796510da27049cad261aec9e Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Wed, 8 Apr 2026 23:56:08 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20eliminate=20hanging=20cards=20=E2=80=94?= =?UTF-8?q?=20ASPA/bgproutes/WHOIS/PeeringRec=20all=20responsive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ASPA: 15min result cache + looking-glass timeout 3s (was 8s), hard cap 12s (was 18s) - bgproutes: 15min result cache + 6s timeout on RIB POST (was no timeout), vpCache 1h - WHOIS: 24h cache + RDAP fallback timeouts 3s (was 5s) - Peering Recommendations: replace 20x full /api/lookup with new /api/quick-ix - postJSON: add configurable timeout (was no timeout, caused indefinite hangs) - Frontend: AbortController timeouts on all slow card fetches (ASPA/bgproutes/WHOIS/health) - New /api/quick-ix endpoint: PeeringDB IX data + network name, 1h cached --- CHANGELOG.md | 76 +- CHANGELOG_PENDING.md | 7 + deploy/server.js | 1749 ++++++++++++++++++++++++++++++++++++++++-- public/index.html | 251 +++++- server.js | 317 ++++++-- 5 files changed, 2228 insertions(+), 172 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f31fc3..be03c53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to PeerCortex are documented here. --- +## v0.6.9 — 2026-04-05 + +### Added +- **Resilience Score**: Weighted 4-factor score (1–10) per ASN — Transit Diversity (30%), Peering Breadth (25%), IXP Presence (20%), Path Redundancy (25%). Hard cap at 5.0 when only a single transit provider is detected. Shows a large score digit plus four colour-coded progress bars in the UI. +- **Route Leak Detection**: Heuristic analysis using RIPE Stat neighbour data. Detects two patterns: *sandwich candidates* (Tier-1 appearing as both upstream and downstream) and *Tier-1 as downstream* (unusual re-origination). Reference set: 21 known Tier-1 ASNs. Confidence: medium — pattern-based, not real-time. +- **Data Provenance System**: Every API response field carries `_provenance` metadata — source, validation method (cross-validated / heuristic / computed / single-source), confidence (high / medium / experimental), and an optional note. Shown in the UI as coloured badges next to each card title. +- **MCP Server** (`mcp-server.js`): PeerCortex as MCP tools for Claude Desktop and Claude Code — `lookup_asn`, `compare_networks`, `get_health_report`, `search_network`, `get_resilience_score`. +- **Rotating Daily Audit**: 100 ASNs tested daily, deterministically rotated via SHA256 date seed. Math checks (prefix sums, RPKI sums, IX dedup) + external cross-validation against RIPE Stat and PeeringDB. +- **Daily Audit Email**: HTML report with all tested ASNs, cross-validation columns and critical/warning/ok/skip counts, sent daily at 06:00 UTC. + +### Fixed +- **ASN name fallback**: ASNs with no RIPE Stat holder or RDAP data now resolve name and country from `bgp.he.net` page title and country href — eliminates `Unknown` name entries for unassigned blocks and micro-ISPs. + +--- + +## v0.6.8 — 2026-04-03 + +### Fixed +- **Name fallback via bgp.he.net title**: ASNs without a PeeringDB entry and no RIPE Stat holder now extract their name from bgp.he.net page title (e.g. LLHOST INC. SRL, RIPE NCC ASN block). +- **Country code fallback via bgp.he.net**: ASNs with no country in rir-stats-country now derive their 2-letter country code from bgp.he.net href (e.g. /country/RO, /country/GB). + +### Infrastructure +- Daily automated audit introduced: 103 ASNs validated every 24h. + +--- + ## v0.6.6 — 2026-04-02 ### Added @@ -15,6 +41,12 @@ All notable changes to PeerCortex are documented here. --- +## v0.6.5 — 2026-04-02 + +### Added +- **Name search with autocomplete**: Type any network or organization name in the search bar to get live suggestions. Results are sourced from both RIPE Stat and PeeringDB — covering thousands of registered networks worldwide. Use arrow keys to navigate, Enter or click to select. + +--- ## v0.6.4 — 2026-04-02 @@ -113,47 +145,3 @@ All notable changes to PeerCortex are documented here. | [Cloudflare RPKI](https://rpki.cloudflare.com/) | ASPA objects, ROA validation | | [NLNOG IRR Explorer](https://irrexplorer.nlnog.net/) | IRR registration across all major databases | | [RIPE DB](https://rest.db.ripe.net/) | WHOIS data, IRR objects, AS-SET expansion | - -## v0.6.5 — 2026-04-02 - -### Added -- **Name search with autocomplete**: Type any network or organization name in the search bar to get live suggestions. Results are sourced from both RIPE Stat and PeeringDB — covering thousands of registered networks worldwide. Use arrow keys to navigate, Enter or click to select. - -## [0.6.8] — 2026-04-03 - -### Fixed -- **Name fallback via bgp.he.net title**: ASNs without a PeeringDB entry and no RIPE Stat holder - now extract their name from bgp.he.net page title (e.g. LLHOST INC. SRL, RIPE NCC ASN block) -- **Country code fallback via bgp.he.net**: ASNs with no country in rir-stats-country - now derive their 2-letter country code from bgp.he.net href (e.g. /country/RO, /country/GB) - -### Quality Audit — 2026-04-03 (103 ASNs, dual-run validation) -- **0 CRITICAL data errors** across all 103 audited ASNs -- **97 PERFECT** — 94% with zero issues -- **6 WARNING only** — slow cold-cache on large Tier-1 carriers and minor - source disagreement in external registries (not PeerCortex data errors) -- All mathematical consistency checks passed 103/103: - prefix math · RPKI math · RPKI coverage% · IX dedup · facility counts -- Prefix counts cross-validated against RIPE Stat: no deviation >10% -- IX connections cross-validated against PeeringDB: no deviation >10% - -### Infrastructure -- Daily automated audit introduced: 103 ASNs validated every 24h - -## [0.6.9] — 2026-04-04 - -### Added -- **Resilience Score (1-10)**: Weighted score combining Transit Diversity (30%), - Peering Breadth (25%), IXP Presence (20%), Path Redundancy (25%). - Hard cap at 5.0 when single transit provider detected. - Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB. -- **Route Leak Detection**: Heuristic pattern detection for suspicious routing - relationships (Tier-1 as downstream, sandwich patterns). Confidence: MEDIUM — - pattern-based, not real-time. False positives possible. -- **Data Provenance System**: Every data point in the API response now includes - a _provenance field: source, validation method (cross-validated / heuristic / - computed / single-source), and confidence level (high / medium / experimental). - Visible in UI as colour-coded badges: green = validated, orange = indicative. -- **MCP Server** (mcp-server.js): Exposes PeerCortex as MCP tools for Claude - Desktop / Claude Code. Tools: lookup_asn, compare_networks, get_health_report, - search_network, get_resilience_score. All responses include provenance metadata. diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 2450cf6..14c16c0 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -9,3 +9,10 @@ {"d":"2026-03-30","t":"FIX","m":"enrich: skip Wikipedia disambiguation pages, try first-word fallback for compound names"} {"d":"2026-03-30","t":"INFRA","m":"reisekosten.context-x.org DNS CNAME configured + service live on port 3104"} {"d":"2026-03-30","t":"INFRA","m":"PeerCortex repo created and pushed to Gitea (gitea.context-x.org/rene/PeerCortex)"} +{"d":"2026-04-08","t":"FIX","m":"MANRS check: replace failing Observatory API (auth required) with public participants page scraping (manrs.org/netops/participants/), 24h cache, O(1) Set lookup"} +{"d":"2026-04-08","t":"FIX","m":"IX Route Servers: identified PeeringDB auth/rate-limit as root cause for excluded status — API key verification on Erik pending"} +{"d":"2026-04-08","t":"FEAT","m":"Prefix Changes card: 5 tabs (Announcements, Withdrawals, Origin Changes, RPKI Issues, Live Stream) with custom time range picker — data via RIPE Stat bgp-updates + local ROA store + RIPE RIS Live WebSocket"} +{"d":"2026-04-08","t":"FIX","m":"WHOIS: add 24h module-level cache, reduce RDAP fallback timeouts 5s→3s — eliminates repeated hammering and hangs for non-RIPE ASNs"} +{"d":"2026-04-08","t":"FIX","m":"Peering Recommendations: replace 20 concurrent full lookup calls with new /api/quick-ix endpoint — was hanging indefinitely on every new lookup"} +{"d":"2026-04-08","t":"FIX","m":"ASPA: reduce looking-glass timeout 8s→5s and hard cap 18s→12s — faster response for slow RIPE Stat endpoints"} +{"d":"2026-04-08","t":"FEAT","m":"New /api/quick-ix endpoint: lightweight PeeringDB IX connections + network name, 1h cache"} diff --git a/deploy/server.js b/deploy/server.js index 0158c84..c0f24da 100644 --- a/deploy/server.js +++ b/deploy/server.js @@ -1,6 +1,7 @@ const fs = require("fs"); const http = require("http"); const https = require("https"); +const crypto = require("crypto"); // Load .env file const envPath = "/opt/peercortex-app/.env"; @@ -23,13 +24,388 @@ try { const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || ""; const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1"; +// bio-rd gRPC client (optional — graceful fallback if unavailable) +let risClient = null; +try { + const { createRisClient } = require('./bio-rd-client'); + const BIO_RD_HOST = process.env.BIO_RD_HOST || 'localhost'; + const BIO_RD_PORT = parseInt(process.env.BIO_RD_PORT || '4321'); + risClient = createRisClient(BIO_RD_HOST, BIO_RD_PORT); + console.log(`[bio-rd] RIS client configured → ${BIO_RD_HOST}:${BIO_RD_PORT}`); +} catch (e) { + console.log('[bio-rd] RIS client not available (bio-rd-client.js missing or gRPC not installed)'); +} + const PEERINGDB_API_KEY = process.env.PEERINGDB_API_KEY || ""; const PEERINGDB_API_URL = process.env.PEERINGDB_API_URL || "https://www.peeringdb.com/api"; +// ── Local PeeringDB SQLite (peeringdb-py sync, refreshed daily by cron) ────── +const PEERINGDB_LOCAL_PATH = process.env.PEERINGDB_LOCAL_PATH || "/opt/peeringdb-data/peeringdb.sqlite3"; +let _pdbLocal = null; +function getPdbLocal() { + if (_pdbLocal) return _pdbLocal; + try { + const BetterSqlite3 = require("better-sqlite3"); + if (!fs.existsSync(PEERINGDB_LOCAL_PATH)) return null; + _pdbLocal = new BetterSqlite3(PEERINGDB_LOCAL_PATH, { readonly: true, fileMustExist: true }); + console.log("[PeeringDB-local] SQLite opened:", PEERINGDB_LOCAL_PATH); + return _pdbLocal; + } catch (e) { + console.warn("[PeeringDB-local] Could not open SQLite:", e.message); + return null; + } +} + +// Map API path → SQLite result in { data: [...] } format, emulating the live PDB REST API. +function queryPeeringDBLocal(path) { + const db = getPdbLocal(); + if (!db) return null; + try { + // /net?asn=X + const netAsnMatch = path.match(/^\/net\?asn=(\d+)/); + if (netAsnMatch) { + const rows = db.prepare( + "SELECT n.*, o.name AS org_name FROM peeringdb_network n " + + "LEFT JOIN peeringdb_organization o ON n.org_id = o.id " + + "WHERE n.asn = ? AND n.status = 'ok'" + ).all(parseInt(netAsnMatch[1])); + return { data: rows }; + } + + // /net?status=ok&depth=0 (coverage endpoint — all networks) + if (path === "/net?status=ok&depth=0" || path.startsWith("/net?status=ok")) { + const rows = db.prepare( + "SELECT id, asn, name, aka, website, info_prefixes4, info_prefixes6, " + + "info_type, info_traffic, info_unicast, info_ipv6, policy_general, org_id " + + "FROM peeringdb_network WHERE status = 'ok' ORDER BY asn" + ).all(); + return { data: rows }; + } + + // /netixlan?net_id=X&limit=... or /netixlan?asn=X&limit=... + const netixlanNetId = path.match(/\/netixlan\?net_id=(\d+)/); + if (netixlanNetId) { + const rows = db.prepare( + "SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " + + "ni.operational, ni.bfd_support, il.id AS ixlan_id, " + + "ix.id AS ix_id, ix.name, ix.city, ix.country " + + "FROM peeringdb_network_ixlan ni " + + "LEFT JOIN peeringdb_ixlan il ON ni.ixlan_id = il.id " + + "LEFT JOIN peeringdb_ix ix ON il.ix_id = ix.id " + + "WHERE ni.net_id = ? AND ni.status = 'ok'" + ).all(parseInt(netixlanNetId[1])); + return { data: rows }; + } + const netixlanAsn = path.match(/\/netixlan\?asn=(\d+)/); + if (netixlanAsn) { + const rows = db.prepare( + "SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " + + "ni.operational, ni.bfd_support, il.id AS ixlan_id, " + + "ix.id AS ix_id, ix.name, ix.city, ix.country " + + "FROM peeringdb_network_ixlan ni " + + "LEFT JOIN peeringdb_ixlan il ON ni.ixlan_id = il.id " + + "LEFT JOIN peeringdb_ix ix ON il.ix_id = ix.id " + + "WHERE ni.asn = ? AND ni.status = 'ok'" + ).all(parseInt(netixlanAsn[1])); + return { data: rows }; + } + + // /netixlan?ixlan_id=X + const netixlanIxlanId = path.match(/\/netixlan\?ixlan_id=(\d+)/); + if (netixlanIxlanId) { + const rows = db.prepare( + "SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " + + "n.name AS net_name " + + "FROM peeringdb_network_ixlan ni " + + "LEFT JOIN peeringdb_network n ON ni.net_id = n.id " + + "WHERE ni.ixlan_id = ? AND ni.status = 'ok'" + ).all(parseInt(netixlanIxlanId[1])); + return { data: rows }; + } + + // /netfac?net_id=X + const netfacNetId = path.match(/\/netfac\?net_id=(\d+)/); + if (netfacNetId) { + const rows = db.prepare( + "SELECT nf.id, nf.net_id, f.id AS fac_id, f.name, f.city, f.state, " + + "f.country, f.latitude, f.longitude, f.website " + + "FROM peeringdb_network_facility nf " + + "LEFT JOIN peeringdb_facility f ON nf.fac_id = f.id " + + "WHERE nf.net_id = ? AND nf.status = 'ok'" + ).all(parseInt(netfacNetId[1])); + return { data: rows }; + } + + // /fac?id__in=X,Y,Z&fields=... + const facIdIn = path.match(/\/fac\?id__in=([\d,]+)/); + if (facIdIn) { + const ids = facIdIn[1].split(",").map(Number).filter(Boolean); + if (ids.length === 0) return { data: [] }; + const placeholders = ids.map(() => "?").join(","); + const rows = db.prepare( + "SELECT id, name, city, country, latitude, longitude, website " + + "FROM peeringdb_facility WHERE id IN (" + placeholders + ") AND status = 'ok'" + ).all(...ids); + return { data: rows }; + } + + // /ixfac?ix_id__in=X,Y,Z + const ixfacIxIdIn = path.match(/\/ixfac\?ix_id__in=([\d,]+)/); + if (ixfacIxIdIn) { + const ids = ixfacIxIdIn[1].split(",").map(Number).filter(Boolean); + if (ids.length === 0) return { data: [] }; + const placeholders = ids.map(() => "?").join(","); + const rows = db.prepare( + "SELECT ixf.id, ixf.ix_id, ixf.fac_id, f.latitude, f.longitude, f.city, f.country " + + "FROM peeringdb_ix_facility ixf " + + "LEFT JOIN peeringdb_facility f ON ixf.fac_id = f.id " + + "WHERE ixf.ix_id IN (" + placeholders + ") AND ixf.status = 'ok'" + ).all(...ids); + return { data: rows }; + } + + // /ix?name__contains=X + const ixNameContains = path.match(/\/ix\?name__contains=([^&]+)/); + if (ixNameContains) { + const term = "%" + decodeURIComponent(ixNameContains[1]) + "%"; + const rows = db.prepare( + "SELECT id, name, name_long, city, country, website, region_continent " + + "FROM peeringdb_ix WHERE (name LIKE ? OR name_long LIKE ?) AND status = 'ok' LIMIT 20" + ).all(term, term); + return { data: rows }; + } + + // /ixlan?ix_id=X + const ixlanIxId = path.match(/\/ixlan\?ix_id=(\d+)/); + if (ixlanIxId) { + const rows = db.prepare( + "SELECT id, ix_id, name, rs_asn, arp_sponge, mtu FROM peeringdb_ixlan " + + "WHERE ix_id = ? AND status = 'ok'" + ).all(parseInt(ixlanIxId[1])); + return { data: rows }; + } + + // /net/X (single network by PDB id) + const netById = path.match(/^\/net\/(\d+)$/); + if (netById) { + const row = db.prepare( + "SELECT n.*, o.name AS org_name FROM peeringdb_network n " + + "LEFT JOIN peeringdb_organization o ON n.org_id = o.id " + + "WHERE n.id = ? AND n.status = 'ok'" + ).get(parseInt(netById[1])); + return row ? { data: [row] } : { data: [] }; + } + + return null; // path not handled locally — fall through to live API + } catch (e) { + console.warn("[PeeringDB-local] Query error for", path, ":", e.message); + return null; + } +} + const FEEDBACK_TOKEN = process.env.FEEDBACK_TOKEN || "changeme-set-in-env"; const FEEDBACK_FILE = "/opt/peercortex-app/feedback.json"; +const VISITORS_FILE = "/opt/peercortex-app/visitors.json"; -const UA = "PeerCortex/0.5.0 (+https://peercortex.org; contact: rene.fichtmueller@flexoptix.net)"; +// ── SMTP / Email ────────────────────────────────────────────── +const SMTP_HOST = 'mail.fichtmueller.org'; +const SMTP_PORT = 587; +const SMTP_USER = process.env.SMTP_USER; +const SMTP_PASS = process.env.SMTP_PASS; +const MAIL_TO = 'peercortex@context-x.org'; +const MAIL_FROM = 'PeerCortex Feedback '; + +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 ────────────────────────────────────────────── + + +function loadVisitors() { + try { return JSON.parse(fs.readFileSync(VISITORS_FILE, "utf8")); } catch (_) { return { hashes: [] }; } +} + +function trackVisitor(req) { + const ip = (req.headers["x-forwarded-for"] || "").split(",")[0].trim() || (req.socket && req.socket.remoteAddress) || ""; + const hash = crypto.createHash("sha256").update(ip + "peercortex-salt-2026").digest("hex"); + const data = loadVisitors(); + if (!data.hashes.includes(hash)) { + data.hashes.push(hash); + try { fs.writeFileSync(VISITORS_FILE, JSON.stringify(data)); } catch (_) {} + } + return data.hashes.length; +} + +// ═══════════════════════════════════════════════════════════════ +// PEERCORTEX v0.6.1 — New Features +// ═══════════════════════════════════════════════════════════════ + +// ── BGP Community Database ───────────────────────────────────── +const BGP_COMMUNITY_DB = { + '65535:666': { name:'BLACKHOLE', desc:'RFC 7999 — Null-route this prefix', type:'rfc' }, + '65535:65281':{ name:'NO_EXPORT', desc:'RFC 1997 — Do not export to EBGP peers', type:'rfc' }, + '65535:65282':{ name:'NO_ADVERTISE', desc:'RFC 1997 — Do not advertise to any peer', type:'rfc' }, + '65535:65283':{ name:'NO_EXPORT_SUBCONFED', desc:'RFC 1997 — No export to sub-AS', type:'rfc' }, + // Lumen/CenturyLink 3356 + '3356:2': { name:'Lumen Peer', desc:'Lumen — Learned from settlement-free peer', type:'carrier', asn:3356 }, + '3356:3': { name:'Lumen Customer', desc:'Lumen — Learned from customer', type:'carrier', asn:3356 }, + '3356:100':{ name:'Lumen Blackhole', desc:'Lumen — RTBH trigger', type:'carrier', asn:3356 }, + // NTT 2914 + '2914:420':{ name:'NTT Peer', desc:'NTT — Settlement-free peer route', type:'carrier', asn:2914 }, + '2914:421':{ name:'NTT Customer', desc:'NTT — Downstream customer route', type:'carrier', asn:2914 }, + '2914:666':{ name:'NTT Blackhole', desc:'NTT — RTBH trigger', type:'carrier', asn:2914 }, + // Cogent 174 + '174:21000':{ name:'Cogent Peer', desc:'Cogent — Learned from peer', type:'carrier', asn:174 }, + '174:22000':{ name:'Cogent Customer', desc:'Cogent — Learned from customer', type:'carrier', asn:174 }, + '174:666': { name:'Cogent Blackhole', desc:'Cogent — RTBH trigger', type:'carrier', asn:174 }, + // HE 6939 + '6939:7000':{ name:'HE RTBH', desc:'Hurricane Electric — Remotely triggered blackhole', type:'carrier', asn:6939 }, + // Telia 1299 + '1299:35000':{ name:'Telia RTBH', desc:'Telia — Remotely triggered blackhole', type:'carrier', asn:1299 }, + '1299:3000': { name:'Telia Peer', desc:'Telia — Learned from peer', type:'carrier', asn:1299 }, + // DTAG 3320 + '3320:1278':{ name:'DTAG Peer', desc:'Deutsche Telekom — Peering route', type:'carrier', asn:3320 }, + '3320:2001':{ name:'DTAG Customer', desc:'Deutsche Telekom — Customer route', type:'carrier', asn:3320 }, + '3320:9900':{ name:'DTAG Blackhole', desc:'Deutsche Telekom — RTBH trigger', type:'carrier', asn:3320 }, + // Cloudflare 13335 + '13335:10000':{ name:'CF Customer', desc:'Cloudflare — Customer route', type:'carrier', asn:13335 }, + '13335:10010':{ name:'CF Peering', desc:'Cloudflare — Learned via peering', type:'carrier', asn:13335 }, + '13335:20050':{ name:'CF Blackhole', desc:'Cloudflare — RTBH trigger', type:'carrier', asn:13335 }, + // Zayo 6461 + '6461:9000':{ name:'Zayo Blackhole', desc:'Zayo — RTBH trigger', type:'carrier', asn:6461 }, + // DE-CIX 6695 + '6695:1000':{ name:'DE-CIX RS', desc:'DE-CIX Frankfurt — Route server export', type:'ixp', asn:6695 }, + '6695:1001':{ name:'DE-CIX RS peer', desc:'DE-CIX — Received from route server peer', type:'ixp', asn:6695 }, + // AMS-IX 1200 + '1200:100': { name:'AMS-IX RS', desc:'AMS-IX — Route server export', type:'ixp', asn:1200 }, + // LINX 5459 + '5459:1001':{ name:'LINX RS', desc:'LINX — Route server export', type:'ixp', asn:5459 }, + // Seabone/TI 6762 + '6762:30': { name:'Seabone Customer', desc:'Telecom Italia Seabone — Customer route', type:'carrier', asn:6762 }, + // Turkcell 9121 + '9121:666': { name:'Turkcell BH', desc:'Turkcell — RTBH trigger', type:'carrier', asn:9121 }, +}; + +function decodeCommunities(communityList) { + if (!Array.isArray(communityList)) return []; + return communityList.map(c => { + const key = Array.isArray(c) ? c.join(':') : String(c); + const known = BGP_COMMUNITY_DB[key]; + return { raw: key, known: known || null }; + }); +} + +// ── Hijack Monitoring ────────────────────────────────────────── +const HIJACK_SUBS_FILE = '/opt/peercortex-app/hijack-subs.json'; +const HIJACK_ALERTS_FILE = '/opt/peercortex-app/hijack-alerts.json'; + +function loadHijackSubs() { try { return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE,'utf8')); } catch(_){ return []; } } +function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALERTS_FILE,'utf8')); } catch(_){ return []; } } + +async function checkHijacksForAsn(asn) { + try { + const url = `https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}&${UA}`; + const data = await fetchJSONWithRetry(url, { timeout: 15000 }); + const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix); + return prefixes; + } catch (_) { return []; } +} + +async function runHijackCheck() { + const subs = loadHijackSubs(); + if (!subs.length) return; + const alerts = loadHijackAlerts(); + for (const sub of subs) { + const current = await checkHijacksForAsn(sub.asn); + const baseline = new Set(sub.prefixes || []); + const unexpected = current.filter(p => baseline.size > 0 && !baseline.has(p)); + const missing = [...baseline].filter(p => !current.includes(p)); + if (unexpected.length || missing.length) { + const alert = { + asn: sub.asn, ts: new Date().toISOString(), + unexpected, missing, + msg: `Possible hijack detected for AS${sub.asn}: ${unexpected.length} unexpected, ${missing.length} missing prefixes` + }; + alerts.push(alert); + try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(alerts.slice(-500), null, 2)); } catch(_) {} + } + // Update baseline with current prefixes if no baseline set + if (!sub.prefixes || !sub.prefixes.length) { + sub.prefixes = current; + try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2)); } catch(_) {} + } + } +} +// Run hijack check every 30 minutes +setInterval(runHijackCheck, 30 * 60 * 1000); + + + +const UA = "PeerCortex/0.5.0 (+https://peercortex.org)"; // Static geocode cache for major networking cities (fallback when PDB facility coords missing) const CITY_COORDS = { @@ -82,11 +458,171 @@ function cacheSet(key, data, ttlMs) { } } + +// ── Tier-1 ASN Set (used for route leak heuristics) ───────────────────────── +const TIER1_ASNS = new Set([ + 174, // Cogent + 209, // CenturyLink/Lumen + 286, // KPN + 701, // Verizon/UUNET + 702, // Verizon + 1239, // Sprint + 1273, // Vodafone + 1280, // Internet Systems Consortium + 1299, // Arelion (Telia) + 2914, // NTT + 3257, // GTT + 3320, // Deutsche Telekom + 3356, // Lumen (Level3) + 3491, // PCCW + 5511, // Orange + 6453, // TATA + 6461, // Zayo + 6762, // Telecom Italia Sparkle + 7018, // AT&T + 7473, // SingTel + 12956, // Telxius +]); + const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes const CACHE_TTL_ASPA = 4 * 60 * 60 * 1000; // 4 hours const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 minutes +// ============================================================ +// RDAP Cache — prevents 429 flooding on LACNIC/AFRINIC/APNIC/ARIN +// ============================================================ +const rdapCache = new Map(); // key: asn string, value: { data, ts } +const RDAP_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours + +function rdapCacheGet(asn) { + const e = rdapCache.get(String(asn)); + if (e && (Date.now() - e.ts) < RDAP_CACHE_TTL) return e.data; + return undefined; // undefined = not cached, null = cached miss +} +function rdapCacheSet(asn, data) { + if (rdapCache.size > 5000) { + rdapCache.delete(rdapCache.keys().next().value); + } + rdapCache.set(String(asn), { data, ts: Date.now() }); +} + +// ============================================================ +// WHOIS Cache — 24h TTL, prevents repeated RDAP hammering +// ============================================================ +const whoisCache = new Map(); // key: asn string, value: { data, ts } +const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000; +function whoisCacheGet(asn) { + const e = whoisCache.get(String(asn)); + if (e && (Date.now() - e.ts) < WHOIS_CACHE_TTL) return e.data; + return undefined; +} +function whoisCacheSet(asn, data) { + if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value); + whoisCache.set(String(asn), { data, ts: Date.now() }); +} + +// ============================================================ +// Quick-IX Cache — 1h TTL, for Peering Recommendations +// ============================================================ +const quickIxCache = new Map(); // key: asn string, value: { data, ts } +const QUICK_IX_CACHE_TTL = 60 * 60 * 1000; +function quickIxCacheGet(asn) { + const e = quickIxCache.get(String(asn)); + if (e && (Date.now() - e.ts) < QUICK_IX_CACHE_TTL) return e.data; + return undefined; +} +function quickIxCacheSet(asn, data) { + if (quickIxCache.size > 2000) quickIxCache.delete(quickIxCache.keys().next().value); + quickIxCache.set(String(asn), { data, ts: Date.now() }); +} + +// ============================================================ +// bgproutes.io Vantage Points Cache — 1h TTL, prevents 429 +// ============================================================ +let bgproutesVpCache = null; +let bgproutesVpCacheTs = 0; +const BGPROUTES_VP_TTL = 60 * 60 * 1000; // 1 hour + +// ============================================================ +// bgproutes + ASPA result caches — 15min TTL, prevent re-hitting slow APIs +// ============================================================ +const bgproutesResultCache = new Map(); +const aspaResultCache = new Map(); +const RESULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes +function resultCacheGet(map, key) { + const e = map.get(String(key)); + if (e && (Date.now() - e.ts) < RESULT_CACHE_TTL) return e.data; + return undefined; +} +function resultCacheSet(map, key, data) { + if (map.size > 2000) map.delete(map.keys().next().value); + map.set(String(key), { data, ts: Date.now() }); +} + +// ============================================================ +// MANRS Participants Cache (scraped from public HTML page, 24h TTL) +// ============================================================ +let manrsAsnSet = null; // Set of member ASNs +let manrsLastFetch = 0; +let manrsFetching = false; +const MANRS_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours + +async function ensureManrsCache() { + const now = Date.now(); + if (manrsAsnSet && (now - manrsLastFetch) < MANRS_CACHE_TTL) return; + if (manrsFetching) { + // Wait up to 8s for in-progress fetch + for (let i = 0; i < 80; i++) { + await new Promise(r => setTimeout(r, 100)); + if (manrsAsnSet) return; + } + return; + } + manrsFetching = true; + try { + const html = await new Promise((resolve, reject) => { + const opts = { hostname: "www.manrs.org", path: "/netops/participants/", method: "GET", timeout: 15000, + headers: { "User-Agent": UA, "Accept": "text/html" } }; + const req = https.request(opts, res => { + let body = ""; + res.on("data", d => { body += d; }); + res.on("end", () => resolve(body)); + }); + req.on("error", reject); + req.on("timeout", () => { req.destroy(); reject(new Error("MANRS fetch timeout")); }); + req.end(); + }); + // Extract ASNs from 267490 — may contain multiple space-separated ASNs + const set = new Set(); + const re = /]*class="asns"[^>]*>([\d\s,]+)<\/td>/gi; + let m; + while ((m = re.exec(html)) !== null) { + m[1].split(/[\s,]+/).forEach(a => { const n = a.trim(); if (n) set.add(n); }); + } + if (set.size > 0) { + manrsAsnSet = set; + manrsLastFetch = Date.now(); + console.log("[MANRS] Loaded " + set.size + " participant ASNs from manrs.org"); + } + } catch (e) { + console.warn("[MANRS] Failed to fetch participants:", e.message); + } finally { + manrsFetching = false; + } +} + +function checkManrsMembership(asn) { + if (!manrsAsnSet) return { status: "info", participant: "unknown", message: "MANRS data not yet loaded", note: "https://www.manrs.org/netops/participants/" }; + const isMember = manrsAsnSet.has(String(asn)); + return { + status: isMember ? "pass" : "fail", + participant: isMember, + member_count: manrsAsnSet.size, + note: isMember ? "Confirmed MANRS Network Operator participant" : "Not listed as MANRS participant — https://www.manrs.org/beamanrs/", + }; +} + // ============================================================ // Infrastructure overlay caches let subCableCache = null; // TeleGeography submarine cables (24h) @@ -609,8 +1145,13 @@ function lookupAspaFromRpki(asn) { // PeeringDB semaphore — limits concurrent PDB requests to avoid 429 rate-limits const pdbSemaphore = new Semaphore(5); -// PeeringDB authenticated fetch helper (throttled via semaphore) +// PeeringDB authenticated fetch helper — tries local SQLite first, falls back to live API async function fetchPeeringDB(path, options) { + // Try local SQLite (instant, no rate-limits) — skip large "all networks" calls to live API + const localResult = queryPeeringDBLocal(path); + if (localResult !== null) return localResult; + + // Fallback: live PeeringDB API (throttled via semaphore) const url = PEERINGDB_API_URL + path; const headers = { "User-Agent": UA }; if (PEERINGDB_API_KEY) { @@ -748,6 +1289,7 @@ function postJSON(url, body, options) { return new Promise((resolve) => { const data = JSON.stringify(body); const parsed = new URL(url); + const timeout = (options && options.timeout) || 10000; const reqOptions = { hostname: parsed.hostname, port: parsed.port || 443, @@ -760,10 +1302,15 @@ function postJSON(url, body, options) { ...(options && options.headers ? options.headers : {}), }, }; + let done = false; + const timer = setTimeout(() => { if (!done) { done = true; req.destroy(); resolve(null); } }, timeout); const req = https.request(reqOptions, (res) => { let chunks = ""; res.on("data", (chunk) => (chunks += chunk)); res.on("end", () => { + if (done) return; + done = true; + clearTimeout(timer); try { resolve(JSON.parse(chunks)); } catch (_e) { @@ -771,7 +1318,7 @@ function postJSON(url, body, options) { } }); }); - req.on("error", () => resolve(null)); + req.on("error", () => { if (!done) { done = true; clearTimeout(timer); resolve(null); } }); req.write(data); req.end(); }); @@ -1114,6 +1661,18 @@ async function fetchBgpHeNet(asn) { if (peerMatch) result.peer_count = parseInt(peerMatch[1].replace(/,/g, '')); const countryMatch = html.match(/Country[^<]*<[^>]*>[^<]*<[^>]*>\s*<[^>]*>([^<]+)/i); if (countryMatch) result.country = countryMatch[1].trim(); + // Extract 2-letter country code from href="/country/XX" + const ccMatch = html.match(/href="\/country\/([A-Z]{2})"/i); + if (ccMatch) result.country_code = ccMatch[1].toUpperCase(); + // Extract clean AS name from title: "AS12345 Some Name - bgp.he.net" → "Some Name" + if (titleMatch) { + const rawTitle = titleMatch[1].trim(); + const nameFromTitle = rawTitle.replace(/^AS\d+\s+/i, '').replace(/\s+-\s+bgp\.he\.net.*$/i, '').trim(); + if (nameFromTitle && !nameFromTitle.toLowerCase().includes('bgp.he.net')) { + result.name_from_title = nameFromTitle; + } + } + const lgMatch = html.match(/Looking\s+Glass[^<]*<[^>]*href="([^"]+)"/i); if (lgMatch) result.looking_glass = lgMatch[1]; const descMatch = html.match(/AS\s+Name[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i); @@ -1185,6 +1744,134 @@ async function fetchTopology(targetAsn, depth) { // ============================================================ // Feature 27: WHOIS via RIPE DB // ============================================================ + +// ── Resilience Score ───────────────────────────────────────────────────────── +// Weighted: Transit Diversity 30%, Peering Breadth 25%, IXP Presence 20%, Path Redundancy 25% +// Hard cap at 5.0 when single transit provider detected. +// Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB. +function computeResilienceScore(upstreams, peers, ixConnections, prefixes) { + const upstreamCount = upstreams.length; + const peerCount = peers.length; + const ixCount = [...new Set(ixConnections.map(c => c.ix_id).filter(Boolean))].length; + const prefixCount = prefixes.length; + + // Transit Diversity (0-10) + let transitRaw = 0; + if (upstreamCount === 0) transitRaw = 0; + else if (upstreamCount === 1) transitRaw = 2; + else if (upstreamCount === 2) transitRaw = 5; + else if (upstreamCount <= 4) transitRaw = 7; + else transitRaw = 10; + + // Peering Breadth (0-10) + let peeringRaw = 0; + if (peerCount >= 100) peeringRaw = 10; + else if (peerCount >= 50) peeringRaw = 8; + else if (peerCount >= 20) peeringRaw = 6; + else if (peerCount >= 5) peeringRaw = 4; + else if (peerCount >= 1) peeringRaw = 2; + + // IXP Presence (0-10) + let ixpRaw = 0; + if (ixCount >= 10) ixpRaw = 10; + else if (ixCount >= 6) ixpRaw = 8; + else if (ixCount >= 3) ixpRaw = 6; + else if (ixCount >= 1) ixpRaw = 4; + + // Path Redundancy (0-10) — proxy: prefix diversity + upstream + IXP combination + let pathRaw = 0; + if (upstreamCount >= 2 && ixCount >= 1) pathRaw = 10; + else if (upstreamCount >= 2) pathRaw = 7; + else if (ixCount >= 2) pathRaw = 6; + else if (upstreamCount === 1) pathRaw = 3; + else if (prefixCount > 0) pathRaw = 1; + + const weighted = + transitRaw * 0.30 + + peeringRaw * 0.25 + + ixpRaw * 0.20 + + pathRaw * 0.25; + + const singleTransitCap = upstreamCount === 1; + let score = Math.round(weighted * 10) / 10; + if (singleTransitCap) score = Math.min(score, 5.0); + score = Math.max(1.0, Math.min(10.0, score)); + + // Only return null if truly no data at all + if (upstreamCount === 0 && peerCount === 0 && ixCount === 0 && prefixCount === 0) { + return null; + } + + return { + score, + breakdown: { + transit_diversity: { raw: transitRaw, weighted: Math.round(transitRaw * 0.30 * 10) / 10, upstream_count: upstreamCount }, + peering_breadth: { raw: peeringRaw, weighted: Math.round(peeringRaw * 0.25 * 10) / 10, peer_count: peerCount }, + ixp_presence: { raw: ixpRaw, weighted: Math.round(ixpRaw * 0.20 * 10) / 10, unique_ixps: ixCount }, + path_redundancy: { raw: pathRaw, weighted: Math.round(pathRaw * 0.25 * 10) / 10, prefix_count: prefixCount }, + }, + single_transit_cap_applied: singleTransitCap, + _provenance: { + source: "RIPE Stat asn-neighbours + PeeringDB netixlan", + validation: "cross-validated", + confidence: "high", + note: "All inputs independently validated daily against external sources", + }, + }; +} + +// ── Route Leak Detection ───────────────────────────────────────────────────── +// Heuristic: detects suspicious routing relationships using RIPE Stat neighbour data. +// NOT real-time. False positives possible for large networks with many Tier-1 relationships. +// Confidence: MEDIUM — pattern-based, not path-level analysis. +function computeRouteLeakDetection(upstreams, downstreams, peers) { + const upstreamAsns = new Set(upstreams.map(n => n.asn)); + const downstreamAsns = new Set(downstreams.map(n => n.asn)); + + const tier1Upstreams = upstreams.filter(n => TIER1_ASNS.has(n.asn)); + const tier1Downstreams = downstreams.filter(n => TIER1_ASNS.has(n.asn)); + + const patterns = []; + + // Pattern A: Tier-1 appearing as BOTH upstream AND downstream → sandwich candidate + const sandwich = tier1Upstreams.filter(n => downstreamAsns.has(n.asn)); + sandwich.forEach(n => { + patterns.push({ + type: "sandwich_candidate", + asn: n.asn, + name: n.name, + description: `AS${n.asn} (${n.name}) appears as both upstream and downstream — possible route leak vector`, + }); + }); + + // Pattern B: Tier-1 as downstream (re-originating routes to Tier-1s) + tier1Downstreams.forEach(n => { + if (!upstreamAsns.has(n.asn)) { + patterns.push({ + type: "tier1_downstream", + asn: n.asn, + name: n.name, + description: `AS${n.asn} (${n.name}) is a downstream — unusual for a Tier-1, may indicate leaked routes`, + }); + } + }); + + const detected = patterns.length > 0; + + return { + detected, + patterns, + tier1_upstream_count: tier1Upstreams.length, + tier1_downstream_count: tier1Downstreams.length, + _provenance: { + source: "RIPE Stat asn-neighbours", + validation: "heuristic", + confidence: "medium", + note: "Pattern-based detection only. Not real-time (15-min RIPE RIS snapshot). False positives possible for large networks with legitimate Tier-1 relationships.", + }, + }; +} + async function fetchWhois(resource) { const result = { resource, type: null, data: null, error: null }; try { @@ -1193,6 +1880,14 @@ async function fetchWhois(resource) { result.type = "aut-num"; const asn = trimmed.replace(/^AS/i, ""); + // Check cache first + const cached = whoisCacheGet(asn); + if (cached !== undefined) { + result.data = cached; + if (!cached) result.error = "Not found in any RIR database (cached)"; + return result; + } + // Try RIPE first const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=AS" + asn + "&type-filter=aut-num&source=ripe", { timeout: 5000 }).catch(() => null); if (ripeData && ripeData.objects && ripeData.objects.object) { @@ -1216,9 +1911,10 @@ async function fetchWhois(resource) { export: parsed["export"] || [], remarks: parsed["remarks"] || [], }; + whoisCacheSet(asn, result.data); } - // If RIPE didn't find it, try all other RIRs via RDAP in parallel + // If RIPE didn't find it, try all other RIRs via RDAP in parallel (3s timeout) if (!result.data) { const rdapEndpoints = [ { name: "APNIC", url: "https://rdap.apnic.net/autnum/" + asn }, @@ -1227,7 +1923,7 @@ async function fetchWhois(resource) { { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn }, ]; const rdapResults = await Promise.all(rdapEndpoints.map((ep) => - fetchJSON(ep.url, { timeout: 5000 }).then((d) => { + fetchJSON(ep.url, { timeout: 3000 }).then((d) => { if (!d || d.errorCode || !d.handle) return null; return { source: ep.name, data: d }; }).catch(() => null) @@ -1258,8 +1954,10 @@ async function fetchWhois(resource) { export: [], remarks: remarks, }; + whoisCacheSet(asn, result.data); } else { result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)"; + whoisCacheSet(asn, null); // cache miss to avoid repeated hammering } } } else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) { @@ -1351,8 +2049,12 @@ const server = http.createServer(async (req, res) => { return res.end('shell.html not found'); } } - // v2.peercortex.org → editorial design - const htmlFile = (host === 'v2.peercortex.org') ? "index-editorial.html" : "index.html"; + // v2.peercortex.org → redirect to main domain + if (host === 'v2.peercortex.org') { + res.writeHead(301, { Location: 'https://peercortex.org' + reqPath }); + return res.end(); + } + const htmlFile = "index.html"; try { const html = fs.readFileSync("/opt/peercortex-app/public/" + htmlFile, "utf8"); res.setHeader("Content-Type", "text/html; charset=utf-8"); @@ -1379,6 +2081,85 @@ const server = http.createServer(async (req, res) => { // Feedback API // ============================================================ + + // ── Name Search (RIPE Stat + PeeringDB combined) ───────────── + if (reqPath === '/api/search') { + const params = new URL(req.url, 'http://localhost').searchParams; + const q = (params.get('q') || '').trim(); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=120'); + if (!q || q.length < 2) { res.writeHead(400); return res.end(JSON.stringify({error:'query too short'})); } + try { + const results = []; + const seen = new Set(); + + // Source 1: RIPE Stat searchcomplete (fast, covers ASNs + org names) + try { + const ripeUrl = 'https://stat.ripe.net/data/searchcomplete/data.json?resource=' + encodeURIComponent(q); + const ripeData = await fetchJSONWithRetry(ripeUrl, { timeout: 6000 }); + const cats = ripeData && ripeData.data && ripeData.data.categories || []; + for (var ci = 0; ci < cats.length; ci++) { + var suggs = cats[ci].suggestions || []; + for (var si = 0; si < suggs.length; si++) { + var s = suggs[si]; + var val = (s.value || '').toString(); + // Only ASN results + if (/^AS\d+$/i.test(val) && !seen.has(val)) { + seen.add(val); + // Use description (e.g. "FLEXOPTIX, DE") as the display label + var ripeName = s.description || s.label || val; + results.push({ asn: val.replace(/^AS/i,''), label: ripeName, description: '', source: 'RIPE Stat' }); + } + } + } + } catch(e) { /* RIPE Stat failed, continue */ } + + // Source 2: PeeringDB name search (best for network operator names) + try { + var pdbUrl = 'https://www.peeringdb.com/api/net?name__icontains=' + encodeURIComponent(q) + '&depth=1&limit=10'; + if (PEERINGDB_API_KEY) pdbUrl += '&key=' + PEERINGDB_API_KEY; + const pdbData = await fetchJSONWithRetry(pdbUrl, { timeout: 8000 }); + var nets = pdbData && pdbData.data || []; + for (var ni = 0; ni < nets.length; ni++) { + var net = nets[ni]; + var asnKey = 'AS' + net.asn; + if (net.asn && !seen.has(asnKey)) { + seen.add(asnKey); + var pdbDesc = [net.info_type, net.country].filter(Boolean).join(' · '); + results.push({ + asn: String(net.asn), + label: net.name || asnKey, + description: pdbDesc, + source: 'PeeringDB' + }); + } + } + } catch(e) { /* PeeringDB failed, continue */ } + + // Sort: RIPE results first (usually more relevant for ASN lookup), then PeeringDB + results.sort((a, b) => { + if (a.source === b.source) return 0; + return a.source === 'RIPE Stat' ? -1 : 1; + }); + + res.writeHead(200); + return res.end(JSON.stringify({ q: q, results: results.slice(0, 12) })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({error: e.message})); + } + } + + // GET /api/visitors — unique visitor count + if (reqPath === "/api/visitors" && req.method === "GET") { + res.setHeader("Content-Type", "application/json"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Cache-Control", "no-store"); + const count = trackVisitor(req); + res.writeHead(200); + return res.end(JSON.stringify({ visitors: count })); + } + // OPTIONS preflight (CORS) if (reqPath === '/api/feedback' && req.method === 'OPTIONS') { res.setHeader('Access-Control-Allow-Origin', '*'); @@ -1415,6 +2196,8 @@ const server = http.createServer(async (req, res) => { try { entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8')); } catch (_e) { /* no file yet */ } entries.push(entry); fs.writeFileSync(FEEDBACK_FILE, JSON.stringify(entries, null, 2)); + // Send email async — don't block response + sendFeedbackMail(entry).catch(e => console.error('[MAIL] Failed:', e.message)); return res.end(JSON.stringify({ ok: true, id: entry.id })); } catch (_e) { res.writeHead(500); @@ -1572,7 +2355,7 @@ const server = http.createServer(async (req, res) => { JSON.stringify({ status, service: "PeerCortex", - version: "0.6.0", + version: "0.6.9", timestamp: new Date().toISOString(), uptime_seconds: Math.floor(process.uptime()), memory_mb: Math.round(mem.heapUsed / 1024 / 1024), @@ -1867,6 +2650,11 @@ const server = http.createServer(async (req, res) => { res.writeHead(400); return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); } + const cachedAspa = resultCacheGet(aspaResultCache, rawAsn); + if (cachedAspa !== undefined) { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(cachedAspa)); + } const start = Date.now(); let _aspaDone = false; const _aspaTimer = setTimeout(() => { @@ -1875,11 +2663,11 @@ const server = http.createServer(async (req, res) => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) })); } - }, 18000); + }, 12000); try { const [lgData, neighbourData] = await Promise.all([ - fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 8000 }), - fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 8000 }), + fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 3000 }).catch(() => null), + fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 4000 }), ]); const rrcs = lgData?.data?.rrcs || []; @@ -1954,26 +2742,22 @@ const server = http.createServer(async (req, res) => { _aspaDone = true; clearTimeout(_aspaTimer); const duration = Date.now() - start; - return res.end( - JSON.stringify( - { - meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() }, - asn: parseInt(rawAsn), - detected_providers: detectedProviders, - provider_count: detectedProviders.length, - aspa_object_exists: aspaObjectExists, - aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })), - aspa_declared_count: aspaDeclaredProviders.length, - recommended_aspa: recommendedAspa, - path_analysis: { - total_paths_seen: asPaths.length, - sample_paths: samplePaths, - }, - }, - null, - 2 - ) - ); + const aspaResult = { + meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() }, + asn: parseInt(rawAsn), + detected_providers: detectedProviders, + provider_count: detectedProviders.length, + aspa_object_exists: aspaObjectExists, + aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })), + aspa_declared_count: aspaDeclaredProviders.length, + recommended_aspa: recommendedAspa, + path_analysis: { + total_paths_seen: asPaths.length, + sample_paths: samplePaths, + }, + }; + resultCacheSet(aspaResultCache, rawAsn, aspaResult); + return res.end(JSON.stringify(aspaResult, null, 2)); } catch (err) { if (!_aspaDone) { _aspaDone = true; @@ -1994,13 +2778,27 @@ const server = http.createServer(async (req, res) => { res.writeHead(400); return res.end(JSON.stringify({ error: "Need asn or prefix parameter" })); } + const cacheKeyBgr = rawAsn || prefix; + const cachedBgr = resultCacheGet(bgproutesResultCache, cacheKeyBgr); + if (cachedBgr !== undefined) { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(cachedBgr)); + } const start = Date.now(); try { const result = { meta: { timestamp: new Date().toISOString() }, vantage_points: null, routes: null }; - const vpData = await fetchJSON(BGPROUTES_API_URL + "/vantage_points", { - headers: { "x-api-key": BGPROUTES_API_KEY }, - }); + // Use module-level vantage_points cache (1h TTL) to prevent 429 flooding + let vpData = null; + if (bgproutesVpCache && (Date.now() - bgproutesVpCacheTs) < BGPROUTES_VP_TTL) { + vpData = bgproutesVpCache; + } else { + vpData = await fetchJSON(BGPROUTES_API_URL + "/vantage_points", { + headers: { "x-api-key": BGPROUTES_API_KEY }, + timeout: 10000, + }); + if (vpData && !vpData.error) { bgproutesVpCache = vpData; bgproutesVpCacheTs = Date.now(); } + } if (vpData && !vpData.error) { const vpList = vpData?.data?.bgp || (Array.isArray(vpData) ? vpData : vpData.data || []); @@ -2048,6 +2846,7 @@ const server = http.createServer(async (req, res) => { try { const ribData = await postJSON(BGPROUTES_API_URL + "/rib", ribBody, { headers: { "x-api-key": BGPROUTES_API_KEY }, + timeout: 6000, }); if (ribData && ribData.data) { @@ -2099,6 +2898,7 @@ const server = http.createServer(async (req, res) => { } result.meta.duration_ms = Date.now() - start; + resultCacheSet(bgproutesResultCache, cacheKeyBgr, result); return res.end(JSON.stringify(result, null, 2)); } catch (err) { res.writeHead(500); @@ -2249,20 +3049,10 @@ const server = http.createServer(async (req, res) => { return { status: listedPrefixes.length === 0 ? "pass" : "fail", checked: results.length, listed_prefixes: listedPrefixes }; }).catch(function(e) { return { status: "error", error: String(e) }; }); - // 16. MANRS Compliance (observatory API requires auth — use fallback indicators) - validationPromises.manrs = fetchJSON("https://observatory.manrs.org/api/v2/asn/" + rawAsn + "/conformance", { timeout: 5000 }).then(function(data) { - if (!data || data.error || data.detail === "Authentication credentials were not provided.") { - // API unavailable — check MANRS indicators: RPKI ROA + IRR objects as proxy - var hasRoa = samplePrefixes.length > 0; // will be checked by RPKI validation - var hasIrr = !!(net.irr_as_set); - if (hasRoa && hasIrr) { - return { status: "info", participant: "unknown", message: "MANRS Observatory API requires authentication — cannot verify membership. Network has ROA + IRR objects (positive indicators).", note: "Unable to verify — MANRS API requires auth. Check https://observatory.manrs.org/asn/" + rawAsn }; - } - return { status: "info", participant: "unknown", message: "Unable to verify MANRS membership (API requires authentication)", note: "Check manually: https://observatory.manrs.org/asn/" + rawAsn }; - } - var score = data.conformance_score || data.score || 0; - return { status: score >= 50 ? "pass" : "warning", participant: true, score: score, details: data }; - }).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable", note: "https://observatory.manrs.org/asn/" + rawAsn }; }); + // 16. MANRS Compliance — scraped from public participants list (24h cache) + validationPromises.manrs = ensureManrsCache().then(function() { + return checkManrsMembership(rawAsn); + }).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable: " + e.message, note: "https://www.manrs.org/netops/participants/" }; }); // 17. Reverse DNS Coverage (sample up to 20 prefixes for better coverage) var rdnsSampleSize = Math.min(20, samplePrefixes.length); @@ -2537,6 +3327,24 @@ const server = http.createServer(async (req, res) => { var healthScore = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0; var duration = Date.now() - start; + // Build relationships from neighbour data + var relNeighbours = neighbourData && neighbourData.data && neighbourData.data.neighbour_counts + ? neighbourData.data.neighbour_counts : {}; + var relList = neighbourData && neighbourData.data && neighbourData.data.neighbours + ? neighbourData.data.neighbours : []; + var relUpstreams = relList.filter(function(n) { return n.type === "left"; }) + .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) + .slice(0, 20) + .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); + var relDownstreams = relList.filter(function(n) { return n.type === "right"; }) + .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) + .slice(0, 20) + .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); + var relPeers = relList.filter(function(n) { return n.type === "uncertain"; }) + .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) + .slice(0, 30) + .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); + return res.end( JSON.stringify( { @@ -2546,6 +3354,14 @@ const server = http.createServer(async (req, res) => { health_score: healthScore, score_breakdown: checkResults, validations: validations, + relationships: { + counts: { upstreams: relNeighbours.left || relUpstreams.length, downstreams: relNeighbours.right || relDownstreams.length, peers: relNeighbours.unique || relPeers.length, uncertain: relNeighbours.uncertain || 0 }, + upstreams: relUpstreams, + downstreams: relDownstreams, + top_peers: relPeers, + source: "RIPE Stat asn-neighbours", + note: "left=upstream providers, right=downstream customers, uncertain=peers. Sorted by power score.", + }, }, null, 2 @@ -2595,19 +3411,47 @@ const server = http.createServer(async (req, res) => { let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey); let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null; + // Per-source timing tracking + const sourceTiming = {}; + function timedFetch(name, promise) { + const ts = Date.now(); + return Promise.resolve(promise) + .then(r => { sourceTiming[name] = Date.now() - ts; return r; }) + .catch(() => { sourceTiming[name] = null; return null; }); + } + + const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null; + + // RDAP: check module-level cache first, only hit RIR endpoints on cache miss + const rdapCached = rdapCacheGet(asn); + const rdapPromise = rdapCached !== undefined + ? Promise.resolve(rdapCached) + : Promise.race([ + ...["https://rdap.db.ripe.net/autnum/"+asn, "https://rdap.arin.net/registry/autnum/"+asn, + "https://rdap.apnic.net/autnum/"+asn, "https://rdap.lacnic.net/rdap/autnum/"+asn, + "https://rdap.afrinic.net/rdap/autnum/"+asn].map(url => + fetchJSON(url, { timeout: 4000 }) + .then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {})) + .catch(() => new Promise(() => {})) + ), + new Promise(resolve => setTimeout(() => resolve(null), 5000)), + ]).then(d => { rdapCacheSet(asn, d); return d; }); + const promises = [ - fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 45000 }), - fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 45000 }), - fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn), - fetchRipeStatCached("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn), - fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500"), - fetchBgpHeNet(asn), - fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=AS" + asn, { timeout: 30000 }), - fetchRipeStatCached("https://stat.ripe.net/data/prefix-size-distribution/data.json?resource=AS" + asn), - cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery), - cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null)), + timedFetch("RIPE Stat Prefixes", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 20000 })), + timedFetch("RIPE Stat Neighbours", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 20000 })), + timedFetch("RIPE Stat Overview", fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn)), + timedFetch("RIPE Stat RIR", fetchRipeStatCached("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn)), + timedFetch("RIPE Atlas", fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500")), + timedFetch("bgp.he.net", fetchBgpHeNet(asn)), + timedFetch("RIPE Stat Visibility", fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=AS" + asn, { timeout: 12000 })), + timedFetch("RIPE Stat PrefixSize", fetchRipeStatCached("https://stat.ripe.net/data/prefix-size-distribution/data.json?resource=AS" + asn)), + timedFetch("PeeringDB IXLan", cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery)), + timedFetch("PeeringDB Facilities", cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))), + timedFetch("PeeringDB Contacts", pocQuery ? fetchPeeringDB(pocQuery).catch(() => null) : Promise.resolve(null)), + timedFetch("RDAP Registration", rdapPromise), ]; - const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData] = await Promise.all(promises); + const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData, pocData, rdapData] = await Promise.all(promises); // Store PDB results in L2 source cache for future lookups if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData); @@ -2639,6 +3483,7 @@ const server = http.createServer(async (req, res) => { ipv4: ix.ipaddr4 || null, ipv6: ix.ipaddr6 || null, city: ix.city || "", + is_rs_peer: ix.is_rs_peer === true, })) .sort((a, b) => b.speed_mbps - a.speed_mbps); @@ -2748,14 +3593,49 @@ const server = http.createServer(async (req, res) => { let rir = ""; let country = ""; + // RIPE Stat rir-stats-country uses 'location' field (not 'country' or 'rir') if (Array.isArray(rirEntries) && rirEntries.length > 0) { + country = rirEntries[0]?.location || rirEntries[0]?.country || ""; rir = rirEntries[0]?.rir || ""; - country = rirEntries[0]?.country || ""; } if (!rir && rirData?.data) { const rirField = rirData.data.rirs || []; if (rirField.length > 0) rir = rirField[0]?.rir || ""; } + // Derive RIR from rdapData.port43 (e.g. "whois.ripe.net" → "RIPE") + if (!rir && rdapData && rdapData.port43) { + const p43 = (rdapData.port43 || "").toLowerCase(); + if (p43.includes("ripe")) rir = "RIPE"; + else if (p43.includes("arin")) rir = "ARIN"; + else if (p43.includes("apnic")) rir = "APNIC"; + else if (p43.includes("lacnic")) rir = "LACNIC"; + else if (p43.includes("afrinic")) rir = "AFRINIC"; + } + // Also derive RIR from RDAP links (URL of the RDAP endpoint that responded) + if (!rir && rdapData && rdapData.links) { + const selfLink = (rdapData.links.find(l => l.rel === "self") || {}).href || ""; + if (selfLink.includes("ripe")) rir = "RIPE"; + else if (selfLink.includes("arin")) rir = "ARIN"; + else if (selfLink.includes("apnic")) rir = "APNIC"; + else if (selfLink.includes("lacnic")) rir = "LACNIC"; + else if (selfLink.includes("afrinic")) rir = "AFRINIC"; + } + // bgp.he.net country_code fallback (for unannounced/reserve ASNs) + if (!country && bgpHeData && bgpHeData.country_code) { + country = bgpHeData.country_code; + } + // Last resort: derive RIR from country code (common assignments) + if (!rir && country) { + const ARIN_CC = new Set(["US","CA","AI","AG","BS","BB","BZ","VG","KY","DM","DO","GD","GP","HT","JM","MQ","MS","PR","KN","LC","VC","TT","TC","VI","UM"]); + const APNIC_CC = new Set(["AU","NZ","JP","CN","KR","IN","HK","SG","TW","VN","TH","ID","MY","PK","BD","LK","NP","PH","AF","KH","LA","MM","MN","BT","BN","FJ","PG","WS","TO","VU","SB","KI","NR","TV","FM","MH","PW","CK","NU","TK","WF","PF","NC","GU","MP","AS","CC","CX","HM","NF"]); + const LACNIC_CC = new Set(["BR","AR","MX","CO","CL","PE","VE","EC","UY","BO","PY","CU","GT","HN","SV","NI","CR","PA","GY","SR","GF","AW","CW","SX","BQ","AN"]); + const AFRINIC_CC = new Set(["ZA","NG","KE","EG","GH","TZ","UG","MA","CI","SN","ZM","ZW","AO","MZ","CM","ET","SD","MG","DZ","TN","LY","RW","NA","BW","MW","ML","BF","NE","GN","TD","SO","LS","SZ","ER","DJ","GM","SL","LR","TG","BJ","GW","CF","CG","CD","GQ","ST","KM","MR","SC","MU","RE","CV","BU","SS","EH"]); + if (ARIN_CC.has(country)) rir = "ARIN"; + else if (APNIC_CC.has(country)) rir = "APNIC"; + else if (LACNIC_CC.has(country)) rir = "LACNIC"; + else if (AFRINIC_CC.has(country)) rir = "AFRINIC"; + else rir = "RIPE"; // Europe + rest = RIPE NCC + } const duration = Date.now() - start; @@ -2941,7 +3821,7 @@ const server = http.createServer(async (req, res) => { const result = { meta: { service: "PeerCortex", - version: "0.5.0", + version: "0.6.9", query: "AS" + asn, duration_ms: duration, sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"], @@ -2951,7 +3831,7 @@ const server = http.createServer(async (req, res) => { }, network: { asn: parseInt(asn), - name: net.name || overview?.holder || "Unknown", + name: net.name || overview?.holder || (bgpHeData && bgpHeData.name_from_title) || "Unknown", aka: net.aka || "", org_name: (net.org && net.org.name) ? net.org.name : "", website: net.website || "", @@ -2964,8 +3844,16 @@ const server = http.createServer(async (req, res) => { peeringdb_id: netId || null, rir: rir, country: country, + city: net.city || "", + latitude: (net.latitude != null) ? net.latitude : null, + longitude: (net.longitude != null) ? net.longitude : null, looking_glass: net.looking_glass || "", route_server: net.route_server || "", + info_prefixes4: net.info_prefixes4 || 0, + info_prefixes6: net.info_prefixes6 || 0, + status: net.status || "", + peeringdb_created: net.created ? net.created.slice(0, 10) : "", + peeringdb_updated: net.updated ? net.updated.slice(0, 10) : "", }, prefixes: { total: prefixes.length, @@ -3004,6 +3892,8 @@ const server = http.createServer(async (req, res) => { list: facilities, }, routing: routingInfo, + resilience_score: computeResilienceScore(upstreams, peers, ixConnections, prefixes), + route_leak: computeRouteLeakDetection(upstreams, downstreams, peers), bgp_he_net: bgpHeData || null, atlas: { total_probes: atlasProbes.length, @@ -3021,6 +3911,42 @@ const server = http.createServer(async (req, res) => { })), }, data_quality: dataQuality, + source_timing: sourceTiming, + contacts: (() => { + const pocs = (pocData && pocData.data) ? pocData.data : []; + return pocs.slice(0, 20).map(p => ({ + role: p.role || "", + name: p.name || "", + email: p.email || "", + url: p.url || "", + visible: p.visible || "", + })); + })(), + registration: (() => { + const events = (rdapData && rdapData.events) ? rdapData.events : []; + const created = (events.find(e => e.eventAction === "registration") || {}).eventDate || ""; + const lastChg = (events.find(e => e.eventAction === "last changed") || {}).eventDate || ""; + return { + created: created ? created.slice(0, 10) : "", + last_modified: lastChg ? lastChg.slice(0, 10) : "", + rir: rir || "", + handle: (rdapData && rdapData.handle) ? rdapData.handle : ("AS" + asn), + rdap_source: (rdapData && rdapData.port43) ? rdapData.port43 : "", + }; + })(), + _provenance: { + prefixes: { source: "RIPE Stat announced-prefixes", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net prefix count daily" }, + neighbours: { source: "RIPE Stat asn-neighbours", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net peer count daily" }, + rpki: { source: "Cloudflare RPKI + RIPE Validator", validation: "cross-validated", confidence: "high", note: "Two independent RPKI sources compared" }, + ix_presence: { source: "PeeringDB netixlan (local mirror)", validation: "cross-validated", confidence: "high", note: "PeeringDB mirror refreshed daily" }, + facilities: { source: "PeeringDB netfac (local mirror)", validation: "single-source", confidence: "medium" }, + bgp_he_net: { source: "bgp.he.net HTML scrape", validation: "single-source", confidence: "medium", note: "HTML scrape, no official API — may have parsing drift" }, + atlas: { source: "RIPE Atlas API", validation: "single-source", confidence: "medium", note: "Probe availability varies by region" }, + resilience_score: { source: "Computed from RIPE Stat + PeeringDB", validation: "computed", confidence: "high", note: "All inputs cross-validated daily" }, + route_leak: { source: "RIPE Stat asn-neighbours heuristic", validation: "heuristic", confidence: "medium", note: "Pattern-based, not real-time — false positives possible" }, + registration: { source: "RDAP (RIR registry)", validation: "single-source", confidence: "high" }, + contacts: { source: "PeeringDB POC API", validation: "single-source", confidence: "medium", note: "Subject to PeeringDB rate limiting" }, + }, }; // Update duration to include cross-check time @@ -3036,6 +3962,82 @@ const server = http.createServer(async (req, res) => { return; } + // ============================================================ + // AS Relationships endpoint: /api/relationships?asn=X + // Returns upstream providers, downstream customers, and peers + // with resolved names. Based on RIPE Stat asn-neighbours. + // ============================================================ + if (reqPath === "/api/relationships") { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); + } + const cacheKey = "relationships:" + rawAsn; + const cached = cacheGet(cacheKey); + if (cached) { + res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); + return res.end(JSON.stringify(cached)); + } + const start = Date.now(); + try { + const neighbourData = await fetchRipeStatCached( + "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn + "&lod=1", + { timeout: 30000 } + ); + const neighbours = (neighbourData && neighbourData.data && neighbourData.data.neighbours) || []; + const counts = (neighbourData && neighbourData.data && neighbourData.data.neighbour_counts) || {}; + + const upstreams = neighbours.filter(n => n.type === "left").sort((a,b) => (b.power||0)-(a.power||0)); + const downstreams = neighbours.filter(n => n.type === "right").sort((a,b) => (b.power||0)-(a.power||0)); + const peers = neighbours.filter(n => n.type === "uncertain").sort((a,b) => (b.power||0)-(a.power||0)); + + // Resolve AS names for top entries (upstreams + downstreams all, top 20 peers) + const toResolve = [...upstreams, ...downstreams, ...peers.slice(0, 20)]; + const resolvedNames = {}; + await Promise.race([ + Promise.all(toResolve.map(n => + fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 }) + .then(r => { if (r && r.data && r.data.holder) resolvedNames[n.asn] = r.data.holder; }) + .catch(() => {}) + )), + new Promise(r => setTimeout(r, 5000)), + ]); + + const fmt = n => ({ + asn: n.asn, + name: resolvedNames[n.asn] || "", + power: n.power || 0, + v4_peers: n.v4_peers || 0, + v6_peers: n.v6_peers || 0, + }); + + const result = { + asn: parseInt(rawAsn), + query_time: new Date().toISOString(), + duration_ms: Date.now() - start, + counts: { + upstreams: counts.left || upstreams.length, + downstreams: counts.right || downstreams.length, + peers_total: counts.unique || peers.length, + uncertain: counts.uncertain || peers.length, + }, + upstreams: upstreams.map(fmt), + downstreams: downstreams.map(fmt), + peers: peers.slice(0, 50).map(fmt), + methodology: "RIPE Stat asn-neighbours API. left=upstream providers (carry your traffic), right=downstream customers (you carry their traffic), uncertain=lateral peers. Sorted by power score (number of prefixes seen via this relationship).", + source_url: "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, + }; + + cacheSet(cacheKey, result, 10 * 60 * 1000); // 10 min cache + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(result, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "Relationships lookup failed", message: err.message })); + } + } + // ============================================================ // Compare endpoint: /api/compare?asn1=X&asn2=Y // ============================================================ @@ -3175,6 +4177,50 @@ const server = http.createServer(async (req, res) => { } + // ============================================================ + // Quick-IX endpoint: /api/quick-ix?asn=X + // Lightweight: only IX connections from PeeringDB, 1h cached + // Used by Peering Recommendations to avoid 20x full lookups + // ============================================================ + if (reqPath === "/api/quick-ix") { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing asn parameter" })); + } + const cached = quickIxCacheGet(rawAsn); + if (cached !== undefined) { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(cached)); + } + try { + const [pdbNetData, pdbIxlanData] = await Promise.all([ + fetchJSON("https://www.peeringdb.com/api/net?asn=" + rawAsn + "&depth=0", { timeout: 5000 }).catch(() => null), + fetchJSON("https://www.peeringdb.com/api/netixlan?asn=" + rawAsn + "&limit=100", { timeout: 6000 }).catch(() => null), + ]); + const netName = pdbNetData?.data?.[0]?.name || ""; + const ixConnections = []; + if (pdbIxlanData && pdbIxlanData.data) { + pdbIxlanData.data.forEach((row) => { + ixConnections.push({ ix_id: row.ixlan_id, name: row.name || "", speed: row.speed || 0 }); + }); + } + // Fall back to RIPE Stat if PeeringDB returns nothing + if (ixConnections.length === 0) { + const rsStat = await fetchRipeStatCached("https://stat.ripe.net/data/ixs/data.json?resource=AS" + rawAsn, { timeout: 5000 }).catch(() => null); + const ixs = rsStat?.data?.ixs || []; + ixs.forEach((ix) => ixConnections.push({ ix_id: ix.ixp_id || 0, name: ix.name || "", speed: 0 })); + } + const result = { asn: parseInt(rawAsn), name: netName, ix_connections: ixConnections }; + quickIxCacheSet(rawAsn, result); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(result)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "quick-ix lookup failed", message: err.message })); + } + } + // ============================================================ // Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000 // ============================================================ @@ -3625,6 +4671,538 @@ const server = http.createServer(async (req, res) => { return res.end(result); } + + // ── Changelog page ───────────────────────────────────────── + if (reqPath === '/changelog') { + try { + const md = fs.readFileSync('/opt/peercortex-app/CHANGELOG.md', 'utf8'); + const lines = md.split('\n'); + let html = ''; + for (const line of lines) { + if (line.startsWith('## ')) { + html += `

${line.slice(3)}

`; + } else if (line.startsWith('### ')) { + html += `

${line.slice(4)}

`; + } else if (line.startsWith('- **')) { + const m = line.replace(/^- \*\*(.+?)\*\*(.*)$/, '$1$2'); + html += `

· ${m}

`; + } else if (line.startsWith('- ')) { + html += `

· ${line.slice(2)}

`; + } else if (line.startsWith('# ')) { + html += `

${line.slice(2)}

`; + } + } + const page = `PeerCortex Changelog + + + +← peercortex.org +${html} +

PeerCortex · v0.5.0 · Open Source · MIT

+`; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.writeHead(200); + return res.end(page); + } catch(e) { + res.writeHead(500); return res.end('Changelog not available'); + } + } + + // ── BGP Community Decoder ──────────────────────────────────── + if (reqPath === '/api/communities') { + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = params.get('asn') || ''; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + try { + const url = `https://stat.ripe.net/data/bgp-state/data.json?resource=AS${asn.replace('AS','')}`; + const data = await fetchJSONWithRetry(url, { timeout: 12000 }); + 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 ───────────────────────────────────────────────── + if (reqPath.startsWith('/api/irr-audit')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = (params.get('asn') || '').replace(/[^0-9]/g,''); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=1800'); + if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); } + try { + // Use NLNOG IRR Explorer — covers RIPE, ARIN, APNIC, RPKI, and all major IRR databases + const nlnogData = await fetchJSONWithRetry( + 'https://irrexplorer.nlnog.net/api/prefixes/asn/AS' + asn, + { timeout: 20000 } + ); + const prefixes = nlnogData && nlnogData.directOrigin || []; + var irrRoutes = []; + var irrDetails = []; + var goodCount = 0; + var warnCount = 0; + var errorCount = 0; + for (var i = 0; i < prefixes.length; i++) { + var pfx = prefixes[i]; + var hasIrr = pfx.irrRoutes && Object.keys(pfx.irrRoutes).length > 0; + var sources = hasIrr ? Object.keys(pfx.irrRoutes) : []; + var cat = pfx.categoryOverall || 'unknown'; + if (hasIrr) irrRoutes.push(pfx.prefix); + irrDetails.push({ + prefix: pfx.prefix, + irr_sources: sources, + rpki_status: pfx.rpkiRoutes && pfx.rpkiRoutes.length ? pfx.rpkiRoutes[0].rpkiStatus : 'not-found', + category: cat, + messages: (pfx.messages || []).map(function(m){ return m.text; }) + }); + if (cat === 'success') goodCount++; + else if (cat === 'warning') warnCount++; + else errorCount++; + } + var actualPfx = prefixes.map(function(p){ return p.prefix; }); + var inBgpNotIrr = actualPfx.filter(function(p){ return !irrRoutes.includes(p); }); + var score = actualPfx.length ? Math.round(irrRoutes.length / actualPfx.length * 100) : 0; + res.writeHead(200); + return res.end(JSON.stringify({ + asn: asn, + irr_routes: irrRoutes, + actual_prefixes: actualPfx, + in_irr_not_bgp: [], + in_bgp_not_irr: inBgpNotIrr, + score: score, + details: irrDetails, + summary: { good: goodCount, warning: warnCount, error: errorCount, total: prefixes.length }, + source: 'NLNOG IRR Explorer' + })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({error:e.message})); + } + } + + // ── AS-SET Expander ─────────────────────────────────────────── + if (reqPath.startsWith('/api/asset-expand')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const setName = params.get('set') || ''; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + if (!setName) { res.writeHead(400); return res.end(JSON.stringify({error:'set required (e.g. AS-FLEXOPTIX)'})); } + try { + async function expandSet(name, depth, visited) { + if (depth > 4 || visited.has(name)) return { asns: [], sets: [] }; + visited.add(name); + const url = `https://rest.db.ripe.net/search.json?query-string=${encodeURIComponent(name)}&type-filter=as-set&flags=no-referenced`; + const data = await fetchJSONWithRetry(url, { timeout: 10000 }); + const asns = [], sets = []; + if (data && data.objects && data.objects.object) { + for (const obj of data.objects.object) { + const attrs = obj.attributes && obj.attributes.attribute || []; + for (const a of attrs) { + if (a.name === 'members') { + for (const m of (a.value || '').split(/[,\s]+/).filter(Boolean)) { + if (/^AS\d+$/i.test(m)) asns.push(m.toUpperCase()); + else if (m.startsWith('AS-')) { sets.push(m); } + } + } + } + } + } + for (const sub of sets.slice(0,10)) { + const sub_r = await expandSet(sub, depth+1, visited); + asns.push(...sub_r.asns); + } + return { asns: [...new Set(asns)], sets }; + } + const visited = new Set(); + const result = await expandSet(setName.toUpperCase(), 0, visited); + result.asns.sort((a,b) => parseInt(a.slice(2)) - parseInt(b.slice(2))); + res.writeHead(200); + return res.end(JSON.stringify({ set: setName.toUpperCase(), count: result.asns.length, asns: result.asns, sub_sets: result.sets })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({error:e.message})); + } + } + + // ── Routing History (prefix table via RIPE Stat routing-history) ── + if (reqPath.startsWith('/api/rpki-history')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = (params.get('asn') || '').replace(/[^0-9]/g,''); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); } + try { + const url = 'https://stat.ripe.net/data/routing-history/data.json?resource=AS' + asn + '&max_rows=100'; + const data = await fetchJSONWithRetry(url, { timeout: 20000 }); + 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) ──────────────── + if (reqPath.startsWith('/api/aspath')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = (params.get('asn') || '').replace(/[^0-9]/g,''); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=300'); + if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); } + try { + // Use RIPE Stat announced-prefixes to get prefixes, then looking-glass for paths + var annUrl = 'https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS' + asn; + var annData = await fetchJSONWithRetry(annUrl, { timeout: 15000 }); + 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 fetchJSONWithRetry(lgUrl, { timeout: 20000 }); + 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) ───────────────────────────────── + if (reqPath.startsWith('/api/looking-glass')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const resource = params.get('prefix') || params.get('asn') || ''; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + if (!resource) { res.writeHead(400); return res.end(JSON.stringify({error:'prefix or asn required'})); } + try { + const url = `https://stat.ripe.net/data/looking-glass/data.json?resource=${encodeURIComponent(resource)}`; + const data = await fetchJSONWithRetry(url, { timeout: 20000 }); + 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 ──────────────────────────────────────── + if (reqPath.startsWith('/api/ix-matrix')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const ixId = (params.get('ix_id') || '').replace(/[^0-9]/g,''); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + if (!ixId) { res.writeHead(400); return res.end(JSON.stringify({error:'ix_id required'})); } + try { + const [netixData, ixData] = await Promise.all([ + fetchJSONWithRetry(`${PEERINGDB_API_URL}/netixlan?ix_id=${ixId}&depth=1&limit=200`, { timeout: 15000 }), + fetchJSONWithRetry(`${PEERINGDB_API_URL}/ix/${ixId}`, { timeout: 10000 }), + ]); + const ix = ixData && ixData.data && ixData.data[0]; + const members = (netixData && netixData.data || []).map(m => ({ + asn: m.asn, name: m.name, speed: m.speed, ipaddr4: m.ipaddr4, ipaddr6: m.ipaddr6, policy: m.policy_general + })); + members.sort((a,b) => (b.speed||0) - (a.speed||0)); + res.writeHead(200); + return res.end(JSON.stringify({ ix_id: ixId, ix_name: ix && ix.name, ix_city: ix && ix.city, members, member_count: members.length })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({error:e.message})); + } + } + + // ── Hijack Subscribe ────────────────────────────────────────── + if (reqPath === '/api/hijack-subscribe' && req.method === 'POST') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + let body = ''; + req.on('data', c => body += c); + req.on('end', async () => { + try { + const { asn, email } = JSON.parse(body); + const asnNum = String(asn).replace(/[^0-9]/g,''); + if (!asnNum) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); } + const subs = loadHijackSubs(); + const exists = subs.find(s => s.asn === asnNum); + if (!exists) { + const prefixes = await checkHijacksForAsn(asnNum); + subs.push({ asn: asnNum, email: email || '', prefixes, subscribed: new Date().toISOString() }); + fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2)); + } + res.writeHead(200); + res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: exists ? exists.prefixes.length : subs[subs.length-1].prefixes.length })); + } catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); } + }); + return; + } + if (reqPath === '/api/hijack-subscribe' && req.method === 'OPTIONS') { + res.setHeader('Access-Control-Allow-Origin','*'); + res.setHeader('Access-Control-Allow-Methods','POST,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers','Content-Type'); + res.writeHead(204); return res.end(); + } + + // ── Hijack Alerts ───────────────────────────────────────────── + if (reqPath.startsWith('/api/hijack-alerts')) { + const params = new URL(req.url, 'http://localhost').searchParams; + const asn = (params.get('asn') || '').replace(/[^0-9]/g,''); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + const allAlerts = loadHijackAlerts(); + const alerts = asn ? allAlerts.filter(a => a.asn === asn) : allAlerts; + const subs = loadHijackSubs(); + const sub = subs.find(s => s.asn === asn); + res.writeHead(200); + return res.end(JSON.stringify({ asn, alerts: alerts.slice(-50), monitoring: !!sub, prefix_count: sub ? sub.prefixes.length : 0 })); + } + + + + // ── Changelog JSON API ──────────────────────────────────────── + if (reqPath === '/changelog-data') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + try { + const md = fs.readFileSync('/opt/peercortex-app/CHANGELOG.md', 'utf8'); + const entries = []; + let current = null; + let currentSection = null; + for (const line of md.split('\n')) { + // Support both ## [0.6.x] — date AND ## v0.6.x — date + const vMatch = line.match(/^## (?:v|\[)?([\d.]+)\]? — (.+)/); + if (vMatch) { + if (current) entries.push(current); + current = { version: vMatch[1], date: vMatch[2].trim(), sections: [] }; + currentSection = null; + continue; + } + const sMatch = line.match(/^### (.+)/); + if (sMatch && current) { + currentSection = { name: sMatch[1], items: [] }; + current.sections.push(currentSection); + continue; + } + const iMatch = line.match(/^- (.+)/); + if (iMatch && currentSection) { + currentSection.items.push(iMatch[1]); + } + } + if (current) entries.push(current); + res.writeHead(200); + return res.end(JSON.stringify(entries)); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({error:e.message})); + } + } + + // ── bio-rd RIB: prefix lookup ────────────────────────────────── + if (reqPath === '/api/rib/prefix') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); } + const params = new URL(req.url, 'http://localhost').searchParams; + const prefix = params.get('prefix') || ''; + const routerParam = params.get('router') || 'default'; + if (!prefix) { res.writeHead(400); return res.end(JSON.stringify({ error: 'prefix required' })); } + try { + const t0 = Date.now(); + const routers = await risClient.getRouters(); + const routerName = (routerParam === 'default' && routers.length > 0) ? routers[0] : routerParam; + const [routes, longer] = await Promise.all([ + risClient.lpm(routerName, prefix), + risClient.getLonger(routerName, prefix), + ]); + res.writeHead(200); + return res.end(JSON.stringify({ + prefix, + router: routerName, + routes: routes || [], + moreSpecifics: (longer || []).slice(0, 20), + source: 'bio-rd-local', + latencyMs: Date.now() - t0, + })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({ error: e.message })); + } + } + + // ── bio-rd RIB: list routers ─────────────────────────────────── + if (reqPath === '/api/rib/routers') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); } + try { + const routers = await risClient.getRouters(); + res.writeHead(200); + return res.end(JSON.stringify({ routers: routers || [], source: 'bio-rd-local' })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({ error: e.message })); + } + } + + // ── bio-rd RIB: dump ─────────────────────────────────────────── + if (reqPath === '/api/rib/dump') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); } + const params = new URL(req.url, 'http://localhost').searchParams; + const router = params.get('router') || ''; + const asnFilter = params.get('asn') ? parseInt(params.get('asn')) : undefined; + const limit = Math.min(parseInt(params.get('limit') || '100', 10), 1000); + if (!router) { res.writeHead(400); return res.end(JSON.stringify({ error: 'router required' })); } + try { + const t0 = Date.now(); + const allRoutes = await risClient.dumpRib(router, 'default', asnFilter); + const routes = (allRoutes || []).slice(0, limit); + res.writeHead(200); + return res.end(JSON.stringify({ + router, + routes, + total: (allRoutes || []).length, + source: 'bio-rd-local', + latencyMs: Date.now() - t0, + })); + } catch(e) { + res.writeHead(500); return res.end(JSON.stringify({ error: e.message })); + } + } + + // ── Prefix Changes ────────────────────────────────────────────── + if (reqPath === '/api/prefix-changes') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, ''); + if (!rawAsn) { res.writeHead(400); return res.end(JSON.stringify({ error: 'Missing ASN' })); } + const fromParam = url.searchParams.get('from'); + const toParam = url.searchParams.get('to'); + const hoursParam = Math.min(parseInt(url.searchParams.get('hours') || '1', 10), 168); + let starttime, endtime; + if (fromParam && toParam) { + starttime = new Date(fromParam).toISOString(); + endtime = new Date(toParam).toISOString(); + } else { + endtime = new Date().toISOString(); + starttime = new Date(Date.now() - hoursParam * 3600000).toISOString(); + } + try { + const updUrl = `https://stat.ripe.net/data/bgp-updates/data.json?resource=AS${rawAsn}&starttime=${encodeURIComponent(starttime)}&endtime=${encodeURIComponent(endtime)}&limit=1000`; + const raw = await fetchJSON(updUrl, { timeout: 25000 }); + 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') { + const rpki = (origin && roaStore.ready) ? roaStore.validate(origin, prefix) : null; + const rpkiStatus = rpki ? rpki.status : 'unknown'; + 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 res.writeHead(404); res.end( @@ -3788,10 +5366,11 @@ roaStore.loadFromDisk("/opt/peercortex-app/.roa-cache.json"); pdbSourceCache.loadFromDisk("/opt/peercortex-app/.pdb-source-cache.json"); loadRipeStatCacheFromDisk("/opt/peercortex-app/.ripe-stat-cache.json"); -// Phase 1: Fetch fresh RPKI feed (ASPA + ROA) + Atlas probes + PDB org countries +// Phase 1: Fetch fresh RPKI feed (ASPA + ROA) + Atlas probes + PDB org countries + MANRS participants +ensureManrsCache(); // fire-and-forget, 24h cache Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => { server.listen(PORT, "0.0.0.0", () => { - console.log("PeerCortex v0.6.0 running on http://0.0.0.0:" + PORT); + console.log("PeerCortex v0.6.1 running on http://0.0.0.0:" + PORT); console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured")); console.log("PeeringDB API key: " + (PEERINGDB_API_KEY ? "configured" : "NOT configured")); console.log("RPKI ASPA objects: " + rpkiAspaMap.size); @@ -3801,6 +5380,48 @@ Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()] }); }); +// ============================================================ +// bio-rd RIB WebSocket — live route streaming on /ws/rib +// ============================================================ +let WebSocketServer = null; +try { WebSocketServer = require('ws').Server; } catch(_e) {} + +if (WebSocketServer) { + const ribWss = new WebSocketServer({ server, path: '/ws/rib' }); + ribWss.on('connection', function(ws) { + let cancelStream = null; + + ws.on('message', function(raw) { + try { + const msg = JSON.parse(raw); + if (msg.type === 'rib-subscribe') { + if (cancelStream) { cancelStream(); cancelStream = null; } + if (!risClient) { + ws.send(JSON.stringify({ type: 'error', error: 'bio-rd RIS not configured' })); + return; + } + const router = msg.router || 'default'; + cancelStream = risClient.observeRib( + router, + 'default', + function(update) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(update)); }, + function(err) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: err.message })); } + ); + } + } catch(e) { + if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: e.message })); + } + }); + + ws.on('close', function() { + if (cancelStream) { cancelStream(); cancelStream = null; } + }); + }); + console.log('[bio-rd] RIB WebSocket server listening on /ws/rib'); +} else { + console.log('[bio-rd] WebSocket server skipped (ws package not installed)'); +} + // ============================================================ // Refresh timers — jittered to avoid thundering herd // ============================================================ diff --git a/public/index.html b/public/index.html index b871288..86fa6aa 100644 --- a/public/index.html +++ b/public/index.html @@ -733,6 +733,36 @@ body.dark .card{border-top-color:#e8e4dc}
+ + +